;; 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.tui-views.persistence
  "Load persisted workflow events from disk on TUI startup.

   Scans ~/.miniforge/events/ for workflow event files, reads the first
   and last events from each file, and reconstructs workflow summaries
   for the TUI model. This gives the TUI immediate visibility into past
   workflows without requiring a running event stream.

   Layer 1: Pure data loading, no side effects beyond file I/O."
  (:require
   [ai.miniforge.config.interface :as config]
   [clojure.java.io :as io]
   [clojure.edn :as edn]
   [ai.miniforge.tui-views.model :as model]))

;------------------------------------------------------------------------------ Layer 0
;; Event constructors — canonical shapes consumed by apply-detail-event

(defn workflow-started-event [workflow-id spec]
  {:event/type :workflow/started
   :workflow/id workflow-id
   :workflow/spec spec})

(defn phase-started-event [workflow-id phase]
  {:event/type :workflow/phase-started
   :workflow/id workflow-id
   :workflow/phase phase})

(defn phase-completed-event [workflow-id phase outcome artifacts duration-ms
                             & [{:keys [tokens cost-usd]}]]
  (cond-> {:event/type :workflow/phase-completed
           :workflow/id workflow-id
           :workflow/phase phase
           :phase/outcome outcome
           :phase/artifacts artifacts
           :phase/duration-ms duration-ms}
    tokens (assoc :phase/tokens tokens)
    cost-usd (assoc :phase/cost-usd cost-usd)))

(defn agent-status-event [workflow-id agent-id status message]
  {:event/type :agent/status
   :workflow/id workflow-id
   :agent/id agent-id
   :status/type status
   :message message})

(defn agent-chunk-event [workflow-id delta]
  {:event/type :agent/chunk
   :workflow/id workflow-id
   :chunk/delta delta})

(defn workflow-completed-event [workflow-id status duration-ms evidence-bundle-id
                                & [{:keys [tokens cost-usd]}]]
  (cond-> {:event/type :workflow/completed
           :workflow/id workflow-id
           :workflow/status status
           :workflow/duration-ms duration-ms
           :workflow/evidence-bundle-id evidence-bundle-id}
    tokens (assoc :workflow/tokens tokens)
    cost-usd (assoc :workflow/cost-usd cost-usd)))

(defn workflow-failed-event [workflow-id error]
  {:event/type :workflow/failed
   :workflow/id workflow-id
   :workflow/failure-reason error
   :workflow/error-details {:message error}})

(defn gate-event [workflow-id gate passed? timestamp]
  {:event/type (if passed? :gate/passed :gate/failed)
   :workflow/id workflow-id
   :gate/id gate
   :event/timestamp timestamp})

;; EDN line reading

