(ns lazygitclj.git
  "Git operations via shell commands."
  (:require [babashka.process :refer [shell]]
            [clojure.string :as str]))

(defn- sh
  "Run shell command, return stdout or nil on error."
  [& args]
  (try
    (-> (apply shell {:out :string :err :string} args)
        :out
        str/trim-newline)
    (catch Exception _ nil)))

(defn- lines [s]
  (if (str/blank? s) [] (str/split-lines s)))

;; === Status ===

(defn current-branch []
  (sh "git" "branch" "--show-current"))

(defn head-sha []
  (sh "git" "rev-parse" "--short" "HEAD"))

(defn repo-root []
  (sh "git" "rev-parse" "--show-toplevel"))

(defn ahead-behind
  "Returns [ahead behind] commit counts relative to upstream."
  []
  (when-let [out (sh "git" "rev-list" "--left-right" "--count" "HEAD...@{upstream}")]
    (let [[ahead behind] (str/split out #"\t")]
      [(parse-long ahead) (parse-long behind)])))

;; === Files ===

(defn- parse-status-line [line]
  (when (>= (count line) 3)
    (let [index (nth line 0)
          worktree (nth line 1)
          path (subs line 3)]
      {:index index
       :worktree worktree
       :path path})))

(defn status-files
  "Returns list of files with their status."
  []
  (->> (sh "git" "status" "--porcelain")
       lines
       (map parse-status-line)
       (remove nil?)))

(defn staged-files []
  (->> (status-files)
       (filter #(not= (:index %) \space))
       (filter #(not= (:index %) \?))))

(defn unstaged-files []
  (->> (status-files)
       (filter #(or (not= (:worktree %) \space)
                    (= (:index %) \?)))))

(defn untracked-files []
  (->> (status-files)
       (filter #(= (:index %) \?))))

;; === Commits ===

(defn- parse-log-line [line]
  (let [[sha date author subject] (str/split line #"\|" 4)]
    {:sha sha
     :date date
     :author author
     :subject subject}))

(defn recent-commits
  "Returns recent commits."
  ([] (recent-commits 20))
  ([n]
   (->> (sh "git" "log" (str "-" n) "--pretty=format:%h|%cr|%an|%s")
        lines
        (map parse-log-line))))

;; === Branches ===

(defn branches
  "Returns list of local branches."
  []
  (->> (sh "git" "branch" "--format=%(refname:short)")
       lines))

(defn remote-branches []
  (->> (sh "git" "branch" "-r" "--format=%(refname:short)")
       lines))

;; === Actions ===

(defn stage-file [path]
  (sh "git" "add" path))

(defn unstage-file [path]
  (sh "git" "reset" "HEAD" path))

(defn stage-all []
  (sh "git" "add" "-A"))

(defn checkout-branch [branch]
  (sh "git" "checkout" branch))

(defn create-branch [name]
  (sh "git" "checkout" "-b" name))

(defn commit [message]
  (sh "git" "commit" "-m" message))

(defn discard-file [path]
  (sh "git" "checkout" "--" path))

(defn diff-file [path]
  (sh "git" "diff" path))

(defn diff-staged [path]
  (sh "git" "diff" "--cached" path))

(defn pull []
  (sh "git" "pull"))

(defn push []
  (sh "git" "push"))

;; === Diffs ===

(defn diff-unstaged
  "Show unstaged changes for a file, or all if no path given."
  ([] (sh "git" "diff"))
  ([path] (sh "git" "diff" "--" path)))

(defn diff-staged-file
  "Show staged changes for a file, or all if no path given."
  ([] (sh "git" "diff" "--cached"))
  ([path] (sh "git" "diff" "--cached" "--" path)))

(defn show-commit
  "Show commit details and diff."
  [sha]
  (sh "git" "show" "--stat" "--patch" sha))

(defn diff-branch
  "Show diff between current branch and another branch."
  [branch]
  (sh "git" "diff" (str branch "...HEAD") "--stat"))

;; === Reset Operations ===

(defn discard-all-unstaged
  "Discard all unstaged changes (git checkout -- .)."
  []
  (sh "git" "checkout" "--" "."))

(defn clean-untracked
  "Remove untracked files (git clean -fd)."
  []
  (sh "git" "clean" "-fd"))

(defn reset-staged
  "Unstage all staged changes (git reset HEAD)."
  []
  (sh "git" "reset" "HEAD"))

(defn reset-soft
  "Soft reset to previous commit (git reset --soft HEAD~1)."
  []
  (sh "git" "reset" "--soft" "HEAD~1"))

(defn reset-mixed
  "Mixed reset to previous commit (git reset --mixed HEAD~1)."
  []
  (sh "git" "reset" "--mixed" "HEAD~1"))

(defn reset-hard
  "Hard reset to previous commit (git reset --hard HEAD~1)."
  []
  (sh "git" "reset" "--hard" "HEAD~1"))

;; === Commit Operations ===

(defn get-full-sha
  "Get the full SHA for a short SHA."
  [short-sha]
  (sh "git" "rev-parse" short-sha))

(defn revert-commit
  "Create a revert commit for the given SHA.
   Uses --no-edit to avoid interactive prompts."
  [sha]
  (sh "git" "revert" "--no-edit" sha))

(defn cherry-pick-commit
  "Cherry-pick the given commit SHA onto the current branch."
  [sha]
  (sh "git" "cherry-pick" sha))

(defn reword-commit
  "Reword the commit message for a given SHA.
   Note: This only works for the HEAD commit using --amend.
   For non-HEAD commits, interactive rebase is required which
   cannot be automated non-interactively. Returns success only for HEAD."
  [sha new-message]
  (let [head-sha (sh "git" "rev-parse" "--short" "HEAD")]
    (if (= sha head-sha)
      ;; Can only reword HEAD commit non-interactively
      (sh "git" "commit" "--amend" "-m" new-message)
      ;; For non-HEAD commits, we'd need interactive rebase
      ;; which isn't practical in a TUI context
      nil)))

;; === Stash ===

(defn- parse-stash-line [line]
  (when-let [[_ index branch message] (re-matches #"stash@\{(\d+)\}: (On [^:]+|WIP on [^:]+): (.+)" line)]
    {:index (parse-long index)
     :ref (str "stash@{" index "}")
     :branch (str/replace branch #"^(On |WIP on )" "")
     :message message}))

(defn stash-list
  "Returns list of stash entries."
  []
  (->> (sh "git" "stash" "list")
       lines
       (map parse-stash-line)
       (remove nil?)))

(defn stash-push
  "Stash all changes with optional message."
  ([] (sh "git" "stash" "push"))
  ([message] (sh "git" "stash" "push" "-m" message)))

(defn stash-pop
  "Apply stash and remove it from stash list."
  ([] (sh "git" "stash" "pop"))
  ([stash-ref] (sh "git" "stash" "pop" stash-ref)))

(defn stash-apply
  "Apply stash but keep it in stash list."
  ([] (sh "git" "stash" "apply"))
  ([stash-ref] (sh "git" "stash" "apply" stash-ref)))

(defn stash-drop
  "Remove a stash entry from stash list."
  ([] (sh "git" "stash" "drop"))
  ([stash-ref] (sh "git" "stash" "drop" stash-ref)))

(defn stash-show
  "Show stash diff."
  ([] (sh "git" "stash" "show" "-p"))
  ([stash-ref] (sh "git" "stash" "show" "-p" stash-ref)))

(defn stash-branch
  "Create a new branch from a stash entry."
  ([branch-name] (sh "git" "stash" "branch" branch-name))
  ([branch-name stash-ref] (sh "git" "stash" "branch" branch-name stash-ref)))

(defn stash-all
  "Stash all changes (git stash push). Alias for stash-push."
  []
  (sh "git" "stash" "push"))

(defn stash-keep-index
  "Stash all changes but keep staged changes in the index (git stash push --keep-index)."
  []
  (sh "git" "stash" "push" "--keep-index"))

(defn stash-include-untracked
  "Stash all changes including untracked files (git stash push --include-untracked)."
  []
  (sh "git" "stash" "push" "--include-untracked"))

(defn stash-staged
  "Stash only staged changes (git stash push --staged). Requires git 2.35+."
  []
  (sh "git" "stash" "push" "--staged"))

(defn stash-unstaged
  "Stash only unstaged changes.
   Uses double-stash technique: stash with --keep-index (stashes all, keeps staged in worktree),
   then we have only staged changes left. Stash those temporarily, apply the first stash
   to restore all changes, drop the staged stash. Finally stash with --keep-index again
   to get only unstaged in stash."
  []
  ;; Simpler approach: git stash push --keep-index stashes everything but keeps staged in worktree
  ;; The resulting stash contains unstaged changes. But wait - that's not quite right either.
  ;;
  ;; Actually --keep-index: stashes both staged AND unstaged, but leaves staged in worktree.
  ;; So the stash has ALL changes, not just unstaged.
  ;;
  ;; Correct approach for "stash unstaged only":
  ;; 1. Stash all with --keep-index (stash has all, worktree has staged)
  ;; 2. Stash again (stash has staged from worktree)
  ;; 3. Pop stash@{1} (restore all changes to worktree)
  ;; 4. Stash with --keep-index (now stash has all, worktree has staged)
  ;; 5. Drop stash@{1} (the staged-only stash we don't need)
  ;; Result: stash@{0} has all changes (not what we want)
  ;;
  ;; Different approach - stage everything first, then use --staged:
  ;; No, that defeats the purpose.
  ;;
  ;; Best approach: commit staged, stash unstaged, reset soft
  ;; 1. Commit staged changes with temp message
  ;; 2. Stash remaining (unstaged) changes
  ;; 3. Reset --soft HEAD~1 to uncommit but keep staged
  (let [has-staged (not (str/blank? (sh "git" "diff" "--cached" "--stat")))]
    (if has-staged
      (do
        ;; Commit staged temporarily
        (sh "git" "commit" "-m" "__lazygitclj_temp_commit__")
        ;; Stash unstaged changes
        (sh "git" "stash" "push")
        ;; Reset soft to restore staged (uncommit)
        (sh "git" "reset" "--soft" "HEAD~1"))
      ;; No staged changes, just stash everything
      (sh "git" "stash" "push"))))

;; === Branch Operations ===

(defn delete-branch
  "Delete a local branch. Returns nil if branch cannot be deleted (e.g., checked out)."
  [branch]
  (sh "git" "branch" "-d" branch))

(defn delete-branch-force
  "Force delete a local branch."
  [branch]
  (sh "git" "branch" "-D" branch))

(defn rename-branch
  "Rename a branch."
  [old-name new-name]
  (sh "git" "branch" "-m" old-name new-name))

(defn merge-branch
  "Merge a branch into the current branch."
  [branch]
  (sh "git" "merge" branch))

(defn can-fast-forward?
  "Check if branch can be fast-forwarded to its upstream.
   Returns true if branch is behind upstream and can be fast-forwarded."
  [branch]
  (let [upstream (sh "git" "rev-parse" "--abbrev-ref" (str branch "@{upstream}"))
        ;; Get merge-base between branch and upstream
        merge-base (when upstream
                     (sh "git" "merge-base" branch upstream))
        ;; Get the commit SHA of the branch
        branch-sha (sh "git" "rev-parse" branch)]
    ;; Can fast-forward if branch is at the merge-base (i.e., upstream is ahead)
    (and merge-base branch-sha (= merge-base branch-sha))))

(defn fast-forward-branch
  "Fast-forward a branch to its upstream. Works for non-current branches too."
  [branch]
  (let [current (current-branch)]
    (if (= branch current)
      ;; If on the branch, use pull --ff-only
      (sh "git" "pull" "--ff-only")
      ;; If not on the branch, fetch and update ref
      (let [upstream (sh "git" "rev-parse" "--abbrev-ref" (str branch "@{upstream}"))]
        (when upstream
          (sh "git" "fetch" "origin" (str branch ":" branch)))))))

;; === Reflog Operations ===

(defn- parse-reflog-line [line]
  (when-let [[_ sha ref-selector action subject] (re-matches #"([a-f0-9]+) (HEAD@\{\d+\}): ([^:]+): ?(.*)" line)]
    {:sha sha
     :ref ref-selector
     :action action
     :subject (if (str/blank? subject) action subject)}))

(defn reflog
  "Returns reflog entries."
  ([] (reflog 50))
  ([n]
   (->> (sh "git" "reflog" "-n" (str n))
        lines
        (map parse-reflog-line)
        (remove nil?))))

(defn checkout-reflog-entry
  "Checkout a specific reflog entry by its SHA."
  [sha]
  (sh "git" "checkout" sha))

(defn reset-to-reflog
  "Reset to a specific reflog entry (git reset --hard <ref>)."
  [ref]
  (sh "git" "reset" "--hard" ref))

;; === Tags ===

(defn list-tags
  "Returns list of tags."
  []
  (->> (sh "git" "tag" "--sort=-creatordate")
       lines))

(defn create-tag
  "Create a new tag."
  ([name] (sh "git" "tag" name))
  ([name message] (sh "git" "tag" "-a" name "-m" message)))

(defn delete-tag
  "Delete a tag locally."
  [name]
  (sh "git" "tag" "-d" name))

(defn push-tag
  "Push a tag to remote."
  ([name] (push-tag name "origin"))
  ([name remote]
   (sh "git" "push" remote name)))

(defn checkout-tag
  "Checkout a tag (detached HEAD)."
  [name]
  (sh "git" "checkout" name))
