;; 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.self-healing.backend-health
  "Backend health tracking and automatic failover.
   Storage: ~/.miniforge/backend_health.edn"
  (:require
   [clojure.edn :as edn]
   [clojure.java.io :as io]))

;;------------------------------------------------------------------------------ Layer 0
;; Tuning constants + file paths

(def ^:private default-success-rate-threshold
  "Minimum cumulative success rate (`:successful-calls / :total-calls`)
   before a backend is considered degraded and a failover is triggered.
   0.90 = at most 1 failure per 10 cumulative calls tolerated; below
   that the backend is skipped in favor of a healthier one. Picked to
   absorb transient single failures without flapping. (Note: the rate
   is cumulative since last decay, not a sliding window — `maybe-decay-health`
   resets the counters wholesale after 24h of stale data.)"
  0.90)

(def ^:private default-switch-cooldown-ms
  "Minimum interval between automatic backend switches (30 min). After
   a switch fires, the FROM backend is parked for this long even if its
   success rate recovers — prevents two flaky backends from oscillating
   between healthy and degraded."
  (* 30 60 1000))

(def ^:private default-failure-recency-window-ms
  "Window during which a recent failure is considered fresh enough to
   trigger a switch decision (5 min). The stored `:last-failure`
   timestamp is compared against this window; older failures stay in
   the cumulative counter for accounting but do not by themselves
   cause a switch — stale failure data shouldn't trigger live failover."
  (* 5 60 1000))

;; File paths and utilities

(defn backend-health-path
  "Get path to backend health tracking file.

   Returns: String path to ~/.miniforge/backend_health.edn"
  []
  (let [home (System/getProperty "user.home")
        miniforge-dir (io/file home ".miniforge")]
    (.getPath (io/file miniforge-dir "backend_health.edn"))))

(defn ensure-directory-exists
  "Ensure parent directory exists for a file path.

   Arguments:
     file-path - String path to file

   Returns: nil"
  [file-path]
  (let [parent-dir (.getParentFile (io/file file-path))]
    (when-not (.exists parent-dir)
      (.mkdirs parent-dir))))

(defn safe-read-edn
  "Safely read EDN from file, returning default on error.

   Arguments:
     file-path - String path to file
     default - Default value if read fails

   Returns: Parsed EDN or default"
  [file-path default]
  (try
    (when (.exists (io/file file-path))
      (edn/read-string (slurp file-path)))
    (catch Exception _
      default)))

(defn atomic-write-edn
  "Atomically write EDN to file using temp file + rename.

   Arguments:
     file-path - String path to file
     data - Data to write as EDN

   Returns: nil"
  [file-path data]
  (ensure-directory-exists file-path)
  (let [temp-file (str file-path ".tmp")]
    (spit temp-file (pr-str data))
    (.renameTo (io/file temp-file) (io/file file-path))))

;;------------------------------------------------------------------------------ Layer 1
;; Default health data structure

(defn default-health-data
  "Create default health data structure.

   Returns: Map with default backends, cooldowns, and fallback order"
  []
  {:backends {}
   :switch-cooldowns {}
   :default-backend :anthropic
   :fallback-order [:anthropic :openai :codex :ollama :google]})

;;------------------------------------------------------------------------------ Layer 2
;; Backend health operations

(def ^:private decay-threshold-ms
  "Maximum age of health data before counters are reset (24 hours)."
  86400000)

(defn- maybe-decay-health
  "Reset backend counters if all recorded last-failure timestamps are older than
   24 hours. Prevents accumulation of stale metrics over weeks of use.

   Backends with no last-failure (nil) are healthy and never trigger decay.
   Decay only fires when at least one backend has a last-failure, and ALL
   last-failure timestamps are older than the threshold.

   Arguments:
     health-data - Map with :backends etc.

   Returns: health-data unchanged, or with backends reset if stale"
  [health-data]
  (let [backends (:backends health-data)
        now (java.time.Instant/now)
        failure-timestamps (->> backends
                                vals
                                (keep :last-failure))
        has-failures? (seq failure-timestamps)
        all-failures-stale? (and has-failures?
                                 (every?
                                  (fn [lf]
                                    (let [age-ms (.until (java.time.Instant/parse lf)
                                                         now
                                                         java.time.temporal.ChronoUnit/MILLIS)]
                                      (> age-ms decay-threshold-ms)))
                                  failure-timestamps))]
    (if all-failures-stale?
      (assoc health-data :backends {})
      health-data)))