(def lifecycle-event-types
  #{:workflow/started :workflow/completed :workflow/failed})

(defn safe-read-edn
  "Read a single EDN value from a string, returning nil on parse errors.
   Uses a :default tagged-literal handler so unknown tags (e.g. a stale
   #object from pre-fix events) don't crash the parse."
  [s]
  (try
    (edn/read-string {:default tagged-literal} s)
    (catch Exception _ nil)))

(defn read-events
  "Read all valid EDN events from a workflow file in file order."
  [file]
  (try
    (with-open [rdr (io/reader file)]
      (->> (line-seq rdr)
           (keep safe-read-edn)
           vec))
    (catch Exception _
      [])))

(def ^:private test-spec-names
  "Spec names generated by the test suite — excluded from TUI display."
  #{"test" "test-wf"})

(defn quick-named-workflow?
  "Fast check: read only the first few lines of a file to see if it contains
   a workflow/started event with a real (non-test) named spec. Avoids parsing
   the entire file for test artifacts, anonymous sub-workflows, or phase-only
   traces."
  [^java.io.File file]
  (try
    (with-open [rdr (io/reader file)]
      (loop [n 0]
        (when (< n 5)
          (if-let [line (.readLine rdr)]
            (let [evt (safe-read-edn line)]
              (if (= :workflow/started (:event/type evt))
                (let [spec-name (or (get-in evt [:workflow/spec :name])
                                    (get-in evt [:workflow/spec :workflow/name]))]
                  (boolean (and spec-name
                                (not (contains? test-spec-names spec-name)))))
                (recur (inc n))))
            false))))
    (catch Exception _ false)))

;------------------------------------------------------------------------------ Layer 1
;; Workflow reconstruction helpers

(defn workflow-start-event
  [events]
  (first (filter #(= :workflow/started (:event/type %)) events)))

(defn workflow-terminal-event
  [events]
  (last (filter #(contains? lifecycle-event-types (:event/type %)) events)))

(defn workflow-id-from-events
  [events]
  (some :workflow/id events))

(defn top-level-workflow-events?
  "True when the file contains lifecycle events for a real, named workflow.
   Excludes:
   - Phase-only files (DAG child traces without lifecycle events)
   - Anonymous workflows (no spec name — typically sub-workflows)
   - Test artifacts (spec names like 'test' or 'test-wf')"
  [events]
  (when-let [start (first (filter #(= :workflow/started (:event/type %)) events))]
    (let [spec-name (or (get-in start [:workflow/spec :name])
                        (get-in start [:workflow/spec :workflow/name]))]
      (boolean (and spec-name
                    (not (contains? test-spec-names spec-name)))))))

(defn event-phase
  [event]
  (or (:workflow/phase event) (:phase event)))

(defn phase-status
  [outcome]
  (case outcome
    :failure :failed
    :failed  :failed
    :skipped :skipped
    :success :success
    :running))

(defn update-phase-entry
  [phases phase status duration-ms & [{:keys [tokens cost-usd]}]]
  (let [entry (cond-> {:phase phase :status status}
                duration-ms (assoc :duration-ms duration-ms)
                tokens (assoc :tokens tokens)
                cost-usd (assoc :cost-usd cost-usd))
        idx (first (keep-indexed (fn [i p] (when (= phase (:phase p)) i)) phases))]
    (if idx
      (assoc phases idx (merge (get phases idx) entry))
      (conj (vec phases) entry))))

(defn infer-artifact-type
  "Infer artifact type from map keys when :type is not set."
  [artifact]
  (cond
    (:type artifact)         (:type artifact)
    (:code/files artifact)   :code
    (:plan/tasks artifact)   :plan
    (:test/files artifact)   :test
    (:review/id artifact)    :review
    (:evidence/id artifact)  :evidence
    :else                    :unknown))

(defn infer-artifact-name
  "Derive a display name from artifact data."
  [artifact type]
  (or (:name artifact)
      (:code/summary artifact)
      (:plan/name artifact)
      (str (name type) " artifact")))

(defn normalize-artifact
  [artifact phase]
  (if (map? artifact)
    (let [type (infer-artifact-type artifact)]
      (assoc artifact
             :phase phase
             :type type
             :name (infer-artifact-name artifact type)))
    {:id artifact :phase phase :type :unknown :name (str artifact)}))

(defn nested-dag-artifacts
  "Extract artifacts persisted inside DAG terminal errors/results."
  [event]
  (or (get-in event [:workflow/error-details :dag-result :artifacts])
      (mapcat #(get-in % [:dag-result :artifacts] [])
              (get-in event [:workflow/error-details :errors] []))
      []))

(defn empty-detail
  [workflow-id]
  {:workflow-id workflow-id
   :phases []
   :current-phase nil
   :current-agent nil
   :agent-output ""
   :agents {}
   :evidence nil
   :artifacts []
   :expanded-nodes #{}
   :focused-pane 0
   :selected-pr nil
   :pr-readiness nil
   :pr-risk nil
   :selected-train nil
   :duration-ms nil
   :tokens 0
   :cost-usd 0.0
   :error nil})

(defn ensure-evidence-intent
  [detail event]
  (if-let [spec (:workflow/spec event)]
    (assoc-in detail [:evidence :intent]
              {:description (or (:name spec)
                                (:workflow/name spec))
               :spec spec})
    detail))

(defn append-artifacts
  [detail phase artifacts]
  (if (seq artifacts)
    (update detail :artifacts into (map #(normalize-artifact % phase) artifacts))
    detail))

(defn append-validation-result
  [detail event passed?]
  (update-in detail [:evidence :validation :results]
             (fnil conj [])
             {:gate (:gate/id event)
              :passed? passed?
              :event/type (:event/type event)
              :event/timestamp (:event/timestamp event)}))

(defn- apply-phase-started
  [detail event]
  (let [phase (event-phase event)]
    (-> detail
        (assoc :current-phase phase)
        (update :phases update-phase-entry phase :running nil))))

(defn- apply-phase-completed
  [detail event]
  (let [phase (event-phase event)
        outcome (:phase/outcome event)
        duration-ms (:phase/duration-ms event)
        artifacts (:phase/artifacts event)
        tokens (:phase/tokens event)
        cost-usd (:phase/cost-usd event)]
    (-> detail
        (assoc :current-phase phase)
        (update :phases update-phase-entry phase (phase-status outcome) duration-ms
                {:tokens tokens :cost-usd cost-usd})
        (append-artifacts phase artifacts)
        (cond->
          tokens (update :tokens + tokens)
          cost-usd (update :cost-usd + cost-usd)))))

(defn- apply-agent-started
  [detail event]
  (let [agent (:agent/id event)
        entry {:status :started :message (:message event)}]
    (-> detail
        (assoc :current-agent (assoc entry :agent agent))
        (assoc-in [:agents agent] entry))))

(defn- apply-agent-completed
  [detail event]
  (let [agent (:agent/id event)
        entry {:status :completed :message (:message event)}]
    (-> detail
        (assoc :current-agent (assoc entry :agent agent))
        (assoc-in [:agents agent] entry))))

(defn- apply-agent-failed
  [detail event]
  (let [agent (:agent/id event)
        entry {:status :failed :message (:message event)}]
    (-> detail
        (assoc :current-agent (assoc entry :agent agent))
        (assoc :error (or (:agent/error event) (:message event)))
        (assoc-in [:agents agent] entry))))

(defn- apply-agent-status
  [detail event]
  (let [agent (:agent/id event)
        entry {:status (:status/type event) :message (:message event)}]
    (-> detail
        (assoc :current-agent (assoc entry :agent agent))
        (assoc-in [:agents agent] entry))))

(defn- apply-agent-chunk
  [detail event]
  (update detail :agent-output str (or (:chunk/delta event) "")))

(defn- apply-workflow-completed
  [detail event]
  (-> detail
      (assoc :duration-ms (:workflow/duration-ms event))
      (cond->
        (:workflow/tokens event) (assoc :tokens (:workflow/tokens event))
        (:workflow/cost-usd event) (assoc :cost-usd (:workflow/cost-usd event)))
      (append-artifacts (get detail :current-phase :done) (nested-dag-artifacts event))))

(defn- apply-workflow-failed
  [detail event]
  (-> detail
      (assoc :error (or (:workflow/failure-reason event)
                        (get-in event [:workflow/error-details :message])))
      (append-artifacts (get detail :current-phase :failed) (nested-dag-artifacts event))))

(defn apply-detail-event
  [detail event]
  (case (:event/type event)
    :workflow/started        (ensure-evidence-intent detail event)
    :workflow/phase-started  (apply-phase-started detail event)
    :workflow/phase-completed (apply-phase-completed detail event)
    :agent/started           (apply-agent-started detail event)
    :agent/completed         (apply-agent-completed detail event)
    :agent/failed            (apply-agent-failed detail event)
    :agent/status            (apply-agent-status detail event)
    :agent/chunk             (apply-agent-chunk detail event)
    :gate/started            detail
    :gate/passed             (append-validation-result detail event true)
    :gate/failed             (append-validation-result detail event false)
    :workflow/completed      (apply-workflow-completed detail event)
    :workflow/failed         (apply-workflow-failed detail event)
    detail))

(defn detail-from-events
  [workflow-id events]
  (reduce apply-detail-event (empty-detail workflow-id) events))

(defn workflow-name
  [workflow-id events]
  (or (get-in (workflow-start-event events) [:workflow/spec :name])
      (get-in (workflow-start-event events) [:workflow/spec :workflow/name])
      (str "workflow-" (subs (str workflow-id) 0 8))))

(def ^:private stale-threshold-ms
  "Workflows without a terminal event and no activity for this long are :stale."
  (* 60 60 1000)) ;; 1 hour

(defn derive-status
  "Derive workflow status from the event sequence.
   Accepts an optional file for age-based stale detection."
  ([events] (derive-status events nil))
  ([events file]
   (let [terminal (workflow-terminal-event events)
         last-event (last events)]
     (case (:event/type terminal)
       :workflow/completed (get terminal :workflow/status :success)
       :workflow/failed    :failed
       (let [active? (contains? #{:workflow/phase-completed :workflow/phase-started
                                   :agent/chunk :agent/status :workflow/started}
                                (:event/type last-event))
             stale?  (when (and active? file (.exists ^java.io.File file))
                       (> (- (System/currentTimeMillis) (.lastModified ^java.io.File file))
                          stale-threshold-ms))]
         (if stale? :stale :running))))))

(defn derive-phase
  "Derive current/last phase from events."
  [events]
  (some->> events reverse (keep event-phase) first))

(defn derive-progress
  "Estimate progress from the full event sequence."
  [events]
  (let [status (derive-status events)
        phase-completions (count (filter #(= :workflow/phase-completed (:event/type %)) events))
        last-event (last events)]
    (cond
      (#{:success :failed :cancelled :completed} status) 100
      (pos? phase-completions) (min 95 (* 20 phase-completions))
      (= :workflow/phase-started (:event/type last-event)) 40
      (#{:agent/chunk :agent/status} (:event/type last-event)) 50
      (= :workflow/started (:event/type last-event)) 10
      :else 0)))

(defn event-file->workflow
  "Convert a single event file into a workflow summary map for the model.
   Returns nil if the file cannot be read or parsed."
  [file]
  (try
    (let [events (read-events file)
          workflow-id (workflow-id-from-events events)]
      (when (and workflow-id
                 (seq events)
                 (top-level-workflow-events? events))
        (let [detail     (detail-from-events workflow-id events)
              start      (workflow-start-event events)
              started-at (or (:event/timestamp start)
                             (:event/timestamp (first events)))
              workflow   (model/make-workflow
                          {:id         workflow-id
                           :name       (workflow-name workflow-id events)
                           :status     (derive-status events file)
                           :phase      (derive-phase events)
                           :progress   (derive-progress events)
                           :started-at started-at})]
          (assoc workflow
                 :agents (:agents detail)
                 :gate-results (get-in detail [:evidence :validation :results] [])
                 :duration-ms (:duration-ms detail)
                 :error (:error detail)
                 :detail-snapshot detail))))
    (catch Exception _ nil)))

;------------------------------------------------------------------------------ Layer 2
;; Public API

(defn events-dir
  "Get the events directory path. Returns a java.io.File."
  []
  (io/file (config/miniforge-home) "events"))

(defn workflow-events-file
  "Resolve the persisted event file for a workflow UUID."
  [workflow-id & [{:keys [dir]}]]
  (io/file (or dir (events-dir)) (str workflow-id ".edn")))

(defn load-workflows
  "Scan the events directory and load workflow summaries.

   Uses a two-pass approach for performance at scale (10K+ files):
   1. Fast pre-filter: read first few lines to find named workflows
   2. Full parse: only for files that pass the pre-filter

   Options:
   - :limit    - Maximum number of workflows to load (default 100, most recent first)
   - :dir      - Override events directory (for testing)

   Returns: Vector of workflow summary maps sorted by started-at (newest first)."
  [& [{:keys [limit dir] :or {limit 100}}]]
  (let [events-directory (or dir (events-dir))]
    (if (and (.exists events-directory) (.isDirectory events-directory))
      (let [edn-files (->> (.listFiles events-directory)
                           (filter #(.endsWith (.getName ^java.io.File %) ".edn"))
                           ;; Sort by modification time, newest first
                           (sort-by #(- (.lastModified ^java.io.File %)))
                           ;; Fast pre-filter: read first few lines to find named workflows
                           ;; This is lazy — stops once we have enough hits
                           (filter quick-named-workflow?)
                           (take limit))]
        (->> edn-files
             (pmap event-file->workflow)
             (filter some?)
             (sort-by :started-at #(compare %2 %1))
             vec))
      [])))

(defn load-workflow-detail
  "Load the reconstructed detail snapshot for a single workflow from disk."
  [workflow-id & [{:keys [dir]}]]
  (let [file (workflow-events-file workflow-id {:dir dir})]
    (when (.exists file)
      (let [events (read-events file)]
        (when (seq events)
          (detail-from-events workflow-id events))))))

;------------------------------------------------------------------------------ Layer 2b
;; Workflow index — lightweight cache for fast startup
;;
;; The index stores one summary map per workflow, avoiding the need to re-parse
;; event files on every TUI launch. Stored at ~/.miniforge/events/index.edn.

(defn index-file
  "Returns the index file path."
  [& [{:keys [dir]}]]
  (io/file (or dir (events-dir)) "index.edn"))

(defn workflow->index-entry
  "Extract the indexable fields from a workflow summary map."
  [wf]
  {:id (:id wf)
   :name (:name wf)
   :status (:status wf)
   :phase (:phase wf)
   :progress (:progress wf)
   :started-at (:started-at wf)
   :duration-ms (:duration-ms wf)
   :error (:error wf)
   :gate-results (get wf :gate-results [])})

(defn read-index
  "Read the workflow index from disk. Returns map of {workflow-id -> entry}."
  [& [opts]]
  (let [f (index-file opts)]
    (if (.exists f)
      (try
        (let [entries (edn/read-string (slurp f))]
          (if (map? entries) entries {}))
        (catch Exception _ {}))
      {})))

(defn write-index!
  "Write the workflow index to disk. entries is a map of {workflow-id -> entry}."
  [entries & [opts]]
  (let [f (index-file opts)]
    (.mkdirs (.getParentFile f))
    (spit f (pr-str entries))))

(defn update-index!
  "Update the index from the current set of loaded workflows.
   Merges new entries and updates changed ones."
  [workflows & [opts]]
  (let [existing (read-index opts)
        updated (reduce (fn [idx wf]
                          (assoc idx (:id wf) (workflow->index-entry wf)))
                        existing
                        workflows)]
    (write-index! updated opts)
    updated))

(defn load-workflows-indexed
  "Load workflows using the index for speed.

   Strategy:
   1. Read index from disk (instant)
   2. Scan events dir for files newer than the index
   3. Parse only new/changed files
   4. Merge with index entries
   5. Write updated index

   Returns vector of workflow summary maps sorted by started-at (newest first)."
  [& [{:keys [limit dir] :or {limit 100} :as opts}]]
  (let [events-directory (or dir (events-dir))
        idx-file (index-file opts)
        idx-mtime (if (.exists idx-file) (.lastModified idx-file) 0)
        existing-index (read-index opts)]
    (if (and (.exists events-directory) (.isDirectory events-directory))
      (let [all-edn (->> (.listFiles events-directory)
                         (filter #(and (.endsWith (.getName ^java.io.File %) ".edn")
                                       (not= (.getName ^java.io.File %) "index.edn"))))
            ;; Files newer than the index need re-parsing
            new-files (->> all-edn
                          (filter #(> (.lastModified ^java.io.File %) idx-mtime))
                          (filter quick-named-workflow?))
            ;; Parse only new files
            new-workflows (->> new-files
                              (pmap event-file->workflow)
                              (filter some?))
            ;; Merge: new workflows override index entries
            merged-index (reduce (fn [idx wf]
                                   (assoc idx (:id wf) (workflow->index-entry wf)))
                                 existing-index
                                 new-workflows)
            ;; Build full workflow list from merged index
            all-workflows (->> (vals merged-index)
                              (sort-by :started-at #(compare %2 %1))
                              (take limit)
                              vec)]
        ;; Write updated index in background
        (future (try (write-index! merged-index opts) (catch Exception _ nil)))
        ;; Return workflow maps (index entries are already the right shape)
        (mapv model/make-workflow all-workflows))
      [])))
(defn load-workflows-into-model
  "Load persisted workflows and merge them into the given model.

   Uses the indexed loader for fast startup when an index exists,
   falling back to full scan on first run.

   Arguments:
   - model - The initial TUI model from model/init-model
   - opts  - Options passed to load-workflows

   Returns: Updated model with :workflows populated and :last-updated set."
  [model & [opts]]
  (let [idx-file (index-file opts)
        workflows (if (.exists idx-file)
                    (load-workflows-indexed opts)
                    (let [wfs (load-workflows opts)]
                      ;; First run: build the index
                      (future (try (update-index! wfs opts) (catch Exception _ nil)))
                      wfs))]
    (if (seq workflows)
      (-> model
          (assoc :workflows workflows)
          (assoc :last-updated (java.util.Date.))
          (assoc :flash-message (str "Loaded " (count workflows) " workflows")))
      model)))

;------------------------------------------------------------------------------ Layer 2c
;; Persistent archival — moves event files to archive subdirectory

(defn archive-dir
  "Get the archive directory path."
  [& [{:keys [dir]}]]
  (io/file (or dir (events-dir)) "archive"))

(defn- archive-single-workflow
  "Archive a single workflow by moving its event file and updating the index.
   Returns updated accumulator map with :archived count and :errors vector."
  [{:keys [archived errors]} wf-id events-directory arch-dir opts]
  (let [src (io/file events-directory (str wf-id ".edn"))
        dst (io/file arch-dir (str wf-id ".edn"))]
    (if (.exists src)
      (try
        (.renameTo src dst)
        ;; Remove from index
        (let [idx (read-index opts)]
          (write-index! (dissoc idx wf-id) opts))
        {:archived (inc archived) :errors errors}
        (catch Exception e
          {:archived archived :errors (conj errors (str wf-id ": " (.getMessage e)))}))
      {:archived archived :errors errors})))

(defn archive-workflows!
  "Move event files for the given workflow IDs to the archive directory.
   Returns {:archived N :errors [...]}."
  [workflow-ids & [opts]]
  (let [arch-dir (archive-dir opts)
        events-directory (or (:dir opts) (events-dir))]
    (.mkdirs arch-dir)
    (reduce
     (fn [acc wf-id]
       (archive-single-workflow acc wf-id events-directory arch-dir opts))
     {:archived 0 :errors []}
     workflow-ids)))

;------------------------------------------------------------------------------ Rich Comment
(comment
  ;; Load workflows
  (def wfs (load-workflows {:limit 20}))
  (count wfs)
  (first wfs)

  ;; Load into model
  (def m (load-workflows-into-model (model/init-model)))
  (count (:workflows m))
  (:flash-message m)

  :leave-this-here)
