;;; ai-code-file.el --- File operations for AI code interface -*- lexical-binding: t; -*-

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

;; SPDX-License-Identifier: Apache-2.0

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

;;; Code:

(require 'magit)
(require 'dired)
(require 'comint)
(require 'subr-x)

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


(declare-function ai-code-read-string "ai-code-input")
(declare-function ai-code--insert-prompt "ai-code-prompt-mode" (prompt-text))
(declare-function ai-code--process-word-for-filepath "ai-code-prompt-mode" (word git-root-truename))
(declare-function ai-code-call-gptel-sync "ai-code-prompt-mode" (prompt))
(declare-function ai-code--extract-radar-id "ai-code-task" (text))
(declare-function ai-code--normalize-radar-text "ai-code-task" (text))
(declare-function ai-code--resolve-auto-test-suffix-for-send "ai-code")
(declare-function ai-code-backends-infra--session-buffer-p "ai-code-backends-infra" (buffer))
(declare-function ai-code-run-test "ai-code-agile")
(declare-function ai-code-flycheck-fix-errors-in-scope "ai-code-change")

(defcustom ai-code-sed-command "sed"
  "GNU sed command used to apply prompts to files."
  :type 'string
  :group 'ai-code)

;; Variables that will be defined in ai-code.el
(defvar ai-code-use-prompt-suffix)
(defvar ai-code-prompt-suffix)
(defvar ai-code-auto-test-type)
(defvar ai-code-auto-test-suffix)
(defvar ai-code-cli)
(defvar ai-code-selected-backend)
(defvar ai-code-task-use-gptel-filename)



(defun ai-code--resolve-auto-test-suffix-for-current-send ()
  "Return auto test suffix for this send action in file operations."
  (if (fboundp 'ai-code--resolve-auto-test-suffix-for-send)
      (ai-code--resolve-auto-test-suffix-for-send)
    ai-code-auto-test-suffix))

(defun ai-code--current-file-context-reference (&optional full-path)
  "Return the current file context reference.
When FULL-PATH is non-nil, use the absolute file path.  Otherwise, use
a repository-relative path with an @ prefix when possible.  Prefer an
active region line range, then the current function, then the file path."
  (when buffer-file-name
    (let* ((git-root (unless full-path
                       (ai-code--git-root)))
           (file-reference
            (if (and git-root (not full-path))
                (ai-code--process-word-for-filepath buffer-file-name git-root)
              buffer-file-name)))
      (cond
       ((use-region-p)
        (let ((start (region-beginning))
              (end (region-end)))
          (format "%s#L%d-L%d"
                  file-reference
                  (line-number-at-pos start t)
                  (line-number-at-pos (max start (1- end)) t))))
       (t
        (let ((function-name (ai-code--current-qualified-scope-name)))
          (if (and (stringp function-name)
                   (not (string-empty-p function-name)))
              (format "%s#%s" file-reference function-name)
            file-reference)))))))

(defun ai-code--dired-explicitly-marked-files ()
  "Return the files explicitly marked in the current Dired buffer.
Return nil when nothing is marked.  `dired-get-marked-files' falls back
to the file at point when no mark exists, so a single entry equal to
that file is not an explicit mark."
  (let ((all-marked (ignore-errors (dired-get-marked-files)))
        (file-at-point (ignore-errors (dired-get-filename nil t))))
    (unless (and (= (length all-marked) 1)
                 file-at-point
                 (equal (car all-marked) file-at-point))
      all-marked)))

;;;###autoload
(defun ai-code-copy-buffer-file-name-to-clipboard (&optional arg)
  "Copy the current buffer's file path or selected text to clipboard.
If in a magit status buffer, copy the current branch name.
If in a Dired buffer, copy the marked files and directories, one path
per line, falling back to the file at point or the directory path.
In a regular file buffer, append the selected region's line range or
the current function name to the file path.  Preserve selected text
before that context reference.
With prefix argument ARG \[universal-argument], always return full path
instead of processed path.  File paths are processed to relative paths
with @ prefix if within git repo."
  (interactive "P")
  (let ((path-to-copy
         (cond
          ;; If current buffer is a magit status buffer
          ((derived-mode-p 'magit-status-mode)
           (magit-get-current-branch))
          ;; If current buffer is a regular file
          ((buffer-file-name)
           (let ((context-reference
                  (ai-code--current-file-context-reference arg)))
             (if (use-region-p)
                 (format "%s in %s"
                         (buffer-substring-no-properties
                          (region-beginning)
                          (region-end))
                         context-reference)
               context-reference)))
          ;; If current buffer is a dired buffer
          ((derived-mode-p 'dired-mode)
           (let* ((git-root-truename (ai-code--git-root))
                  ;; Marked items win; otherwise keep the previous behavior of
                  ;; copying the file under cursor, then the dired directory.
                  (targets (or (ai-code--dired-explicitly-marked-files)
                               (list (or (ignore-errors (dired-get-file-for-visit))
                                         (dired-current-directory))))))
             (mapconcat (lambda (path)
                          (if (and git-root-truename (not arg))
                              (ai-code--process-word-for-filepath path git-root-truename)
                            path))
                        targets
                        "\n")))
          ;; For other buffer types, return nil
          (t nil))))
    (if path-to-copy
        (progn
          (kill-new path-to-copy)
          (message "copied %s to clipboard" path-to-copy))
      (message "No file path available to copy"))))

;;;###autoload
(defun ai-code-open-clipboard-file-path-as-dired ()
  "Open the file or directory path from clipboard in Dired.
If the clipboard contains a valid file path, open its directory in Dired
in another window and move the cursor to that file.  If the clipboard
contains a directory path, open it directly in Dired in another window."
  (interactive)
  (let ((path (current-kill 0)))
    (if (and path (file-exists-p path))
        (if (file-directory-p path)
            (dired-other-window path)
          (let* ((dir (file-name-directory path))
                 (file (file-name-nondirectory path))
                 (dired-buffer (dired-other-window dir)))
            (with-current-buffer dired-buffer
              (goto-char (point-min))
              (when (search-forward (regexp-quote file) nil t)
                (goto-char (match-beginning 0))))))
      (message "Clipboard does not contain a valid file or directory path"))))

(defvar ai-code-run-file-history nil
  "History list for `ai-code-run-current-file' commands.")

(defvar ai-code-shell-command-history nil
  "History list for shell command prompts.")

(defun ai-code--run-command-in-comint (command run-directory display-name)
  "Run COMMAND in a comint buffer using RUN-DIRECTORY and DISPLAY-NAME.
COMMAND is parsed into a program and its SWITCHES using
`split-string-and-unquote'.  RUN-DIRECTORY is used as the default
directory for the process.  DISPLAY-NAME is the buffer name applied to
the comint session.  This function is a programmatic wrapper around
`comint-run'."
  (let* ((command-parts (split-string-and-unquote command))
         (program (car command-parts))
         (switches (cdr command-parts)))
    (unless program
      (user-error "Command cannot be empty"))
    (let* ((origin-window (selected-window))
           (origin-buffer (window-buffer origin-window))
           (effective-directory (or run-directory default-directory))
           (comint-buffer-name (format "*%s*" (file-name-nondirectory program))))
      (when-let* ((existing (get-buffer display-name)))
        (unless (yes-or-no-p (format "Kill existing session %s? " display-name))
          (user-error "Aborted running command: %s" command))
        (when-let* ((proc (get-buffer-process existing)))
          (delete-process proc))
        (kill-buffer existing))
      (let ((default-directory effective-directory))
        (apply #'make-comint "Run" program nil switches))
      (when (window-live-p origin-window)
        (set-window-buffer origin-window origin-buffer))
      (when-let* ((buffer (get-buffer comint-buffer-name)))
        (with-current-buffer buffer
          (rename-buffer display-name t)
          (setq-local default-directory effective-directory))
        (let ((session-window
               (display-buffer buffer '((display-buffer-pop-up-window)
                                        (inhibit-same-window . t)))))
          (when (window-live-p session-window)
            (select-window session-window))))
      (get-buffer display-name))))

;;;###autoload
(defun ai-code-run-current-file ()
  "Generate command to run current script file (.py, .js, .ts, or .sh).
Let user modify the command before running it in a comint buffer.
Maintains a dedicated history list for this command."
  (interactive)
  (let* ((current-file (buffer-file-name))
         (file-ext (when current-file (file-name-extension current-file)))
         (file-name (when current-file (file-name-nondirectory current-file)))
         (last-command (when ai-code-run-file-history (car ai-code-run-file-history)))
         (default-command
           (cond
            ;; Check if current file is in the last run command
            ((and last-command file-name (string-match-p (regexp-quote file-name) last-command))
             last-command)
           ;; Generate default command based on file extension
            ((string= file-ext "py")
             (format "python %s" file-name))
            ((string= file-ext "js")
             (format "node %s" file-name))
            ((string= file-ext "ts")
             (format "npx tsx %s" file-name))
            ((string= file-ext "sh")
             (format "bash %s" file-name))
            (t nil))))
    (unless current-file
      (user-error "Current buffer is not visiting a file"))
    (unless default-command
      (user-error "Current file is not a .py, .js, .ts, or .sh file"))
    (let ((command (read-string (format "Run command for %s: " file-name)
                                default-command
                                'ai-code-run-file-history)))
      (let* ((run-directory (file-name-directory current-file))
             (display-name (format "*ai-code-run-current-file: %s*"
                                   (file-name-base file-name))))
        (ai-code--run-command-in-comint command run-directory display-name)))))

;;;###autoload
(defun ai-code-apply-prompt-on-current-file ()
  "Apply a user prompt to the current file and send to an AI CLI tool.
The file can be the one in the current buffer or at point in a Dired
buffer.  It constructs a shell command:
sed \"1i <prompt>: \" <file> | <ai-code-cli>
and runs it in a compilation buffer."
  (interactive)
  (when (memq (and (boundp 'ai-code-selected-backend)
                   ai-code-selected-backend)
              '(agent-shell eca))
    (user-error "Backend '%s' does not support piping file content via ai-code-cli"
                ai-code-selected-backend))
  (let* ((prompt (ai-code-read-string "Prompt: "))
         (suffix-parts (delq nil (list ai-code-prompt-suffix
                                       (when ai-code-auto-test-type
                                         (ai-code--resolve-auto-test-suffix-for-current-send)))))
         (suffix (when (and ai-code-use-prompt-suffix suffix-parts)
                   (string-join suffix-parts ", ")))
         (prompt-with-suffix (if suffix
                                 (concat prompt ", " suffix)
                               prompt))
         (file-name (cond
                     ((derived-mode-p 'dired-mode)
                      (dired-get-filename))
                     ((buffer-file-name)
                      (buffer-file-name))
                     (t (user-error "Cannot determine the file name"))))
         (command (format "%s \"1i %s: \" %s | %s"
                          ai-code-sed-command
                          (shell-quote-argument prompt-with-suffix)
                          (shell-quote-argument file-name)
                          ai-code-cli)))
    (when file-name
      (let* ((default-directory (file-name-directory file-name))
             (buffer-name (format "*ai-code-apply-prompt: %s*"
                                  (file-name-base file-name))))
        (compilation-start
         command
         nil
         (lambda (_mode)
           (generate-new-buffer-name buffer-name)))))))

(defun ai-code--generate-shell-command (&optional initial-input)
  "Generate shell command from user input or AI assistance.
Read initial command with INITIAL-INPUT as the default, falling back to
the latest entry in `ai-code-shell-command-history'.
If command starts with ':', treat as prompt for AI to generate command.
Record the confirmed AI command in the dedicated history and return it."
  (let* ((default-command (or initial-input
                              (car ai-code-shell-command-history)))
         (initial-command (read-string "Shell command: " default-command
                                       'ai-code-shell-command-history))
         ;; if current buffer is Dired buffer, replace the * character
         ;; inside initial-command with file base name under cursor,
         ;; or marked files, separate with space
         (initial-command
          (if (and (derived-mode-p 'dired-mode)
                   (string-match-p "\\*" initial-command))
              (let* ((files (ignore-errors (dired-get-marked-files)))
                     (file-names (when files
                                   (delete-dups (mapcar #'file-name-nondirectory files)))))
                (if file-names
                    (replace-regexp-in-string
                     (regexp-quote "*")
                     (mapconcat #'identity file-names " ")
                     initial-command
                     nil
                     t)
                  initial-command))
            initial-command))
         (command
          (if (string-prefix-p ":" initial-command)
              ;; If command starts with :, treat as prompt for AI
              (let* ((base-prompt (concat "Generate a shell command (pure command, no fense, no duplicate, in one line) for: " (substring initial-command 1)))
                     (prompt (if (derived-mode-p 'dired-mode)
                                 (let* ((files (ignore-errors (dired-get-marked-files)))
                                        (file-names (when files (delete-dups (mapcar #'file-name-nondirectory files)))))
                                   (if file-names
                                       (concat (format "For these files %s, " (mapconcat #'identity file-names " "))
                                               base-prompt)
                                     base-prompt))
                               base-prompt)))
                (condition-case err
                    ;; DONE: if ai-generated has more than one line,
                    ;; only take the first line
                    (let* ((ai-generated (ai-code-call-gptel-sync prompt))
                           (first-line (when ai-generated
                                         (car (split-string ai-generated "\n" t)))))
                      (when first-line
                        ;; Ask user to confirm/edit the AI-generated command
                        (let ((confirmed-command
                               (read-string "Shell command (AI generated): "
                                            (string-trim first-line))))
                          (when (and confirmed-command
                                     (not (string-empty-p confirmed-command)))
                            (add-to-history 'ai-code-shell-command-history
                                            confirmed-command))
                          confirmed-command)))
                  (error
                   (message "Failed to generate command with AI: %s" err)
                   "")))
            ;; Regular command, use as-is
            initial-command)))
    command))

;;;###autoload
(defun ai-code-shell-cmd (&optional initial-input)
  "Run shell command in Dired directory or insert command in shell buffers.
If current buffer is a Dired buffer, get user input shell command with
`read-string', then run it under the directory of Dired buffer, in a
buffer with name as *ai-code-shell-cmd: <current-dir>*.  If current
buffer is `shell-mode', `eshell-mode' or `sh-mode', get input and insert
command under cursor, do not run it.  If the command starts with
\\=':\\=', it means it is a prompt.  In this case, ask gptel to generate
the corresponding shell command, and call `ai-code-shell-cmd' with that
command as candidate.  INITIAL-INPUT is the initial text to populate the
shell command prompt."
  (interactive)
  (cond
   ;; Handle shell modes: insert command without running
   ((memq major-mode '(shell-mode eshell-mode))
    (let ((command (ai-code--generate-shell-command initial-input)))
      (when (and command (not (string= command "")))
        (insert command))))
   ;; Handle other modes: run command in compilation buffer
   (t
    (let* ((current-dir (cond
                         ((derived-mode-p 'dired-mode)
                          (dired-current-directory))
                         (initial-input
                          default-directory)
                         (t nil))))
      (unless current-dir
        (user-error "Cannot determine working directory: requires either a Dired buffer or initial input"))
      (let* ((command (ai-code--generate-shell-command initial-input))
             (buffer-name (format "*ai-code-shell-cmd: %s*" (directory-file-name current-dir))))
        (when (and command (not (string= command "")))
          (let ((default-directory current-dir))
            (compilation-start
             command
             nil
             (lambda (_mode)
               (generate-new-buffer-name buffer-name))))))))))

;;;###autoload
(defun ai-code-run-current-file-or-shell-cmd ()
  "Run current file or shell command based on buffer state.
Call `ai-code-shell-cmd` when in Dired mode, shell modes or a region
is active; otherwise run the current file."
  (interactive)
  (cond
   ((derived-mode-p 'dired-mode)
    (ai-code-shell-cmd))
   ((memq major-mode '(shell-mode eshell-mode))
    (ai-code-shell-cmd))
   ((use-region-p)
    (let ((initial-input (string-trim (buffer-substring-no-properties (region-beginning)
                                                                      (region-end)))))
      (ai-code-shell-cmd initial-input)))
   (t
    (ai-code-run-current-file))))

;;;###autoload
(defun ai-code-build-or-test-project ()
  "Run a build, test, lint, or Flycheck action selected by the user.
If user chooses to build, check for build.sh in the project root
and send to AI for execution.  Otherwise, ask AI to generate a build command.
If user chooses to test the whole project, call `ai-code-test-project'.
If user chooses scoped testing, call `ai-code-run-test'.
If user chooses linting, call `ai-code-lint-current-file'.
If user chooses a Flycheck fix, call
`ai-code-flycheck-fix-errors-in-scope'."
  (interactive)
  (let ((action (completing-read
                 "Choose action: "
                 '("Build project"
                   "Test project"
                   "Test on scope"
                   "Lint current file"
                   "Fix Flycheck errors in scope")
                 nil t)))
    (pcase action
      ("Build project" (ai-code-build-project))
      ("Test project" (ai-code-test-project))
      ("Test on scope" (ai-code-run-test))
      ("Lint current file" (ai-code-lint-current-file))
      ("Fix Flycheck errors in scope"
       (let ((this-command 'ai-code-flycheck-fix-errors-in-scope))
         (ai-code-flycheck-fix-errors-in-scope))))))

(defun ai-code--lint-current-file-follow-up ()
  "Return follow-up instructions for lint diagnostics."
  (concat "\n\nIf lint errors are found:"
          "\n1. Provide error analysis for the diagnostics"
          "\n2. Explain likely root causes in the current file"
          "\n3. Suggest specific code fix suggestions for user approval before making any changes"))

;;;###autoload
(defun ai-code-lint-current-file ()
  "Ask AI to lint the current file with Emacs diagnostics and explain issues."
  (interactive)
  (unless buffer-file-name
    (user-error "Current buffer is not visiting a file"))
  (let* ((repo-context (ai-code--format-repo-context-info))
         (initial-input
          (concat
           "Use the get_diagnostics Emacs MCP tool to lint the current file and report the results."
           (format "\nCurrent file: %s" buffer-file-name)
           (when repo-context (concat "\n" repo-context))
           (ai-code--lint-current-file-follow-up))))
    (ai-code--confirm-and-send "Send to AI: " initial-input)))

;;;###autoload
(defun ai-code-build-project ()
  "Build the current project.
Check for build.sh in the project root and send to AI for execution.
Otherwise, ask AI to generate a build command."
  (interactive)
  (let* ((proj-root (ai-code--project-root))
         (build-script (when proj-root (expand-file-name "build.sh" proj-root)))
         (repo-context (ai-code--format-repo-context-info))
         (error-handling-instruction
          (concat "\n\nIf the build fails:"
                  "\n1. Analyze the error output carefully to identify the failure point"
                  "\n2. Investigate the root cause by examining relevant source files"
                  "\n3. Provide a clear explanation of what went wrong"
                  "\n4. Suggest specific code fixes for user approval before making any changes"))
         (initial-input
          (if (and proj-root build-script (file-exists-p build-script))
              ;; build.sh exists, ask AI to run it
              (concat "Run the build script and report the results. "
                      (format "\nProject root: %s" proj-root)
                      (format "\nBuild script: %s" build-script)
                      (when repo-context (concat "\n" repo-context))
                      error-handling-instruction)
            ;; No build.sh, ask AI to generate build command
            (concat "Build the current project. Provide the build command and execute it if possible. "
                    (when proj-root (format "\nProject root: %s" proj-root))
                    (when repo-context (concat "\n" repo-context))
                    error-handling-instruction))))
    (ai-code--confirm-and-send "Send to AI: " initial-input)))

;;;###autoload
(defun ai-code-test-project ()
  "Ask AI to run test on the whole project and provide failure analysis."
  (interactive)
  (let* ((proj-root (ai-code--project-root))
         (repo-context (ai-code--format-repo-context-info))
         (failure-follow-up
          (concat "\n\nIf test fails:"
                  "\n1. Analyze the test results to identify failing tests and failure points"
                  "\n2. Investigate likely root causes by checking related source and test files"
                  "\n3. Explain what failed and why in clear terms"
                  "\n4. Provide specific code fix suggestions for user approval before making any changes"))
         (initial-input
          (concat "Run test on the whole project and report the results."
                  (when proj-root (format "\nProject root: %s" proj-root))
                  (when repo-context (concat "\n" repo-context))
                  failure-follow-up)))
    (ai-code--confirm-and-send "Send to AI: " initial-input)))

(defun ai-code--store-context-entry (repo-root context)
  "Store CONTEXT string for REPO-ROOT in `ai-code--repo-context-info'."
  (let ((current (gethash repo-root ai-code--repo-context-info)))
    (unless (member context current)
      (setq current (cons context current)))
    (puthash repo-root current ai-code--repo-context-info)))

;;;###autoload
(defun ai-code-add-context ()
  "Capture current buffer context and store it per selected repository root.
If current buffer is not inside a Git repository, select from known repository
roots gathered from existing context entries and visible/session buffers.
When no region is selected, use the full file path and current function
\(if any).  When a region is active, use the file path with line range
in the form filepath#Lstart-Lend."
  (interactive)
  (let* ((current-root (ai-code--git-root))
         (all-roots (let ((roots '()))
                      (walk-windows
                       (lambda (w)
                         (with-current-buffer (window-buffer w)
                           (let ((root (ai-code--git-root)))
                             (when (and root (not (member root roots)))
                               (push root roots)))))
                       nil 'current-frame)
                      (when (fboundp 'ai-code-backends-infra--session-buffer-p)
                        (dolist (buf (buffer-list))
                          (when (ai-code-backends-infra--session-buffer-p buf)
                            (with-current-buffer buf
                              (let ((root (ai-code--git-root)))
                                (when (and root (not (member root roots)))
                                  (push root roots)))))))
                      (nreverse roots)))
         (existing-roots (let ((roots '()))
                           (maphash (lambda (root _contexts)
                                      (when (and (stringp root)
                                                 (not (member root roots)))
                                        (push root roots)))
                                    ai-code--repo-context-info)
                           (sort roots #'string<)))
         (candidate-roots (sort (delete-dups (append existing-roots all-roots))
                                #'string<))
         (ordered-roots (if (and current-root (member current-root candidate-roots))
                            (cons current-root (remove current-root candidate-roots))
                          candidate-roots))
         (repo-root (cond
                     ((null ordered-roots)
                      (user-error "Not inside a Git repository"))
                     ((= (length ordered-roots) 1)
                      (car ordered-roots))
                     (t
                      (completing-read "Select Git repository for context: "
                                       ordered-roots
                                       nil t nil nil (car ordered-roots))))))
    (if (derived-mode-p 'dired-mode)
        (let* ((file-at-point (dired-get-filename nil t))
               (targets (or (ai-code--dired-explicitly-marked-files)
                            (when file-at-point (list file-at-point)))))
          (unless targets
            (user-error "No file or directory selected in Dired"))
          (dolist (path targets)
            (ai-code--store-context-entry repo-root path))
          (message "Added context for %s: %s"
                   repo-root
                   (mapconcat #'identity targets ", ")))
      (unless buffer-file-name
        (user-error "Current buffer is not visiting a file"))
      (let ((context (ai-code--current-file-context-reference t)))
        (ai-code--store-context-entry repo-root context)
        (message "Added context for %s: %s" repo-root context)))))

(defun ai-code-list-context ()
  "Display stored context entries grouped by Git repository."
  (interactive)
  (if (= (hash-table-count ai-code--repo-context-info) 0)
      (message "No stored context info.")
    (let ((sections '()))
      (maphash
       (lambda (repo contexts)
         (let ((entries (if contexts
                            (mapconcat (lambda (ctx)
                                         (concat "  - " ctx))
                                       (reverse contexts)
                                       "\n")
                          "  (no entries)")))
           (push (format "Repo: %s\n%s" repo entries) sections)))
       ai-code--repo-context-info)
      (message "%s" (string-join (nreverse sections) "\n\n")))))

(defun ai-code-clear-context (&optional arg)
  "Clear stored context entries.
Without ARG, clear entries for the current Git repository.
With prefix ARG, clear all repositories."
  (interactive "P")
  (if arg
      (progn
        (clrhash ai-code--repo-context-info)
        (message "Cleared all stored context info."))
    (let ((repo-root (or (ai-code--git-root)
                         (user-error "Not inside a Git repository"))))
      (if (gethash repo-root ai-code--repo-context-info)
          (progn
            (remhash repo-root ai-code--repo-context-info)
            (message "Cleared context info for %s." repo-root))
        (message "No context info stored for %s." repo-root)))))

(defun ai-code-context-action (_arg)
  "Copy, add, show, or clear context entries via `completing-read'.
Presents copy actions first, followed by Add context, Show context, and
Clear context.  The prefix argument ARG is ignored."
  (interactive "P")
  (let ((action (completing-read "Context action: "
                                 '("Copy context"
                                   "Copy context with full path"
                                   "Add context"
                                   "Show context"
                                   "Clear context")
                                 nil t)))
    (pcase action
      ("Copy context"
       (ai-code-copy-buffer-file-name-to-clipboard))
      ("Copy context with full path"
       (ai-code-copy-buffer-file-name-to-clipboard t))
      ("Add context"
       (call-interactively #'ai-code-add-context)
       (ai-code-list-context))
      ("Show context"
       (ai-code-list-context))
      ("Clear context"
       (call-interactively #'ai-code-clear-context)))))

;;;###autoload
(defun ai-code-toggle-current-buffer-dedicated (arg)
  "Toggle the dedicated state of the current buffer's window.
When a window is dedicated, Emacs will not automatically reuse it for
displaying other buffers.  With prefix ARG \[universal-argument],
toggle dedication for every window in the current frame."
  ;; DONE: if C-u pressed, toggle dedicate to all buffers inside current window
  (interactive "P")
  (let* ((targets (if arg
                      (window-list nil 'no-minibuf)
                    (list (selected-window))))
         (results '()))
    (dolist (window targets)
      (let ((buffer (window-buffer window)))
        (when (buffer-file-name buffer)
          (let ((new-status (not (window-dedicated-p window))))
            (set-window-dedicated-p window new-status)
            (push (format "%s: %s" (buffer-name buffer) (if new-status "dedicated" "free")) results)))))
    ;; DONE: show each affected buffer name and dedicate status
    (if results
        (message "Window dedication updated: %s" (mapconcat #'identity (nreverse results) ", "))
      (message "No file buffers found in target windows"))))

(defun ai-code--sanitize-generated-path-name (name)
  "Sanitize generated NAME for file or directory creation."
  (let ((clean-name (downcase (string-trim (ai-code--normalize-radar-text name)))))
    (setq clean-name (or (car (split-string clean-name "\n" t)) ""))
    (setq clean-name (replace-regexp-in-string "[^a-z0-9._-]" "_" clean-name))
    (setq clean-name (replace-regexp-in-string "_+" "_" clean-name))
    (setq clean-name (replace-regexp-in-string "^[_.]+\\|[_.]+$" "" clean-name))
    clean-name))

(defun ai-code--generate-file-or-dir-name-with-gptel (description target-type)
	  "Generate a file or directory name for DESCRIPTION and TARGET-TYPE.
TARGET-TYPE should be either \"file\" or \"directory\"."
	  (let* ((prompt
	          (format
	           "Generate one concise lowercase %s name for this request: %s\n\
Rules: return only the name, no explanation, no markdown, no quotes, max 60 chars.\n\
Use letters/numbers/_/./- only, no slashes or path separators."
	           target-type
	           description))
         (generated-name (condition-case nil
                             (ai-code-call-gptel-sync prompt)
                           (error description))))
    (ai-code--sanitize-generated-path-name generated-name)))

;;;###autoload
(defun ai-code-create-file-or-dir ()
  "Generate and create a new file or directory under current directory.
Use GPTel to generate the name, ask user to confirm, then create and open
it in another window."
  (interactive)
  (let* ((current-dir (if (derived-mode-p 'dired-mode)
                          (dired-current-directory)
                        default-directory))
         (target-type (completing-read "Create target type: "
                                       '("file" "directory")
                                       nil t nil nil "file"))
         (description (read-string (format "Describe the %s to create: " target-type)))
         (radar-id (ai-code--extract-radar-id description)))
    (when (string-empty-p (string-trim description))
      (user-error "Description cannot be empty"))
    (let* ((generated-name (if (and (boundp 'ai-code-task-use-gptel-filename)
                                    ai-code-task-use-gptel-filename)
                               (ai-code--generate-file-or-dir-name-with-gptel description target-type)
                             (ai-code--sanitize-generated-path-name description)))
           (generated-name (if radar-id
                               (concat (format "rdar_%s_" radar-id) generated-name)
                             generated-name))
           (fallback-name (if (string-empty-p generated-name)
                              (if (string= target-type "directory")
                                  "new_dir"
                                "new_file.txt")
                            generated-name))
           (confirmed-name
            (read-string (format "Confirm %s name (will create under %s): "
                                 target-type
                                 (abbreviate-file-name current-dir))
                         fallback-name))
           (sanitized-name (ai-code--sanitize-generated-path-name confirmed-name)))
      (when (string-empty-p sanitized-name)
        (user-error "Confirmed name cannot be empty"))
      (let ((target-path (expand-file-name sanitized-name current-dir)))
        (if (string= target-type "directory")
            (progn
              (unless (file-directory-p target-path)
                (make-directory target-path t))
              (dired-other-window target-path)
              (message "Opened directory: %s" target-path))
          (progn
            (unless (file-exists-p target-path)
              (make-directory (file-name-directory target-path) t)
              (write-region "" nil target-path nil 'silent))
            (find-file-other-window target-path)
            (message "Opened file: %s" target-path)))))))

(provide 'ai-code-file)

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