(in-package #:lispsearch)

;;;; clingon command tree. Global option: --db PATH. The index/refresh commands
;;;; additionally take --registry and --catalog-url (enrichment source).
;;;; Subcommands: index, refresh, search, show, deps, rdeps, stats.

(defun getopt* (cmd key)
  "Look up option KEY on CMD, walking up to parent commands so the global --db
defined on the top command is visible inside sub-command handlers."
  (loop for c = cmd then (clingon:command-parent c)
        while c
        for v = (ignore-errors (clingon:getopt c key))
        when v return v))

(defun %open-db (cmd)
  (let ((db (connect (or (getopt* cmd :db) (namestring *db-path*)))))
    (migrate db)
    db))

(defmacro with-config ((&key registry catalog-url) &body body)
  "Bind the registry / catalog-url config specials for BODY, each overridden only
when a non-NIL value is supplied — so a CLI flag wins, and an absent flag falls
back to the compiled default (the special's value)."
  `(let ((*registry*    (or ,registry *registry*))
         (*catalog-url* (or ,catalog-url *catalog-url*)))
     ,@body))

(defmacro with-cli-db ((db cmd) &body body)
  "Open the DB for a command run, with the global --registry / --catalog-url
overrides bound for the dynamic extent (one chokepoint for every handler)."
  `(with-config (:registry (getopt* ,cmd :registry)
                 :catalog-url (getopt* ,cmd :catalog-url))
     (let ((,db (%open-db ,cmd)))
       (unwind-protect (progn ,@body)
         (sqlite:disconnect ,db)))))

(defun emit-rows (rows &key json names-only (empty "No matches."))
  "Print search-style ROWS as text (or JSON), or the EMPTY message when none."
  (cond (json (write-line (rows->json rows :names-only names-only)))
        ((null rows) (format t "~A~%" empty))
        (t (dolist (row rows)
             (write-line (format-row row :names-only names-only))))))

;;; --- handlers ---------------------------------------------------------------

(defun index/handler (cmd)
  (let ((enrich (not (getopt* cmd :no-enrich)))
        (jobs   (or (getopt* cmd :jobs) *jobs*))
        (limit  (getopt* cmd :limit)))
    (with-cli-db (db cmd)
      (format t "~&Indexing catalog~@[ (limit ~D)~]~:[ (names only)~;~]...~%" limit enrich)
      (multiple-value-bind (loaded enriched) (build-index db :enrich enrich :jobs jobs :limit limit)
        (format t "Loaded ~D system~:P~@[; enriched ~D~].~%" loaded (and enrich enriched))))))

(defun refresh/handler (cmd)
  (let ((max-age (or (getopt* cmd :max-age-days) 30))
        (jobs    (or (getopt* cmd :jobs) *jobs*)))
    (with-cli-db (db cmd)
      (format t "~&Refreshing (re-enrich older than ~D days)...~%" max-age)
      (multiple-value-bind (new re) (refresh-index db :max-age-days max-age :jobs jobs)
        (format t "Added ~D new name~:P; re-enriched ~D.~%" new re)))))

(defun search/handler (cmd)
  (let ((query (str:join " " (clingon:command-arguments cmd))))
    (when (zerop (length (string-trim '(#\Space #\Tab) query)))
      (format *error-output* "error: search requires a QUERY~%")
      (uiop:quit 2))
    (with-cli-db (db cmd)
      (emit-rows (run-search db query
                             :limit (or (getopt* cmd :limit) 20)
                             :names-only (getopt* cmd :names-only))
                 :json (getopt* cmd :json)
                 :names-only (getopt* cmd :names-only)))))

(defun deps/handler (cmd)
  (let ((name (first (clingon:command-arguments cmd))))
    (unless name
      (format *error-output* "error: deps requires a NAME~%")
      (uiop:quit 2))
    (with-cli-db (db cmd)
      (unless (get-system db name)
        (format *error-output* "not found: ~A~%" name)
        (uiop:quit 1))
      (emit-rows (deps-rows db name)
                 :json (getopt* cmd :json)
                 :names-only (getopt* cmd :names-only)
                 :empty "No dependencies recorded (system may not be enriched yet)."))))

(defun rdeps/handler (cmd)
  (let ((name (first (clingon:command-arguments cmd))))
    (unless name
      (format *error-output* "error: rdeps requires a NAME~%")
      (uiop:quit 2))
    (with-cli-db (db cmd)
      (emit-rows (rdeps-rows db name :limit (or (getopt* cmd :limit) 200))
                 :json (getopt* cmd :json)
                 :names-only (getopt* cmd :names-only)
                 :empty "Nothing depends on it (or the catalog isn't enriched yet)."))))

(defun show/handler (cmd)
  (let ((name (first (clingon:command-arguments cmd))))
    (unless name
      (format *error-output* "error: show requires a NAME~%")
      (uiop:quit 2))
    (with-cli-db (db cmd)
      (let ((p (get-system db name)))
        (cond ((null p)
               (format *error-output* "not found: ~A~%" name)
               (uiop:quit 1))
              ((getopt* cmd :json) (write-line (system->json p)))
              (t (write-string (format-show p))))))))

(defun stats/handler (cmd)
  (with-cli-db (db cmd)
    (let ((s (stats db)))
      (format t "~&systems:      ~D~%enriched:     ~D~%last refresh: ~A~%schema:       ~A~%"
              (getf s :total) (getf s :enriched)
              (or (getf s :last-refresh) "-") (or (getf s :schema-version) "-")))))

;;; --- commands ---------------------------------------------------------------

(defun enrichment-source-options ()
  "Options shared by index/refresh that select where enrichment data is fetched
from. Defined on those subcommands (not globally) so they read naturally after
the command, e.g. `lispsearch index --registry ...`."
  (list (clingon:make-option :string :key :registry :long-name "registry"
                             :description "OCI registry + namespace for enrichment (ocicl list / oras pull); default ghcr.io/ocicl")
        (clingon:make-option :string :key :catalog-url :long-name "catalog-url"
                             :description "URL of the authoritative system-list catalog")))

(defun index-command ()
  (clingon:make-command
   :name "index" :description "Cold-build the index from the ocicl catalog."
   :options (append (list (clingon:make-option :flag :description "load names only, skip enrichment"
                                               :long-name "no-enrich" :key :no-enrich)
                          (clingon:make-option :integer :description "parallelism for enrichment"
                                               :long-name "jobs" :initial-value *jobs* :key :jobs)
                          (clingon:make-option :integer :description "only load the first N systems (debug)"
                                               :long-name "limit" :key :limit))
                    (enrichment-source-options))
   :handler #'index/handler))

(defun refresh-command ()
  (clingon:make-command
   :name "refresh" :description "Update the catalog and re-enrich stale records."
   :options (append (list (clingon:make-option :integer :description "re-enrich rows older than N days"
                                               :long-name "max-age-days" :initial-value 30 :key :max-age-days)
                          (clingon:make-option :integer :description "parallelism for enrichment"
                                               :long-name "jobs" :initial-value *jobs* :key :jobs))
                    (enrichment-source-options))
   :handler #'refresh/handler))

(defun search-command ()
  (clingon:make-command
   :name "search" :description "Full-text search the catalog (BM25 ranked)."
   :usage "[options] QUERY..."
   :options (list (clingon:make-option :integer :description "max results"
                                       :long-name "limit" :initial-value 20 :key :limit)
                  (clingon:make-option :flag :description "match names only (fast GLOB)"
                                       :long-name "names-only" :key :names-only)
                  (clingon:make-option :flag :description "output JSON"
                                       :long-name "json" :key :json))
   :handler #'search/handler))

(defun show-command ()
  (clingon:make-command
   :name "show" :description "Show the full record for one system."
   :usage "[--json] NAME"
   :options (list (clingon:make-option :flag :description "output JSON"
                                       :long-name "json" :key :json))
   :handler #'show/handler))

(defun deps-command ()
  (clingon:make-command
   :name "deps" :description "List the direct dependencies of a system."
   :usage "[options] NAME"
   :options (list (clingon:make-option :flag :description "print names only"
                                       :long-name "names-only" :key :names-only)
                  (clingon:make-option :flag :description "output JSON"
                                       :long-name "json" :key :json))
   :handler #'deps/handler))

(defun rdeps-command ()
  (clingon:make-command
   :name "rdeps" :description "List systems that depend on a system (reverse deps)."
   :usage "[options] NAME"
   :options (list (clingon:make-option :integer :description "max results"
                                       :long-name "limit" :initial-value 200 :key :limit)
                  (clingon:make-option :flag :description "print names only"
                                       :long-name "names-only" :key :names-only)
                  (clingon:make-option :flag :description "output JSON"
                                       :long-name "json" :key :json))
   :handler #'rdeps/handler))

(defun stats-command ()
  (clingon:make-command
   :name "stats" :description "Show totals, enrichment progress, last refresh."
   :handler #'stats/handler))

(defun top-command ()
  (clingon:make-command
   :name "lispsearch"
   :description "Index and search the ocicl Common Lisp package catalog."
   :options (list (clingon:make-option :string :description "index database path"
                                       :long-name "db" :key :db
                                       :initial-value (namestring (default-db-path))))
   :sub-commands (list (index-command) (refresh-command) (search-command)
                       (show-command) (deps-command) (rdeps-command)
                       (stats-command))
   :handler (lambda (cmd) (clingon:print-usage-and-exit cmd t))))
