;;; ai-code-git.el --- Git operations for AI Code -*- lexical-binding: t; -*-

;; Author: Kang Tu <tninja@gmail.com>

;; SPDX-License-Identifier: Apache-2.0

;;; Commentary:
;; This file provides git operation functionality for the AI Code package.

;;; Code:

(require 'magit)
(require 'seq)

(require 'ai-code-input)
(require 'ai-code-task)
(require 'ai-code-prompt-mode)

(declare-function helm-gtags-create-tags "helm-gtags" (dir &optional label))
(declare-function magit-anything-modified-p "magit" ())
(declare-function magit-branch-p "magit" (branch))
(declare-function magit-call-git "magit-git" (&rest args))
(declare-function magit-diff-visit-directory "magit-diff" (directory))
(declare-function magit-git-lines "magit-git" (&rest args))
(declare-function magit-git-output "magit-git" (&rest args))
(declare-function magit-git-string "magit-git" (&rest args))
(declare-function magit-git-success "magit-git" (&rest args))
(declare-function magit-list-local-branch-names "magit-git" ())
(declare-function magit-list-remote-branch-names "magit-git" (&optional remote relative))
(declare-function magit-read-starting-point "magit-git" (prompt &optional branch default))
(declare-function magit-read-string-ns "magit-base" (prompt &optional initial-input history default-value inherit-input-method))
(declare-function magit-rev-verify "magit-git" (rev))
(declare-function magit-run-git "magit-git" (&rest args))
(declare-function magit-worktree-status "magit-worktree" ())
(declare-function magit-worktree-delete "magit-worktree" (worktree))
(declare-function difftastic-magit-diff "difftastic" ())
(declare-function ai-code-pull-or-review-diff-file "ai-code-github" ())
 (declare-function ai-code--open-git-web-compare "ai-code-github" (start end))
 (declare-function ai-code--open-git-web-commit "ai-code-github" (commit))

(defcustom ai-code-init-project-gtags-label "pygments"
  "Default label passed to Helm-Gtags when initializing a project.
Candidate values:
- \\='default
- \\='native
- \\='ctags
- \\='new-ctags
- \\='pygments"
  :type 'string
  :group 'ai-code)

(defcustom ai-code-git-worktree-root
  (expand-file-name "ai-code-worktrees" user-emacs-directory)
  "Directory used to host centralized Git worktrees for all repositories."
  :type 'directory
  :group 'ai-code)

(declare-function ai-code--insert-prompt "ai-code-prompt-mode" (prompt-text))
(declare-function ai-code--ensure-files-directory "ai-code-utils" ())
(declare-function ai-code--git-root "ai-code-utils" (&optional dir))
(declare-function ai-code--generate-task-filename "ai-code-task" (task-name))
(declare-function ai-code--initialize-task-file-content "ai-code-task" (task-name task-url))
(declare-function ai-code--set-session-project-root "ai-code-utils" (root))

(defvar ai-code-files-dir-name)

(defun ai-code--git-ignored-repo-file-p (file root)
  "Return non-nil when FILE should be ignored for repo candidates under ROOT."
  (when (and file root)
    (let ((ignore-dir (file-truename (expand-file-name ai-code-files-dir-name root)))
          (truename (file-truename file)))
      (string-prefix-p ignore-dir truename))))

(defun ai-code--require-current-branch ()
  "Return the current branch name or signal a user error."
  (or (magit-get-current-branch)
      (user-error "Current branch is not available")))

(defun ai-code--normalize-branch-name (branch)
  "Normalize BRANCH for display and user defaults."
  (when branch
    (replace-regexp-in-string
     "\\`refs/heads/\\|\\`refs/remotes/[^/]+/\\|\\`origin/"
     ""
     branch)))

(defun ai-code--remote-default-branch ()
  "Return the normalized default branch for origin, or nil when unavailable."
  (ai-code--normalize-branch-name
   (ignore-errors
     (magit-git-string "symbolic-ref" "--quiet" "--short" "refs/remotes/origin/HEAD"))))

(defun ai-code--strip-remote-prefix (ref)
  "Strip a configured remote prefix from remote-tracking REF.
REF is a name below `refs/remotes/'.  Match against the configured remote
names rather than splitting on the first path segment, because a remote name
may itself contain a slash while a branch name may contain several."
  (let ((remotes (ignore-errors (magit-git-lines "remote"))))
    (or (seq-some (lambda (remote)
                    (let ((prefix (concat remote "/")))
                      (when (and (string-prefix-p prefix ref)
                                 (> (length ref) (length prefix)))
                        (substring ref (length prefix)))))
                  ;; Longest remote name first, so that a remote named "grp/sub"
                  ;; wins over a remote named "grp".
                  (sort (copy-sequence remotes)
                        (lambda (a b) (> (length a) (length b)))))
        ref)))

(defun ai-code--branch-creation-source (branch)
  "Return the raw ref BRANCH was created from, or nil when unknown.
Read the oldest \"branch: Created from REF\" entry in the reflog of BRANCH.
Git does not record a branch parent, so this is the only available record and
it disappears once the reflog expires (see `gc.reflogExpire')."
  (when (and branch (not (string-empty-p branch)))
    (let* ((entries (ignore-errors
                      (magit-git-lines "reflog" "show" "--format=%gs" branch)))
           ;; The creation entry is the oldest one; later resets and rebases add
           ;; newer entries that must not be mistaken for the creation record.
           (creation-entry
            (car (last (seq-filter
                        (lambda (entry)
                          (string-match-p "\\`branch: Created from " entry))
                        entries)))))
      (when creation-entry
        (let ((source (string-trim
                       (substring creation-entry
                                  (length "branch: Created from ")))))
          (unless (string-empty-p source) source))))))

(defun ai-code--creation-source-parent-branch (source)
  "Return the bare branch name SOURCE refers to, or nil when it is not a branch.
SOURCE is a raw creation ref recorded in a reflog.  Resolve it with
`git rev-parse --symbolic-full-name', which dereferences a symbolic ref such as
`origin/HEAD' to the branch it points at."
  ;; A bare HEAD records "created from wherever HEAD was", which carries no
  ;; parent information: it resolves to whatever branch happens to be checked
  ;; out now rather than to the fork point being looked up.
  (unless (string= source "HEAD")
    (when-let* ((full-ref (ignore-errors
                            (magit-git-string "rev-parse"
                                              "--symbolic-full-name"
                                              source))))
      (cond
       ((string-prefix-p "refs/heads/" full-ref)
        (substring full-ref (length "refs/heads/")))
       ((string-prefix-p "refs/remotes/" full-ref)
        (ai-code--strip-remote-prefix
         (substring full-ref (length "refs/remotes/"))))
       ;; Tags and other namespaces are not usable PR targets.
       (t nil)))))

(defun ai-code--branch-parent-branch (branch)
  "Return the branch BRANCH was created from, or nil when not determinable.
Only refs that resolve inside a branch namespace qualify, so raw commits, tags,
and deleted branches are rejected.  Return a bare branch name, because a PR base
takes a branch name rather than a remote-qualified one."
  (when-let* ((source (ai-code--branch-creation-source branch))
              (parent (ai-code--creation-source-parent-branch source)))
    ;; Checking out an existing remote branch records its own remote-tracking
    ;; ref as the creation source, which would make BRANCH its own PR target.
    (unless (or (string-empty-p parent)
                (string= parent branch))
      parent)))

(defun ai-code--default-pr-target-branch (current-branch)
  "Return the default PR target branch for CURRENT-BRANCH."
  (let* ((parent-branch
          (ai-code--branch-parent-branch current-branch))
         (upstream-branch
          (ignore-errors
            (magit-git-string "rev-parse"
                              "--abbrev-ref"
                              "--symbolic-full-name"
                              "@{upstream}")))
         (remote-default-branch
          (ai-code--remote-default-branch))
         (normalized-upstream
          (ai-code--normalize-branch-name upstream-branch)))
    (cond
     ;; The branch this one was forked from is the most likely PR target.
     (parent-branch parent-branch)
     ((and normalized-upstream
           (not (string-empty-p normalized-upstream))
           (not (string= normalized-upstream current-branch)))
      normalized-upstream)
     ((and remote-default-branch
           (not (string-empty-p remote-default-branch))
           (not (string= remote-default-branch current-branch)))
      remote-default-branch)
     ((or (magit-branch-p "main") (magit-branch-p "origin/main"))
      "main")
     ((or (magit-branch-p "master") (magit-branch-p "origin/master"))
      "master")
     (t nil))))

(defun ai-code--validate-git-repository ()
  "Validate that current directory is in a git repository.
Return the git root directory if valid, otherwise signal an error."
  (let ((git-root (ai-code--git-root)))
    (if git-root
        git-root
      (user-error "Not in a git repository"))))

(defun ai-code--git-current-changes-p ()
  "Return non-nil when the current repository needs a commit."
  (let ((status (magit-git-output "status" "--porcelain")))
    (and status (not (string-empty-p status)))))

(defun ai-code--git-generate-commit-message (diff)
  "Generate a concise commit message from staged DIFF."
  (unless (fboundp 'ai-code-call-gptel-sync)
    (user-error "GPTel commit message generation is not available"))
  (let ((message
         (ai-code-call-gptel-sync
          (concat
           "Generate a concise Git commit message for the staged diff below.\n\n"
           "Return only the commit message. Use imperative mood. Keep the first "
           "line under 72 characters. Do not use Markdown.\n\n"
           diff))))
    (setq message (and message (string-trim message)))
    (unless (and message (not (string-empty-p message)))
      (user-error "Could not generate commit message"))
    message))

(defun ai-code--git-default-push-remote ()
  "Return a suitable remote name for pushing the current branch."
  (cond
   ((ignore-errors (magit-git-string "remote" "get-url" "origin"))
    "origin")
   ((car (magit-git-lines "remote")))
   (t
    (user-error "No Git remote configured"))))

(defun ai-code--git-push-current-branch ()
  "Push the current branch, setting an upstream when needed."
  (let ((branch (ai-code--require-current-branch)))
    (if (ignore-errors
          (magit-git-string "rev-parse"
                            "--abbrev-ref"
                            "--symbolic-full-name"
                            "@{upstream}"))
        (unless (zerop (magit-call-git "push"))
          (user-error "Git push failed"))
      (let ((remote (ai-code--git-default-push-remote)))
        (unless (zerop (magit-call-git "push" "-u" remote branch))
          (user-error "Git push failed"))))))

;;;###autoload
(defun ai-code-git-commit-current-changes ()
  "Commit the current working tree state and optionally push it.
Prompt for a commit message first.  When the message is empty, stage all
changes, generate a message from the exact staged diff, and let the user edit
that generated message before committing.  Finally ask whether to push the
current branch to its remote."
  (interactive)
  (let* ((root (ai-code--validate-git-repository))
         (default-directory root))
    (unless (ai-code--git-current-changes-p)
      (user-error "No changes to commit"))
    (let ((commit-message
           (ai-code-read-string "Commit message (empty = AI generate): ")))
      (unless (zerop (magit-call-git "add" "-A"))
        (user-error "Git add failed"))
      (when (string-empty-p (or commit-message ""))
        (let* ((diff (magit-git-output "diff" "--cached"))
               (generated (ai-code--git-generate-commit-message diff)))
          (setq commit-message
                (ai-code-read-string "Commit message: " generated))))
      (setq commit-message (string-trim (or commit-message "")))
      (when (string-empty-p commit-message)
        (user-error "Commit message cannot be empty"))
      (unless (zerop (magit-call-git "commit" "-m" commit-message))
        (user-error "Git commit failed"))
      (message "Committed: %s" commit-message)
      (when (y-or-n-p "Push to remote? ")
        (ai-code--git-push-current-branch)))))

(defun ai-code--validate-buffer-file ()
  "Validate that current buffer is visiting a file.
Signal an error if not visiting a file."
  (unless buffer-file-name
    (user-error "Buffer is not visiting a file")))

(defun ai-code--get-full-branch-ref (branch)
  "Get full reference for BRANCH, handling remote branches properly.
Prefer remote branch (origin/BRANCH) if it exists.
Otherwise, use local branch or ref.
Git can diff remote branches directly without checking them out locally."
  (cond
   ;; Check if it exists as a remote branch first, unless 'branch' already starts with 'origin/'
   ((and (not (string-prefix-p "origin/" branch)) (magit-branch-p (concat "origin/" branch)))
    (concat "origin/" branch))
   ;; Then check if it's a valid local branch or ref (this will also handle cases like "origin/main" directly)
   ((or (magit-branch-p branch) (magit-rev-verify branch))
    branch)
   ;; Return as is (might be a commit hash or special ref if not caught above)
   (t branch)))

(defun ai-code--verify-branches (base-branch feature-branch)
  "Verify that BASE-BRANCH and FEATURE-BRANCH exist.
Signal an error if either branch doesn't exist."
  ;; Verify base branch exists
  (unless (or (magit-branch-p base-branch)
              (magit-branch-p (concat "origin/" base-branch))
              (magit-rev-verify base-branch))
    (user-error "Base branch '%s' not found locally or in remotes" base-branch))
  ;; Verify feature branch exists (if not HEAD)
  (when (and (not (string= feature-branch "HEAD"))
             (not (magit-branch-p feature-branch))
             (not (magit-branch-p (concat "origin/" feature-branch)))
             (not (magit-rev-verify feature-branch)))
    (user-error "Feature branch '%s' not found locally or in remotes" feature-branch)))

(defun ai-code--generate-staged-diff (diff-file)
  "Generate diff for staged (staged) change and save to DIFF-FILE."
  (message "Generating diff for staged (staged) changes...")
  (magit-run-git "diff" "--cached" (concat "--output=" diff-file)))

(defun ai-code--resolve-diff-branches (type input-base-branch input-feature-branch &optional branch-scope)
  "Resolve base and feature branches for diff generation.
TYPE is `'commit`, `'base-vs-head`, or `'branch-range`.
INPUT-BASE-BRANCH and INPUT-FEATURE-BRANCH are user-provided names.
BRANCH-SCOPE is `'local` or `'remote`, used for `'branch-range`.
Returns a cons cell (RESOLVED-BASE . RESOLVED-FEATURE)."
  (let (resolved-base-branch resolved-feature-branch)
    (pcase type
      ('commit
       ;; Input is already commit^ and commit
       (setq resolved-base-branch input-base-branch)
       (setq resolved-feature-branch input-feature-branch))
      ('base-vs-head
       ;; Base branch can be local or remote, feature is HEAD
       (setq resolved-base-branch (ai-code--get-full-branch-ref input-base-branch))
       (setq resolved-feature-branch "HEAD")) ; HEAD is always resolved correctly by git
      ('branch-range
       (pcase branch-scope
         ('local
          ;; User asserts branches are local
          (setq resolved-base-branch input-base-branch)
          (setq resolved-feature-branch input-feature-branch))
         ('remote
          ;; For remote scope, explicitly try to use origin/ prefixed branches
          ;; Add origin/ prefix if not already present
          (setq resolved-base-branch
                (if (string-prefix-p "origin/" input-base-branch)
                    input-base-branch
                  (concat "origin/" input-base-branch)))
          (setq resolved-feature-branch
                (if (string-prefix-p "origin/" input-feature-branch)
                    input-feature-branch
                  (concat "origin/" input-feature-branch))))
         (_ ; Default or unknown scope, fallback to smart resolution (should not happen with prompt)
          (setq resolved-base-branch (ai-code--get-full-branch-ref input-base-branch))
          (setq resolved-feature-branch (ai-code--get-full-branch-ref input-feature-branch))))))
    (cons resolved-base-branch resolved-feature-branch)))

(defun ai-code--generate-branch-or-commit-diff (diff-params diff-file)
  "Generate diff based on DIFF-PARAMS and save to DIFF-FILE.
DIFF-PARAMS is a plist with :type (`'commit`, `'base-vs-head`, `'branch-range`),
:base-branch, :feature-branch, :diff-file-name-part, and optionally
:branch-scope."
  (let* ((type (plist-get diff-params :type))
         (input-base-branch (plist-get diff-params :base-branch))
         (input-feature-branch (plist-get diff-params :feature-branch))
         (branch-scope (plist-get diff-params :branch-scope)) ; Might be nil for 'commit' or 'base-vs-head'
         (diff-file-name-part (plist-get diff-params :diff-file-name-part))

         (resolved-branches (ai-code--resolve-diff-branches type input-base-branch input-feature-branch branch-scope))
         (resolved-base-branch (car resolved-branches))
         (resolved-feature-branch (cdr resolved-branches)))

    (message "Fetching from all remotes to ensure latest branches...")
    (magit-run-git "fetch" "--all")
    ;; Verify input branches for relevant types
    (when (memq type '(base-vs-head branch-range))
      (ai-code--verify-branches input-base-branch input-feature-branch))
    ;; Display message about what we're doing
    (pcase type
      ('commit
       (message "Generating diff for single commit: %s" diff-file-name-part))
      ('base-vs-head
       (message "Generating diff between %s and HEAD" resolved-base-branch))
      ('branch-range
       (message "Generating diff between branches: %s..%s (%s)"
                resolved-base-branch resolved-feature-branch (or branch-scope "unknown-scope"))))
    (when (magit-anything-modified-p)
      (message "Repository has uncommitted changes. You might want to commit or stash them first.")
      (sleep-for 1))
    (message "Generating diff file: %s" diff-file)
    (magit-run-git "diff" (concat resolved-base-branch ".." resolved-feature-branch)
                   (concat "--output=" diff-file))))

(defun ai-code--open-diff-file (diff-file)
  "Open the generated DIFF-FILE."
  (find-file diff-file)
  (message "Generated diff file: %s" diff-file))

(defun ai-code--handle-staged-diff-generation (_git-root)
  "Handle generation of diff for staged change.
_GIT-ROOT is unused but kept for API compatibility."
  (let* ((files-dir (ai-code--ensure-files-directory))
         (diff-file-name-part "staged")
         (diff-file (expand-file-name (concat diff-file-name-part ".diff") files-dir)))
    (ai-code--generate-staged-diff diff-file)
    diff-file))

(defun ai-code--handle-base-vs-head-diff-generation (_git-root
                                                      &optional
                                                      open-in-browser)
  "Handle generation of diff between a base branch and HEAD.
_GIT-ROOT is unused but kept for API compatibility.
If OPEN-IN-BROWSER is non-nil, only open the diff in GitHub web
interface without generating file."
  (let* ((base-branch (ai-code-read-string "Base branch name: "))
         (current-branch (magit-get-current-branch)))
    (if open-in-browser
        (progn
          (ai-code--open-git-web-compare base-branch (or current-branch "HEAD"))
          nil)  ; Return nil since no file was generated
      (let* ((files-dir (ai-code--ensure-files-directory))
             (diff-file-name-part (concat (replace-regexp-in-string "/" "-" base-branch) ".HEAD"))
             (diff-file (expand-file-name (concat diff-file-name-part ".diff") files-dir))
             (diff-params (list :type 'base-vs-head
                                :base-branch base-branch
                                :feature-branch "HEAD"
                                :diff-file-name-part diff-file-name-part)))
        (ai-code--generate-branch-or-commit-diff diff-params diff-file)
        diff-file))))

(defun ai-code--handle-branch-range-diff-generation (_git-root
                                                      &optional
                                                      open-in-browser)
  "Handle generation of diff between a base and a feature branch.
_GIT-ROOT is unused but kept for API compatibility.
If OPEN-IN-BROWSER is non-nil, only open the diff in GitHub web
interface without generating file."
  (let* ((base-branch (ai-code-read-string "Base branch name: "))
         (feature-branch (ai-code-read-string "Feature branch name: ")))
    (if open-in-browser
        (progn
          (ai-code--open-git-web-compare base-branch feature-branch)
          nil)  ; Return nil since no file was generated
      (let* ((branch-scope)
             (scope-alist '(("Local" . local)
                            ("Remote (will prefix with 'origin/')" . remote)))
             (raw-scope-choice (completing-read "Are branches local or remote? "
                                                scope-alist
                                                nil t nil nil "Local")))
        (setq branch-scope
              (if (consp raw-scope-choice)
                  (cdr raw-scope-choice)
                (cdr (assoc raw-scope-choice scope-alist))))
        (let* ((files-dir (ai-code--ensure-files-directory))
               (diff-file-name-part (concat (replace-regexp-in-string "/" "-" base-branch)
                                           "."
                                           (replace-regexp-in-string "/" "-" feature-branch)))
               (diff-file (expand-file-name (concat diff-file-name-part ".diff") files-dir))
               (diff-params (list :type 'branch-range
                                   :base-branch base-branch
                                   :feature-branch feature-branch
                                   :branch-scope branch-scope
                                   :diff-file-name-part diff-file-name-part)))
          (ai-code--generate-branch-or-commit-diff diff-params diff-file)
          diff-file)))))

(defun ai-code--handle-commit-diff-generation (_git-root
                                                &optional open-in-browser)
  "Handle generation of diff for a single commit.
_GIT-ROOT is unused but kept for API compatibility.
If OPEN-IN-BROWSER is non-nil, only open the commit in GitHub web
interface without generating file."
  (let* ((commit-hash (ai-code-read-string "Commit hash: ")))
    (if open-in-browser
        (progn
          (ai-code--open-git-web-commit commit-hash)
          nil)  ; Return nil since no file was generated
      (let* ((files-dir (ai-code--ensure-files-directory))
             (base-branch (concat commit-hash "^"))  ; Diff against parent
             (feature-branch commit-hash)
             (diff-file-name-part commit-hash)
             (diff-file (expand-file-name (concat diff-file-name-part ".diff") files-dir))
             (diff-params (list :type 'commit
                                :base-branch base-branch
                                :feature-branch feature-branch
                                :diff-file-name-part diff-file-name-part)))
        (ai-code--generate-branch-or-commit-diff diff-params diff-file)
        diff-file))))

(defun ai-code--get-diff-type-choice ()
  "Get user's choice for diff type and return the corresponding value."
  (let* ((diff-type-alist '(("Staged changes" . staged)
                            ("Base branch vs HEAD" . base-vs-head)
                            ("Branch range (e.g., base..feature)" . branch-range)
                            ("Single commit" . commit)
                            ("Commit range (e.g., commitA..commitB)" . commit-range)))
         (raw-diff-type-choice
          (completing-read "Select diff type: "
                           diff-type-alist
                           nil t nil nil "Staged changes")))
    (if (consp raw-diff-type-choice)
        (cdr raw-diff-type-choice)
      ;; If raw-diff-type-choice is a string, look up its corresponding value
      (cdr (assoc raw-diff-type-choice diff-type-alist)))))

;;; New helper for commit ranges
(defun ai-code--handle-commit-range-diff-generation (_git-root &optional open-in-browser)
  "Handle generation of diff between two commits (commit range).
_GIT-ROOT is unused but kept for API compatibility.
If OPEN-IN-BROWSER is non-nil, only open the diff in GitHub web interface
without generating file."
  (let* ((raw-start (ai-code-read-string "Start commit or branch: "))
         (raw-end   (ai-code-read-string "End commit or branch: "))
         ;; try to resolve remote branches or commits
         (start     (ai-code--get-full-branch-ref raw-start))
         (end       (ai-code--get-full-branch-ref raw-end)))
    (if open-in-browser
        (progn
          (ai-code--open-git-web-compare start end)
          nil)  ; Return nil since no file was generated
      ;; Sanitize branch names for file path (replace "/" with "-")
      (let* ((files-dir (ai-code--ensure-files-directory))
             (name-sanitized (concat (replace-regexp-in-string "/" "-" start)
                                     ".."
                                     (replace-regexp-in-string "/" "-" end)))
             (file      (expand-file-name (concat name-sanitized ".diff") files-dir))
             ;; reuse branch-range plumbing (it will fetch and verify)
             (params    (list :type 'branch-range
                              :base-branch start
                              :feature-branch end
                              :branch-scope 'remote  ; commit-range uses resolved refs
                              :diff-file-name-part name-sanitized)))
        (ai-code--generate-branch-or-commit-diff params file)
        file))))

(defun ai-code--magit-generate-feature-branch-diff-file ()
  "Generate a diff file based on user-selected type (staged, branches, commit).
For non-staged diffs, user is prompted whether to open in browser."
  (when-let* ((git-root (ai-code--validate-git-repository)))
    (let* ((selected-diff-type-value (ai-code--get-diff-type-choice))
           (open-in-browser (and (not (eq selected-diff-type-value 'staged))
                                 (y-or-n-p "Open diff in browser? ")))
           (diff-file (pcase selected-diff-type-value
                        ('staged       (ai-code--handle-staged-diff-generation git-root))
                        ('base-vs-head (ai-code--handle-base-vs-head-diff-generation git-root open-in-browser))
                        ('branch-range (ai-code--handle-branch-range-diff-generation git-root open-in-browser))
                        ('commit       (ai-code--handle-commit-diff-generation git-root open-in-browser))
                        ('commit-range (ai-code--handle-commit-range-diff-generation git-root open-in-browser))
                        (_ (user-error "Invalid diff type selected")))))
      (when diff-file
        (ai-code--open-diff-file diff-file)
        (when (y-or-n-p "Review this change? ")
          (ai-code-pull-or-review-diff-file))))))

;;;###autoload
(defun ai-code-magit-blame-analyze ()
  "Analyze current file or region Git history with AI for deeper insights.
If region is active, analyze just that region.  Otherwise analyze entire file.
Combines `magit-blame' history tracking with AI analysis to help understand
code evolution and the reasoning behind changes."
  (interactive)
  (when (ai-code--validate-buffer-file)
  (let* ((file-path (buffer-file-name))
         (has-region (use-region-p))
         (line-start (if has-region
                         (line-number-at-pos (region-beginning))
                       1))
         (line-end (if has-region
                       (line-number-at-pos (region-end))
                     (line-number-at-pos (point-max))))
         (region-text (if has-region
                          (buffer-substring-no-properties
                           (region-beginning) (region-end))
                        nil))
         (blame-args (list "blame" "-l"
                           (format "-L%d,%d" line-start line-end)
                           file-path))
         (blame-output (with-temp-buffer
                         (apply #'process-file "git" nil t nil blame-args)
                         (buffer-string)))
         (context (format "File: %s\nLines: %d-%d\n\n"
                          file-path line-start line-end))
         (files-context-string (ai-code--get-context-files-string))
         (code-sample (if has-region
                          (concat "Selected code:\n```\n" region-text "\n```\n\n")
                        ""))
         (default-analysis "Please provide the following analysis:\n1. Code evolution patterns and timeline\n2. Key changes and their purpose\n3. Potential design decisions and thought processes\n4. Possible refactoring or improvement opportunities\n5. Insights about code architecture or design")
         (analysis-instructions (ai-code-read-string "Analysis instructions: " default-analysis))
         (prompt (format "Analyze the Git commit history for this code:\n\n%s%s%sCommit history information:\n```\n%s\n```\n\n%s"
                         context files-context-string code-sample blame-output analysis-instructions)))
    (ai-code--insert-prompt prompt))))

(defun ai-code--ensure-git-log (git-root repo-name keyword)
  "Fetch commits from the last X months under GIT-ROOT for REPO-NAME.
KEYWORD is used to filter commits.
Returns the path to the git.log file."
  (let* ((project-log-file-path (expand-file-name "git.log" git-root))
         (date-str (ai-code-read-string (format "Start date for history of %s (YYYY-MM-DD, e.g. 2025-01-01): " repo-name)))
         (since-arg (unless (string-empty-p date-str)
                      (format "--since=%s" date-str)))
         ;; compute defaults, then let user review & edit
         (magit-args-default
          (let ((args (list "log" "--pretty=medium" "--stat")))
            (when since-arg
              (setq args (append args (list since-arg))))
            (unless (string-empty-p keyword)
              (setq args (append args (list "-S" keyword))))
            args))
         (magit-args-str (mapconcat #'identity magit-args-default " "))
         (magit-args-input
          (ai-code-read-string (format "Git log args (edit if needed): ") magit-args-str))
         (magit-args (split-string magit-args-input nil t))
         (log-output (apply #'magit-git-output magit-args)))
    (message "Saving Git log to %s" project-log-file-path)
    (with-temp-file project-log-file-path
      (insert log-output))
    (find-file project-log-file-path)
    project-log-file-path))

(defun ai-code--default-log-analysis-instructions (keyword)
  "Return the default analysis prompt for KEYWORD (may be empty)."
  (if (not (string-empty-p keyword))
      (format "Analyze the commits filtered by keyword '%s'. Provide insights on:\n\
1. Overall '%s' related feature evolution and major development phases, with author name in each phase.\n\
2. Frequency and patterns of '%s' related commits.\n\
3. Files or areas most impacted by '%s' changes.\n\
4. Main contributors and their roles in '%s' work.\n\
5. Trends or hotspots in '%s' related development.\n\
6. Suggestions for improving or refactoring '%s' implementation.\n"
              keyword keyword keyword keyword keyword keyword keyword)
    (concat "Please analyze the following Git log for the entire repository. Provide insights on:\n"
            "1. Overall project evolution and major development phases, with author name in each phase.\n"
            "2. Identification of key features, refactorings, or architectural changes and their timeline, with author name for each one.\n"
            "3. Patterns in development activity (e.g., periods of rapid development, bug fixing, etc.), with author name.\n"
            "4. Significant contributors or shifts in contribution patterns (if discernible from commit messages).\n"
            "5. Potential areas of technical debt or architectural concerns suggested by the commit history.\n"
            "6. General trends in the project's direction or focus over time.")))

(defun ai-code--build-log-prompt (repo-name analysis-instructions)
  "Build the final AI prompt for REPO-NAME using ANALYSIS-INSTRUCTIONS."
  (let ((context (format "Repository: %s\n\n" repo-name)))
    (format "Analyze the Git commit history for the entire repository '%s'.\n\n%sThe detailed Git log content is in the 'git.log' file (which has been added to the chat).\nPlease use its content for your analysis, following these instructions:\n%s"
            repo-name context analysis-instructions)))

(defun ai-code-magit-log-analyze ()
  "Analyze Git log with AI.
If current buffer is visiting a file named \\='git.log\\=', analyze its
content.  Otherwise, prompt for number of commits (default 100) and
optionally a keyword, generate the log, save it to
\\='PROJECT_ROOT/git.log\\=', open this file, and then analyze its content."
  (interactive)
  (let* ((git-root (ai-code--validate-git-repository))
         (repo-name (file-name-nondirectory (directory-file-name git-root)))
         (keyword (ai-code-read-string "Optional: Keyword to filter commits (leave empty for no filter): "))
         (default-analysis (ai-code--default-log-analysis-instructions keyword))
         (analysis-instructions (ai-code-read-string "Analysis instructions for repository log: " default-analysis))
         (prompt (ai-code--build-log-prompt repo-name analysis-instructions)))
    (ai-code--insert-prompt prompt)))

;;;###autoload
(defun ai-code-magit-blame-or-log-analyze (&optional arg)
  "If current buffer is git.log, run log analysis.
Otherwise if prefix ARG, run log analysis; else run blame analysis.
ARG is the prefix argument."
  (interactive "P")
  (cond ((and buffer-file-name
              (string-equal (file-name-nondirectory buffer-file-name) "git.log"))
         (ai-code-magit-log-analyze))
        (arg (ai-code-magit-log-analyze))
        (t (ai-code-magit-blame-analyze))))

;;;###autoload
(defun ai-code-magit-setup-transients ()
  "Configure AI Code's transient menu entries in Magit.
This function uses `with-eval-after-load` to ensure that the
Magit transients are modified only after Magit itself has been loaded.
Call this function to register the AI Code commands with Magit."
  (interactive)
  ;; Integration with magit: This with-eval-after-load is intentional
  ;; to provide optional Magit integration without breaking when Magit is absent
  ;; For magit-diff-popup (usually 'd' in status buffer)
  (transient-append-suffix 'magit-diff "r" ; "Extra" group
    '("a" "AI Code: Review/generate diff" ai-code-pull-or-review-diff-file))
  ;; For magit-blame-popup (usually 'B' in status buffer or log)
  (transient-append-suffix 'magit-blame "b" ; "Extra" group
    '("a" "AI Code: Analyze blame" ai-code-magit-blame-analyze))
  ;; For magit-log-popup (usually 'l' in status buffer)
  (transient-append-suffix 'magit-log "b" ; "Extra" group
    '("a" "AI Code: Analyze log" ai-code-magit-log-analyze)))

;; Ensure the Magit transients are set up when this file is loaded.
;; (ai-code-magit-setup-transients)

;;;###autoload
(defun ai-code-init-project (prefix)
  "Initialize project helpers for Projectile and Helm-Gtags.
If either package is available, prompt for a project directory
defaulting to the Magit repository root, initialize the project in
Projectile, and configure Helm-Gtags with a pygments label.  Show a
summary message of performed actions.
PREFIX is the prefix argument."
  (interactive "P")
  ;; DONE: this function should create ai-code-files-dir-name folder under git root direction
  (let* ((projectile-available (or (featurep 'projectile)
                                   (require 'projectile nil t)))
         (helm-gtags-available (or (featurep 'helm-gtags)
                                   (require 'helm-gtags nil t))))
    (cond
     ((not (or projectile-available helm-gtags-available))
      (message "Projectile and Helm-Gtags are not available; skipping project initialization."))
     ((not (require 'magit nil t))
      (message "Magit is not available; cannot determine project root."))
     (t
      (let* ((git-root (ai-code--git-root))
             (initial-dir (or git-root default-directory))
             (dir (expand-file-name
                   (read-directory-name "Initialize project at: "
                                        initial-dir nil t initial-dir))))
        (let ((gtags-label (when (and helm-gtags-available
                                      (fboundp 'helm-gtags--read-gtagslabel))
                             (if prefix
                                 (helm-gtags--read-gtagslabel)
                               ai-code-init-project-gtags-label)))
              actions)
          (when (and projectile-available
                     (fboundp 'projectile-init-project))
            (projectile-init-project dir)
            (push (format "initialized Projectile project at %s" dir) actions))
          (cond
           ((and helm-gtags-available
                 (fboundp 'helm-gtags--read-gtagslabel)
                 gtags-label)
            (helm-gtags-create-tags dir gtags-label)
            (push (format "set Helm-Gtags label to %s for %s" gtags-label dir)
                  actions))
           ((and helm-gtags-available prefix)
            (push (format "skipped Helm-Gtags label for %s" dir) actions)))
          (when git-root
            (let ((files-dir (expand-file-name ai-code-files-dir-name git-root)))
              (unless (file-directory-p files-dir)
                (make-directory files-dir t)
                (push (format "created %s in %s" ai-code-files-dir-name git-root)
                      actions))))
          (if actions
              (message "ai-code-init-project: %s"
                       (mapconcat #'identity (nreverse actions) "; "))
            (message "ai-code-init-project: no actions performed for %s" dir))
          (ai-code-update-git-ignore)))))))

;;;###autoload
(defun ai-code-update-git-ignore ()
  "Ensure repository .gitignore has AI Code-related entries.
If not inside a Git repository, do nothing."
  (interactive)
  (let ((git-root (ai-code--git-root)))
    (if (not git-root)
        (message "ai-code-update-git-ignore: not in a git repository, skipped")
      (let* ((gitignore-path (expand-file-name ".gitignore" git-root))
             (required-entries (list (concat ai-code-files-dir-name "/")
                                     ".projectile"
                                     "GTAGS"
                                     "GRTAGS"
                                     "GPATH"
                                     "__pycache__/"
                                     "*.elc"
                                     "flycheck_*"))
             (gitignore-content (when (file-exists-p gitignore-path)
                                  (with-temp-buffer
                                    (insert-file-contents gitignore-path)
                                    (buffer-string))))
             (missing-entries nil))
        (dolist (entry required-entries)
          (unless (and gitignore-content
                       (string-match-p (concat "\\(?:^\\|\n\\)\\s-*"
                                              (regexp-quote entry)
                                              "\\s-*\\(?:\n\\|$\\)")
                                       gitignore-content))
            (unless (member entry missing-entries)
              (push entry missing-entries))))
        (if missing-entries
            (let ((added (nreverse missing-entries)))
              (with-temp-buffer
                (when gitignore-content
                  (insert gitignore-content)
                  (unless (or (zerop (buffer-size))
                              (eq (char-before) ?\n))
                    (insert "\n")))
                (dolist (entry added)
                  (insert entry "\n"))
                (write-region (point-min) (point-max) gitignore-path))
              (message "ai-code-update-git-ignore: added %s"
                       (mapconcat #'identity added ", ")))
          (message "ai-code-update-git-ignore: no updates needed"))))))

(defun ai-code--git-repo-recent-modified-files (base-dir limit)
  "Return up to LIMIT most recently modified files under BASE-DIR.
If BASE-DIR is in a Git repository, use `git ls-files' to enumerate files."
  (let* ((git-root (ai-code--git-root base-dir))
         (root (or git-root base-dir))
         (files (if git-root
                    (let ((default-directory root))
                      (mapcar (lambda (path)
                                (expand-file-name path root))
                              (magit-git-lines "ls-files")))
                  (directory-files root t directory-files-no-dot-files-regexp)))
         (file-times nil))
    (dolist (file files)
      (when (and (file-regular-p file)
                 (not (ai-code--git-ignored-repo-file-p file root)))
        (let* ((attrs (file-attributes file))
               (mtime (file-attribute-modification-time attrs)))
          (push (cons file mtime) file-times))))
    (setq file-times (sort file-times
                           (lambda (a b)
                             (time-less-p (cdr b) (cdr a)))))
    (let ((recent '())
          (count 0))
      (dolist (item file-times)
        (when (< count limit)
          (push (car item) recent)
          (setq count (1+ count))))
      (delete-dups (nreverse recent)))))

;;;###autoload
(defun ai-code-git-repo-recent-modified-files (prefix)
  "Open or insert a recently modified file in the repo or current dir.
With no PREFIX argument, prompt for a recently modified file and open it
with `find-file'.

With a PREFIX argument (e.g., when called via `C-u'), prompt for a recently
modified file and insert \"@\" followed by the selected filename into the
buffer from which this command was invoked, instead of visiting the file."
  (interactive "P")
  (let* ((git-root (ai-code--git-root))
         (base-dir (or git-root default-directory))
         (files (ai-code--git-repo-recent-modified-files base-dir 20))
         (origin-buffer (current-buffer)))
    (if (not files)
        (message "ai-code-git-repo-recent-modified-files: no files found")
      (let* ((candidates (mapcar (lambda (file)
                                   (file-relative-name file base-dir))
                                 files))
             (choice (completing-read "Recent modified file: "
                                      candidates nil t)))
        (unless (string= choice "")
          (if prefix
              (when (buffer-live-p origin-buffer)
                (with-current-buffer origin-buffer
                  (insert "@" choice)))
            (find-file (expand-file-name choice base-dir))))))))

(defun ai-code--git-worktree-repo-dir (git-root)
  "Return centralized worktree directory for repository at GIT-ROOT."
  (let ((repo-name (file-name-nondirectory (directory-file-name git-root))))
    (expand-file-name repo-name ai-code-git-worktree-root)))

(defun ai-code--validate-git-worktree-root ()
  "Signal a user error unless `ai-code-git-worktree-root' is configured."
  (unless (and (stringp ai-code-git-worktree-root)
               (> (length ai-code-git-worktree-root) 0))
    (user-error "Please configure `ai-code-git-worktree-root` first")))

(defun ai-code--worktree-default-branch-name (start-point)
  "Return a default new branch name derived from START-POINT, or nil.
When START-POINT is a remote branch such as \"origin/topic\", suggest
\"topic\", unless a local branch of that name already exists."
  (let ((local-name (string-join (cdr (split-string start-point "/")) "/")))
    (and (not (string-empty-p local-name))
         (member start-point (magit-list-remote-branch-names))
         (not (member local-name (magit-list-local-branch-names)))
         local-name)))

(defun ai-code--read-new-branch-name (start-point)
  "Read the name of a new branch to be created at START-POINT.
Ask again when Git rejects the name, so a typo does not abort the whole
command.  `magit-read-string-ns' is used on purpose: it reads through
`read-from-minibuffer', so completion frameworks cannot reject a name
just because it matches no existing candidate."
  (let* ((base-prompt (format "Name for new branch (starting at %s)" start-point))
         (default (ai-code--worktree-default-branch-name start-point))
         (prompt base-prompt)
         (initial-input nil)
         (name nil))
    (while (not name)
      (let ((input (magit-read-string-ns prompt initial-input nil default)))
        (if (magit-git-success "check-ref-format" "--branch" input)
            (setq name input)
          (setq prompt (concat "Invalid branch name; " base-prompt))
          (setq initial-input input))))
    name))

(defun ai-code--read-worktree-branch-args ()
  "Read the (BRANCH START-POINT) arguments of `ai-code-git-worktree-branch'.
Ask for the starting point first, then for the new branch name.

This deliberately does not use `magit-branch-read-args': since Magit
4.7 that function reads the branch name with `magit-completing-read'
and passes a validation function as its REQUIRE-MATCH argument.  Helm
coerces any such non-standard REQUIRE-MATCH into a strict match against
a candidate list that is empty here, so a brand new branch name can
never be confirmed and the whole command has to be aborted."
  (let ((start-point (magit-read-starting-point "Create and checkout branch")))
    (unless (magit-rev-verify start-point)
      (user-error "Not a valid starting-point: %s" start-point))
    (list (ai-code--read-new-branch-name start-point) start-point)))

;;;###autoload
(defun ai-code-git-worktree-branch (branch start-point)
  "Create BRANCH and check it out in a new centralized worktree.
The worktree path for START-POINT is
`ai-code-git-worktree-root/REPO-NAME/BRANCH'."
  (interactive (ai-code--read-worktree-branch-args))
  (ai-code--validate-git-worktree-root)
  (let* ((git-root (ai-code--validate-git-repository))
         (repo-worktree-dir (ai-code--git-worktree-repo-dir git-root))
         (path (expand-file-name branch repo-worktree-dir))
         (parent-dir (file-name-directory path)))
    (unless (file-directory-p repo-worktree-dir)
      (make-directory repo-worktree-dir t))
    (when (and parent-dir
               (not (file-directory-p parent-dir)))
      (make-directory parent-dir t))
    (let ((branch-exists (magit-branch-p branch)))
      (when branch-exists
        (message "Branch '%s' already exists; reusing it for the new worktree." branch))
      (let ((exit-code (if branch-exists
                           (magit-call-git "worktree" "add"
                                           (file-truename path) branch)
                         (magit-call-git "worktree" "add" "-b" branch
                                         (file-truename path) start-point))))
        (unless (zerop exit-code)
          (user-error
           "Git failed to create worktree %s (exit code %s); see the *magit-process* buffer"
           path exit-code)))
      (dired path)
      (when (y-or-n-p "Create a task file for this worktree? ")
        (ai-code--worktree-create-and-link-task-file git-root path branch)))))

(defun ai-code--worktree-create-and-link-task-file (git-root worktree-path branch)
  "Create a task file in GIT-ROOT and symlink it into WORKTREE-PATH.
BRANCH is used as the default task name."
  (let* ((files-dir (expand-file-name ai-code-files-dir-name git-root))
         (task-name (ai-code-read-string "Task name: " branch))
         (generated-filename (ai-code--generate-task-filename task-name))
         (confirmed-filename (ai-code-read-string "Task filename: " generated-filename)))
    (unless (string-suffix-p ".org" confirmed-filename)
      (setq confirmed-filename (concat confirmed-filename ".org")))
    (unless (file-directory-p files-dir)
      (make-directory files-dir t))
    (let ((task-file (expand-file-name confirmed-filename files-dir)))
      (unless (file-exists-p task-file)
        (with-temp-file task-file
          (ai-code--initialize-task-file-content task-name "")))
      (let ((symlink-path (expand-file-name confirmed-filename worktree-path)))
        (unless (file-exists-p symlink-path)
          (make-symbolic-link task-file symlink-path))
        (with-current-buffer (find-file-other-window symlink-path)
          (ai-code--set-session-project-root worktree-path))))))

;;;###autoload
(defun ai-code-git-worktree-open-dir ()
  "Open Dired on the centralized worktree directory of the current repository.
The directory is `ai-code-git-worktree-root/REPO-NAME'."
  (interactive)
  (ai-code--validate-git-worktree-root)
  (let* ((git-root (ai-code--validate-git-repository))
         (repo-worktree-dir (ai-code--git-worktree-repo-dir git-root)))
    (if (file-directory-p repo-worktree-dir)
        (dired repo-worktree-dir)
      (user-error "Worktree directory does not exist: %s" repo-worktree-dir))))

(defconst ai-code--worktree-action-choices
  '(("New branch and worktree"         . ai-code-git-worktree-branch)
    ("Open worktree directory (Dired)" . ai-code-git-worktree-open-dir)
    ("Visit existing worktree"         . magit-worktree-status)
    ("Delete worktree"                 . magit-worktree-delete))
  "Worktree actions offered by `ai-code-git-worktree-action'.
The first entry is used as the completion default so that accepting the
default keeps the historical behavior of creating a new worktree.")

;;;###autoload
(defun ai-code-git-worktree-action ()
  "Choose a Git worktree action and run it.
Candidates come from `ai-code--worktree-action-choices'.  The Magit-backed
entries do not require `ai-code-git-worktree-root' to be configured."
  (interactive)
  (let* ((default-choice (caar ai-code--worktree-action-choices))
         (choice (completing-read "Worktree action: "
                                  (mapcar #'car ai-code--worktree-action-choices)
                                  nil t nil nil default-choice))
         (command (alist-get choice ai-code--worktree-action-choices
                             nil nil #'string=)))
    ;; Every candidate is a command with its own interactive spec, so it must
    ;; be dispatched with `call-interactively' rather than `funcall'.
    (call-interactively command)))

(provide 'ai-code-git)

;;; ai-code-git.el ends here