(defn load-health
  "Load backend health data from persistent storage.
   Applies decay: if all backend metrics are older than 24 hours, resets counters.

   Returns: Map with :backends, :switch-cooldowns, :default-backend, :fallback-order"
  []
  (let [raw (or (safe-read-edn (backend-health-path) nil)
                (default-health-data))]
    (maybe-decay-health raw)))

(defn save-health!
  "Save backend health data to persistent storage.

   Arguments:
     health-data - Map with :backends, :switch-cooldowns, etc.

   Returns: nil"
  [health-data]
  (atomic-write-edn (backend-health-path) health-data))

(defn reset-backend-health!
  "Reset all backend health data to defaults.
   Useful for clearing stale accumulated metrics.

   Returns: Default health data map"
  []
  (let [defaults (default-health-data)]
    (save-health! defaults)
    defaults))

(defn record-backend-call!
  "Record a backend API call and its result.

   Arguments:
     backend - Keyword backend name (:anthropic, :openai, etc.)
     success? - Boolean indicating if call succeeded

   Returns: Updated backend health map"
  [backend success?]
  (let [health (load-health)
        backend-key (keyword backend)
        current-stats (get-in health [:backends backend-key]
                              {:total-calls 0
                               :successful-calls 0
                               :success-rate 0.0
                               :last-failure nil})
        new-total (inc (:total-calls current-stats))
        new-successful (if success?
                        (inc (:successful-calls current-stats))
                        (:successful-calls current-stats))
        new-success-rate (if (> new-total 0)
                          (double (/ new-successful new-total))
                          0.0)
        updated-stats (-> current-stats
                         (assoc :total-calls new-total)
                         (assoc :successful-calls new-successful)
                         (assoc :success-rate new-success-rate)
                         (assoc :last-failure (when-not success?
                                                (str (java.time.Instant/now)))))
        new-health (assoc-in health [:backends backend-key] updated-stats)]
    (save-health! new-health)
    updated-stats))

(defn get-backend-success-rate
  "Get current success rate for a backend.

   Arguments:
     backend - Keyword backend name

   Returns: Double success rate (0.0-1.0) or nil if no data"
  [backend]
  (let [health (load-health)
        backend-key (keyword backend)
        stats (get-in health [:backends backend-key])]
    (:success-rate stats)))

(defn- recent-failure?
  "Check if the last failure for a backend is within the recency window.

   Arguments:
     backend - Keyword backend name
     recency-ms - Recency window in milliseconds (default `default-failure-recency-window-ms`)

   Returns: Boolean true if last failure is recent (or nil if no failure recorded)"
  ([backend]
   (recent-failure? backend default-failure-recency-window-ms))
  ([backend recency-ms]
   (let [health (load-health)
         backend-key (keyword backend)
         last-failure (get-in health [:backends backend-key :last-failure])]
     (when last-failure
       (let [failure-instant (java.time.Instant/parse last-failure)
             now (java.time.Instant/now)
             age-ms (.until failure-instant now java.time.temporal.ChronoUnit/MILLIS)]
         (< age-ms recency-ms))))))

(defn should-switch-backend?
  "Check if backend should be switched due to low success rate.
   Only triggers if the last failure is recent (within 5 minutes),
   preventing false positives from stale accumulated health data.

   Arguments:
     backend - Keyword backend name
     threshold - Double threshold (default `default-success-rate-threshold`)

   Returns: Boolean true if should switch"
  ([backend]
   (should-switch-backend? backend default-success-rate-threshold))
  ([backend threshold]
   (if-let [success-rate (get-backend-success-rate backend)]
     (and (recent-failure? backend)
          (< success-rate threshold))
     false)))

(defn in-cooldown?
  "Check if backend is in cooldown period after a switch.

   Arguments:
     backend - Keyword backend name
     cooldown-ms - Cooldown period in milliseconds (default `default-switch-cooldown-ms`)

   Returns: Boolean true if in cooldown"
  ([backend]
   (in-cooldown? backend default-switch-cooldown-ms))
  ([backend cooldown-ms]
   (let [health (load-health)
         backend-key (keyword backend)
         cooldown-until (get-in health [:switch-cooldowns backend-key])]
     (when cooldown-until
       (let [now (java.time.Instant/now)
             instant (if (string? cooldown-until)
                       (java.time.Instant/parse cooldown-until)
                       cooldown-until)
             cooldown-end (.plusMillis instant cooldown-ms)]
         (.isBefore now cooldown-end))))))

(defn select-best-backend
  "Select the best available backend that is not unhealthy or in cooldown.

   Arguments:
     current-backend - Keyword current backend
     threshold       - Success rate threshold (default `default-success-rate-threshold`)
     cooldown-ms     - Cooldown period in milliseconds (default `default-switch-cooldown-ms`)
     allowed-set     - Optional set of keyword backends to restrict selection.
                       When `nil` (not supplied), all backends in the stored
                       fallback-order are considered. When a non-nil empty
                       set is supplied, the caller has explicitly restricted
                       to *no* allowed backends — returns nil immediately
                       rather than silently expanding to the full
                       fallback-order. When non-empty, only backends in the
                       intersection of allowed-set and fallback-order are
                       eligible, preserving the global priority ordering.

   Returns: Keyword backend name or nil if none available"
  ([current-backend]
   (select-best-backend current-backend
                        default-success-rate-threshold
                        default-switch-cooldown-ms
                        nil))
  ([current-backend threshold cooldown-ms]
   (select-best-backend current-backend threshold cooldown-ms nil))
  ([current-backend threshold cooldown-ms allowed-set]
   (if (and (some? allowed-set) (empty? allowed-set))
     nil
     (let [health         (load-health)
           fallback-order (:fallback-order health)
           current-key    (keyword current-backend)
           candidates     (if (seq allowed-set)
                            (filter allowed-set fallback-order)
                            fallback-order)]
       (first
        (filter
         (fn [backend]
           (and (not= backend current-key)
                (not (in-cooldown? backend cooldown-ms))
                (if-let [rate (get-backend-success-rate backend)]
                  (>= rate threshold)
                  true))) ;; No data = eligible
         candidates))))))

(defn trigger-backend-switch!
  "Trigger a backend switch and record cooldown.

   Arguments:
     from-backend - Keyword current backend
     to-backend - Keyword new backend
     cooldown-ms - Cooldown period in milliseconds (default `default-switch-cooldown-ms`)

   Returns: Map with :from, :to, :cooldown-until"
  ([from-backend to-backend]
   (trigger-backend-switch! from-backend to-backend default-switch-cooldown-ms))
  ([from-backend to-backend cooldown-ms]
   (let [health (load-health)
         now (java.time.Instant/now)
         from-key (keyword from-backend)
         to-key (keyword to-backend)
         cooldown-until-str (str now)
         updated-health (-> health
                           (assoc-in [:switch-cooldowns from-key] cooldown-until-str)
                           (assoc :default-backend to-key))]
     (save-health! updated-health)
     {:from from-key
      :to to-key
      :cooldown-until now
      :cooldown-ms cooldown-ms})))

;;------------------------------------------------------------------------------ Layer 3
;; High-level health check

(defn check-and-switch-if-needed
  "Check current backend health and switch if necessary.

   Arguments:
     current-backend - Keyword current backend
     threshold - Success rate threshold (default `default-success-rate-threshold`)
     cooldown-ms - Cooldown period in milliseconds (default `default-switch-cooldown-ms`)

   Returns: Map with :should-switch?, :from, :to, or nil if no switch"
  ([current-backend]
   (check-and-switch-if-needed current-backend
                                default-success-rate-threshold
                                default-switch-cooldown-ms))
  ([current-backend threshold cooldown-ms]
   (when (and (should-switch-backend? current-backend threshold)
              (not (in-cooldown? current-backend cooldown-ms)))
     (when-let [new-backend (select-best-backend current-backend threshold cooldown-ms)]
       (let [switch-result (trigger-backend-switch! current-backend new-backend cooldown-ms)]
         (assoc switch-result :should-switch? true))))))
