;;; ai-code-input.el --- Helm completion for ai-code.el -*- lexical-binding: t; -*-

;; Author: Kang Tu <tninja@gmail.com>
;; Keywords: convenience, tools
;; URL: https://github.com/tninja/ai-code-interface.el
;; SPDX-License-Identifier: Apache-2.0

;;; Commentary:
;; Optional Helm completion interface for ai-code.el
;; To use this, ensure both ai-code.el and helm are installed.

;;; Code:

(require 'cl-lib)  ; For `cl-subseq`
(require 'imenu)
(require 'magit)
(require 'ai-code-utils)
(require 'ai-code-session-link)
(require 'subr-x)

(declare-function browse-url "browse-url" (url &optional new-window))
(declare-function helm-comp-read "helm-mode" (prompt collection &rest args))
(declare-function ai-code-backends-infra--session-buffer-p "ai-code-backends-infra" (buffer))
(declare-function ai-code-backends-infra--linkify-session-region "ai-code-backends-infra" (start end))
(declare-function ai-code-backends-infra--terminal-send-string "ai-code-backends-infra" (string))
(declare-function ai-code-backends-infra--terminal-send-backspace "ai-code-backends-infra" ())
(declare-function ai-code--prompt-filepath-candidates "ai-code-prompt-mode" ())
(declare-function ai-code--insert-prompt "ai-code-prompt-mode" (prompt))
(declare-function whisper-run "whisper" ())

(eval-when-compile
  (defvar whisper-after-transcription-hook))

(defconst ai-code--speech-to-text-buffer-name "*whisper-stdout*"
  "Buffer name used by whisper.el transcription output.")

(defconst ai-code--speech-to-text-actions
  '("Insert to current buffer"
    "Send to AI coding session"
    "Copy to clipboard")
  "Completion choices for speech-to-text transcription handling.")

;;;###autoload
(defun ai-code-plain-read-string (prompt &optional initial-input _candidate-list)
  "Read a string from the user with PROMPT and optional INITIAL-INPUT.
_CANDIDATE-LIST is accepted for interface compatibility but ignored.
Uses `read-string' directly to avoid `helm-mode' intercepting `completing-read'."
  (read-string prompt initial-input 'ai-code-read-string-history))

(defvar ai-code--read-string-fn #'ai-code-plain-read-string
  "Function used by `ai-code-read-string' to read user input.")

;;;###autoload
(defun ai-code-read-string (prompt &optional initial-input candidate-list)
  "Read a string from the user with PROMPT and optional INITIAL-INPUT.
CANDIDATE-LIST provides additional completion options if provided."
  (funcall ai-code--read-string-fn prompt initial-input candidate-list))

(defun ai-code--confirm-and-send (prompt-label initial-prompt)
  "Let user edit INITIAL-PROMPT with PROMPT-LABEL, then send to AI.
Returns non-nil on successful send."
  (when-let* ((prompt (if (and initial-prompt
                               (> (length (split-string initial-prompt "\n")) 5))
                          (read-string prompt-label initial-prompt)
                        (ai-code-read-string prompt-label initial-prompt))))
    (ai-code--insert-prompt prompt)))

(defun ai-code-helm-read-string-with-history (prompt history-file-name &optional initial-input candidate-list)
  "Read a string with Helm completion using specified history file.
PROMPT is the prompt string.
HISTORY-FILE-NAME is the base name for history file.
INITIAL-INPUT is optional initial input string.
CANDIDATE-LIST is an optional list of candidate strings to show before history."
  ;; Load history from file
  (let* ((helm-history-file (expand-file-name history-file-name user-emacs-directory))
         (helm-history (if (file-exists-p helm-history-file)
                           (condition-case nil
                               (with-temp-buffer
                                 (insert-file-contents helm-history-file)
                                 (let ((content (buffer-string)))
                                   (unless (string-empty-p content)
                                     (read content))))
                             (error nil))
                         '()))
         ;; Use only Helm history, no CLI history
         (history helm-history)
         ;; Extract the most recent item from history (if exists)
         (most-recent (when history
                        (car history)))
         ;; Remove the first item to add it back later
         (rest-history (when history
                         (cl-remove-duplicates (cdr history) :test #'equal)))
         ;; Combine completion list: most recent + candidates + separator + rest of history
         (completion-list
          (append
           ;; If most recent item exists, put it at the top
           (when most-recent
             (list most-recent))
           ;; Add candidate list
           (or candidate-list '())
           ;; Add separator and rest of history
           (when rest-history
             (cons "==================== HISTORY ========================================" rest-history))))
         ;; Read input with helm
         (input (helm-comp-read
                 prompt
                 completion-list
                 :must-match nil
                 :name "Helm Read String, Use C-c C-y to edit selected command. C-b and C-f to move cursor during editing"
                 :fuzzy nil
                 :initial-input initial-input)))
    ;; Add to history if non-empty, single-line and save
    (unless (or (string-empty-p input) (string-match "\n" input))
      (push input history)
      ;; (setq history (mapcar #'substring-no-properties history))
      (with-temp-file helm-history-file ; Save to the Helm-specific history file
        (let ((history-entries (cl-subseq history
                                          0 (min (length history)
                                                 1000))))  ; Keep last 1000 entries
          (insert (let ((print-circle nil))
                    (prin1-to-string history-entries))))))
    input))

(defun ai-code-helm-read-string (prompt &optional initial-input candidate-list)
  "Read a string with Helm completion for ai-code, showing historical inputs.
PROMPT is the prompt string.
INITIAL-INPUT is optional initial input string.
CANDIDATE-LIST is an optional list of candidate strings to show before history."
  (ai-code-helm-read-string-with-history prompt "ai-code-helm-read-string-history.el" initial-input candidate-list))

(defun ai-code--speech-to-text-apply-transcription (origin-buffer)
  "Read transcription from current buffer and apply a chosen speech action.
The transcription is obtained from the current buffer, then if
ORIGIN-BUFFER is still live and the transcription is non-empty,
prompt the user to choose an action (insert into ORIGIN-BUFFER,
send to an AI coding session, or copy to the clipboard) and
perform that action."
  (let ((transcription
         (string-trim
          (buffer-substring-no-properties (point-min) (point-max)))))
    (when (and (buffer-live-p origin-buffer)
               (not (string-empty-p transcription)))
      (ai-code--speech-to-text-handle-action
       origin-buffer
       transcription
       (completing-read "Choose speech action: "
                        ai-code--speech-to-text-actions
                        nil t)))))

(defun ai-code--speech-to-text-handle-action (origin-buffer transcription action)
  "Handle speech ACTION for TRANSCRIPTION from ORIGIN-BUFFER."
  (pcase action
    ("Insert to current buffer"
     (with-current-buffer origin-buffer
       (insert transcription)))
    ("Send to AI coding session"
     (ai-code--speech-to-text-send-prompt origin-buffer transcription))
    ("Copy to clipboard"
     (kill-new transcription))))

(defun ai-code--speech-to-text-send-prompt (origin-buffer transcription)
  "Edit TRANSCRIPTION from ORIGIN-BUFFER and send the result to the AI session."
  (when-let* ((prompt (with-current-buffer origin-buffer
                        (ai-code-read-string "Send to AI: " transcription))))
    (require 'ai-code-prompt-mode)
    (ai-code--insert-prompt prompt)))

;;;###autoload
(defun ai-code-speech-to-text-input ()
  "Record audio with whisper.el, then choose how to use the transcription.

After recording and transcription, you can insert the text at point in the
original buffer, send it to an AI coding session, or copy it to the clipboard."
  (interactive)
  (unless (require 'whisper nil t)
    (user-error "Whisper.el is not available, please install it first"))
  (let ((origin-buffer (current-buffer)))
    (with-current-buffer (get-buffer-create ai-code--speech-to-text-buffer-name)
      (erase-buffer)
      (setq-local whisper-after-transcription-hook nil)
      (add-hook 'whisper-after-transcription-hook
                (lambda ()
                  (ai-code--speech-to-text-apply-transcription origin-buffer))
                nil t)
      (whisper-run)
      (message "Recording audio. Press RET when you are done.")
      (while (not (equal ?\r (read-char)))
        (sit-for 0.5))
      (whisper-run))))

;;;###autoload
(when (featurep 'helm)
  (setq ai-code--read-string-fn #'ai-code-helm-read-string))

(defun ai-code--enable-helm-read-string (&rest _args)
  "Enable Helm-backed prompt reading after Helm is loaded."
  (when (featurep 'helm)
    (setq ai-code--read-string-fn #'ai-code-helm-read-string)
    (remove-hook 'after-load-functions #'ai-code--enable-helm-read-string)))

(unless (featurep 'helm)
  (add-hook 'after-load-functions #'ai-code--enable-helm-read-string))


(defun ai-code--imenu-subalist-p (payload)
  "Return non-nil when PAYLOAD resembles an imenu sub-alist."
  (and (listp payload)
       (cl-some (lambda (entry)
                  (and (consp entry) (stringp (car entry))))
                payload)))

(defun ai-code--imenu-item-position (payload)
  "Extract buffer position from imenu PAYLOAD."
  (cond
   ((or (integerp payload) (markerp payload)) payload)
   ((overlayp payload) (overlay-start payload))
   ((and (consp payload)
         (or (integerp (car payload))
             (markerp (car payload))))
    (car payload))
   (t nil)))

(defun ai-code--extract-symbol-from-line (line)
  "Extract a likely symbol identifier from LINE."
  (let ((patterns
         '("^[ \t]*\\(?:async[ \t]+\\)?\\(?:def\\|class\\|function\\|func\\|fn\\|sub\\|proc\\|method\\|interface\\|struct\\|enum\\|type\\|trait\\|module\\|namespace\\)[ \t]+\\([[:word:]_.$:\\-]+\\)"
           "^[ \t]*\\([[:word:]_.$:\\-]+\\)[ \t]*("
           "^[ \t]*\\([[:word:]_.$:\\-]+\\)[ \t]*[{:]")))
    (catch 'found
      (dolist (pattern patterns)
        (when (string-match pattern line)
          (let ((name (match-string 1 line)))
            (throw 'found (replace-regexp-in-string ":+\\'" "" name)))))
      nil)))

(defun ai-code--imenu-symbol-from-position (payload)
  "Extract a symbol name from PAYLOAD position as fallback."
  (when-let* ((pos (ai-code--imenu-item-position payload)))
    (save-excursion
      (goto-char pos)
      (ai-code--extract-symbol-from-line
       (buffer-substring-no-properties
        (line-beginning-position)
        (line-end-position))))))

(defun ai-code--imenu-noise-name-p (name)
  "Return non-nil when NAME resembles an imenu group/template label."
  (or (not (stringp name))
      (string-empty-p (string-trim name))
      (string-match-p "\\`\\*.*\\*\\'" name)
      (string-match-p "\\`[0-9]+\\'" name)))

(defun ai-code--normalize-imenu-symbol-name (name payload)
  "Normalize imenu NAME using PAYLOAD as fallback source."
  (let ((trimmed (and (stringp name) (string-trim name))))
    (if (and trimmed (not (ai-code--imenu-noise-name-p trimmed)))
        trimmed
      (ai-code--imenu-symbol-from-position payload))))

(defun ai-code--flatten-imenu-index (index)
  "Flatten imenu INDEX into a list of useful symbol names."
  (let (result)
    (dolist (item index)
      (when (consp item)
        (let ((name (car item))
              (payload (cdr item)))
          (if (ai-code--imenu-subalist-p payload)
              (setq result (append result (ai-code--flatten-imenu-index payload)))
            (when-let* ((symbol (ai-code--normalize-imenu-symbol-name name payload)))
              (push symbol result))))))
    result))

(defun ai-code--get-functions-from-buffer (buffer)
  "Get a list of function/symbol names from BUFFER using imenu."
  (with-current-buffer buffer
    (when (derived-mode-p 'prog-mode)
      (condition-case nil
          (let ((imenu-auto-rescan t)
                (index (imenu--make-index-alist t)))
            (ai-code--flatten-imenu-index index))
        (error nil)))))

;;;###autoload
(defun ai-code-insert-function-at-point ()
  "Insert a function name selected from current windows' `prog-mode' buffers."
  (interactive)
  (let ((functions nil))
    (dolist (window (window-list))
      (let ((buffer (window-buffer window)))
        (setq functions (append (ai-code--get-functions-from-buffer buffer) functions))))
    (setq functions (sort (delete-dups (cl-remove-if-not #'stringp functions)) #'string<))
      (let ((selected (completing-read "Insert function: " functions nil nil)))
        (when (and selected (not (string-empty-p selected)))
          (insert selected)))))

(defvar ai-code-prompt-filepath-completion-enabled nil
  "Non-nil enables @ file completion inside comments and AI sessions.")

(defun ai-code--any-ai-session-active-p ()
  "Return non-nil when any AI session buffer is active."
  (when (fboundp 'ai-code-backends-infra--session-buffer-p)
    (let ((active nil))
      (dolist (buf (buffer-list))
        (when (and (not active)
                   (ai-code-backends-infra--session-buffer-p buf))
          (setq active t)))
      active)))

(defun ai-code--comment-context-p ()
  "Return non-nil when point is inside a comment."
  (nth 4 (syntax-ppss)))

(defun ai-code--hash-completion-target-file (&optional end-pos)
  "Return an absolute file path for @relative path ending at END-POS.
END-POS defaults to the current '#' position."
  (when-let* ((git-root (ai-code--git-root)))
    (let* ((end (or end-pos (1- (point))))
           (start (save-excursion
                    (goto-char end)
                    (skip-chars-backward "A-Za-z0-9_./-")
                    (point)))
           (has-at (save-excursion
                     (goto-char start)
                     (eq (char-before) ?@)))
           (relative-path (and has-at
                               (< start end)
                               (buffer-substring-no-properties start end))))
      (when relative-path
        (let ((file (expand-file-name relative-path git-root)))
          (when (and (file-regular-p file)
                     (string-prefix-p git-root (file-truename file)))
            file))))))

(defun ai-code--file-symbol-candidates--imenu (buffer)
  "Return sorted imenu candidates from BUFFER."
  (let (symbols)
    (with-current-buffer buffer
      (condition-case nil
          (let ((imenu-auto-rescan t)
                (index (imenu--make-index-alist t)))
            (setq symbols (ai-code--flatten-imenu-index index)))
        (error nil)))
    (sort (delete-dups (cl-remove-if-not #'stringp symbols)) #'string<)))

(defun ai-code--file-symbol-candidates (file)
  "Return function/class symbol candidates from FILE.
When Tree-sitter is available for FILE's buffer, return qualified
names in file-definition order.  Otherwise fall back to an
alphabetically sorted imenu list."
  (let ((buf (ignore-errors (find-file-noselect file t))))
    (if (and buf
             (fboundp 'ai-code--treesit-available-p)
             (fboundp 'ai-code--treesit-file-symbols)
             (ignore-errors (ai-code--treesit-available-p buf)))
        (let ((symbols (ignore-errors (ai-code--treesit-file-symbols buf))))
          (if (and symbols (consp symbols))
              (let ((qualified (mapcar (lambda (s) (plist-get s :qualified)) symbols)))
                (delete-dups (cl-remove-if-not #'stringp qualified)))
            (ai-code--file-symbol-candidates--imenu buf)))
      (when buf
        (ai-code--file-symbol-candidates--imenu buf)))))

(defun ai-code--choose-symbol-annotation-alist (symbols-or-buffer)
  "Return annotation alist for Tree-sitter SYMBOLS-OR-BUFFER.
Each element is (QUALIFIED . \" HEADER  line N\")."
  (let ((symbols (if (bufferp symbols-or-buffer)
                     (when (and (fboundp 'ai-code--treesit-available-p)
                                (fboundp 'ai-code--treesit-file-symbols)
                                (ignore-errors (ai-code--treesit-available-p symbols-or-buffer)))
                       (ignore-errors (ai-code--treesit-file-symbols symbols-or-buffer)))
                   symbols-or-buffer)))
    (when (and symbols (consp symbols))
      (mapcar (lambda (s)
                (cons (plist-get s :qualified)
                      (format " %s  line %s"
                              (or (plist-get s :header) "")
                              (or (plist-get s :line) ""))))
              symbols))))

(defun ai-code--choose-symbol-from-file (file)
  "Prompt user to select a symbol from FILE and return it.
When Tree-sitter symbols are available, show header and line as
annotation via `completion-extra-properties'."
  (let* ((buf (ignore-errors (find-file-noselect file t)))
         (ts-symbols (when (and buf
                                (fboundp 'ai-code--treesit-available-p)
                                (fboundp 'ai-code--treesit-file-symbols)
                                (ignore-errors (ai-code--treesit-available-p buf)))
                       (ignore-errors (ai-code--treesit-file-symbols buf))))
         (candidates (if (and ts-symbols (consp ts-symbols))
                         (delete-dups
                          (cl-remove-if-not #'stringp
                                            (mapcar (lambda (s) (plist-get s :qualified)) ts-symbols)))
                       (when buf (ai-code--file-symbol-candidates--imenu buf))))
         (annotation-alist (when ts-symbols
                             (ai-code--choose-symbol-annotation-alist ts-symbols))))
    (when candidates
      (let ((completion-extra-properties
             (when annotation-alist
               (list :annotation-function
                     (lambda (cand)
                       (cdr (assoc cand annotation-alist)))))))
        (condition-case nil
            (completing-read "Symbol: " candidates nil nil)
          (quit nil))))))

(defun ai-code--comment-filepath-capf ()
  "Provide completion candidates for @file paths inside comments."
  (when (and ai-code-prompt-filepath-completion-enabled
             (ai-code--comment-context-p)
             (buffer-file-name)
             (not (minibufferp))
             (ai-code--git-root))
    (let ((end (point))
          (start (save-excursion
                   (skip-chars-backward "A-Za-z0-9_./-")
                   (when (eq (char-before) ?@)
                     (1- (point))))))
      (when start
        (let ((candidates (ai-code--prompt-filepath-candidates)))
          (when candidates
            (list start end candidates :exclusive 'no)))))))

(defun ai-code--comment-auto-trigger-filepath-completion ()
  "Auto trigger file path/symbol completion in comments."
  (when (and ai-code-prompt-filepath-completion-enabled
             (ai-code--comment-context-p)
             (buffer-file-name)
             (not (minibufferp)))
    (pcase (char-before)
      (?@
       (let ((candidates (ai-code--prompt-filepath-candidates)))
         (when candidates
           (let ((choice (completing-read "File: " candidates nil nil)))
             (when (and choice (not (string-empty-p choice)))
               (delete-char -1)  ; Remove the '@' we just typed
               (insert choice))))))
      (?#
       (when-let* ((file (ai-code--hash-completion-target-file (1- (point))))
                   (symbol (ai-code--choose-symbol-from-file file)))
         (when (not (string-empty-p symbol))
           (delete-char -1)  ; Remove the '#' we just typed
          (insert "#" symbol)))))))

(defun ai-code--session-auto-trigger-filepath-completion ()
  "Auto trigger file path/symbol completion in AI session buffers."
  (when (and ai-code-prompt-filepath-completion-enabled
             (fboundp 'ai-code-backends-infra--session-buffer-p)
             (ai-code-backends-infra--session-buffer-p (current-buffer))
             (not (minibufferp))
             (ai-code--git-root))
    (pcase (char-before)
      (?@
       (let ((candidates (ai-code--prompt-filepath-candidates)))
         (when candidates
           (let ((choice (completing-read "File: " candidates nil nil)))
             (when (and choice (not (string-empty-p choice)))
               (ai-code-backends-infra--terminal-send-backspace)
               (ai-code-backends-infra--terminal-send-string choice))))))
      (?#
       (when-let* ((file (ai-code--hash-completion-target-file (1- (point))))
                   (symbol (ai-code--choose-symbol-from-file file)))
         (when (not (string-empty-p symbol))
           (ai-code-backends-infra--terminal-send-backspace)
           (ai-code-backends-infra--terminal-send-string (concat "#" symbol))))))))

(defun ai-code--session-handle-at-input ()
  "Handle '@' input in AI session buffers with optional filepath completion."
  (interactive)
  (let ((should-complete
         (and ai-code-prompt-filepath-completion-enabled
              (fboundp 'ai-code-backends-infra--session-buffer-p)
              (ai-code-backends-infra--session-buffer-p (current-buffer))
              (not (minibufferp))
              (ai-code--git-root))))
    (ai-code-backends-infra--terminal-send-string "@")
    (when should-complete
      (let ((candidates (ai-code--prompt-filepath-candidates)))
        (when candidates
          (let ((choice (condition-case nil
                            (completing-read "File: " candidates nil nil)
                          (quit nil))))
            (when (and choice (not (string-empty-p choice)))
              (ai-code-backends-infra--terminal-send-backspace)
              (ai-code-backends-infra--terminal-send-string choice))))))))

(defun ai-code--session-handle-hash-input ()
  "Handle '#' input in AI session buffers with optional symbol completion."
  (interactive)
  (let* ((should-complete
          (and ai-code-prompt-filepath-completion-enabled
               (fboundp 'ai-code-backends-infra--session-buffer-p)
               (ai-code-backends-infra--session-buffer-p (current-buffer))
               (not (minibufferp))
               (ai-code--git-root)))
         (file (and should-complete
                    (ai-code--hash-completion-target-file (point)))))
    (ai-code-backends-infra--terminal-send-string "#")
    (when-let* ((symbol (and file (ai-code--choose-symbol-from-file file))))
      (when (not (string-empty-p symbol))
        (ai-code-backends-infra--terminal-send-backspace)
        (ai-code-backends-infra--terminal-send-string (concat "#" symbol))))))

(defun ai-code--comment-filepath-setup ()
  "Ensure comment @ completion is available in the current buffer."
  (add-hook 'completion-at-point-functions #'ai-code--comment-filepath-capf nil t))

;;;###autoload
(define-minor-mode ai-code-prompt-filepath-completion-mode
  "Toggle @ file completion in comments and AI sessions across all buffers."
  :global t
  :group 'ai-code
  (setq ai-code-prompt-filepath-completion-enabled
        ai-code-prompt-filepath-completion-mode)
  (if ai-code-prompt-filepath-completion-mode
      (progn
        (add-hook 'post-self-insert-hook
                  #'ai-code--comment-auto-trigger-filepath-completion)
        (add-hook 'post-self-insert-hook
                  #'ai-code--session-auto-trigger-filepath-completion)
        (add-hook 'after-change-major-mode-hook #'ai-code--comment-filepath-setup)
        (dolist (buf (buffer-list))
          (with-current-buffer buf
            (ai-code--comment-filepath-setup))))
    (remove-hook 'post-self-insert-hook
                 #'ai-code--comment-auto-trigger-filepath-completion)
    (remove-hook 'post-self-insert-hook
                 #'ai-code--session-auto-trigger-filepath-completion)
    (remove-hook 'after-change-major-mode-hook #'ai-code--comment-filepath-setup)
    (dolist (buf (buffer-list))
      (with-current-buffer buf
        (remove-hook 'completion-at-point-functions
                     #'ai-code--comment-filepath-capf t)))))

;;;###autoload
(defun ai-code-toggle-filepath-completion ()
  "Toggle @ file completion in comments and AI sessions across all buffers."
  (interactive)
  (if ai-code-prompt-filepath-completion-mode
      (ai-code-prompt-filepath-completion-mode -1)
    (ai-code-prompt-filepath-completion-mode 1))
  (message "Filepath @ completion is %s"
           (if ai-code-prompt-filepath-completion-mode "enabled" "disabled")))

;; Keep @ filepath completion available out of the box.
(unless ai-code-prompt-filepath-completion-mode
  (ai-code-prompt-filepath-completion-mode 1))

;;; Code Link Navigation

(defconst ai-code--session-link-file-base-regexp
  "@?[[:alnum:]_./~-]*[./][[:alnum:]_./~-]+"
  "Regexp matching the base path portion of a session file link.")

(defconst ai-code--session-link-file-regexp
  (concat ai-code--session-link-file-base-regexp
          "\\(?:"
          ":[0-9]+:[0-9]+"
          "\\|:[0-9]+"
          "\\|:L[0-9]+\\(?:-[0-9]+\\)?"
          "\\|#[Ll][0-9]+\\(?:-[Ll]?[0-9]+\\)?"
          "\\|([0-9]+\\(?:,[0-9]+\\)?)"
          "\\)?")
  "Regexp matching clickable file-like session links.")

(defconst ai-code--session-link-url-regexp
  "https?://[^][\"'`()<>[:space:]]+"
  "Regexp matching clickable http/https session links.")

(defconst ai-code--session-clickable-link-regexp
  (concat "\\(?:" ai-code--session-link-url-regexp
          "\\)\\|\\(?:" ai-code--session-link-file-regexp
          "\\)")
  "Regexp matching file and URL links in session buffers.")

(defun ai-code--session-link-property-at-point ()
  "Return the clickable session link text at point, or nil."
  (or (get-text-property (point) 'ai-code-session-link)
      (when (not (bobp))
        (get-text-property (1- (point)) 'ai-code-session-link))))

(defun ai-code--session-link-bounds-at-point ()
  "Return bounds of the clickable session link at point, or nil."
  (if-let* ((text (ai-code--session-link-property-at-point)))
      (let* ((prop-pos (if (get-text-property (point) 'ai-code-session-link)
                           (point)
                         (1- (point))))
             (start (or (previous-single-property-change
                         (1+ prop-pos) 'ai-code-session-link nil (point-min))
                        (point-min)))
             (end (or (next-single-property-change
                       prop-pos 'ai-code-session-link nil (point-max))
                      (point-max))))
        (when (equal (buffer-substring-no-properties start end) text)
          (cons start end)))
    (let ((point (point))
          (line-start (line-beginning-position))
          (line-end (line-end-position))
          bounds)
      (save-excursion
        (goto-char line-start)
        (while (and (not bounds)
                    (re-search-forward ai-code--session-clickable-link-regexp line-end t))
          (when (and (<= (match-beginning 0) point)
                     (<= point (match-end 0)))
            (setq bounds (cons (match-beginning 0) (match-end 0))))))
      bounds)))

(defun ai-code--session-link-text-at-point ()
  "Return the clickable session link text at point, or nil."
  (or (ai-code--session-link-property-at-point)
      (when-let* ((bounds (ai-code--session-link-bounds-at-point)))
        (buffer-substring-no-properties (car bounds) (cdr bounds)))))

(defun ai-code--parse-session-link (text)
  "Parse TEXT as a session link and return a plist."
  (when (and text (not (string-empty-p (string-trim text))))
    (cond
     ((string-match-p "\\`https?://" text)
      (list :url text))
     ((string-match "^\\(.*?\\):\\([0-9]+\\):\\([0-9]+\\)$" text)
      (list :file (match-string 1 text)
            :line-start (string-to-number (match-string 2 text))
            :column-start (string-to-number (match-string 3 text))))
     ((string-match "^\\(.*\\)#[Ll]\\([0-9]+\\)-[Ll]?\\([0-9]+\\)$" text)
      (list :file (match-string 1 text)
            :line-start (string-to-number (match-string 2 text))))
     ((string-match "^\\(.*\\)#[Ll]\\([0-9]+\\)$" text)
      (list :file (match-string 1 text)
            :line-start (string-to-number (match-string 2 text))))
     ((string-match "^\\(.*\\)(\\([0-9]+\\),\\([0-9]+\\))$" text)
      (list :file (match-string 1 text)
            :line-start (string-to-number (match-string 2 text))
            :column-start (string-to-number (match-string 3 text))))
     ((string-match "^\\(.*\\)(\\([0-9]+\\))$" text)
      (list :file (match-string 1 text)
            :line-start (string-to-number (match-string 2 text))))
     ((string-match "^\\(.*\\):L\\([0-9]+\\)-\\([0-9]+\\)$" text)
      (list :file (match-string 1 text)
            :line-start (string-to-number (match-string 2 text))))
     ((string-match "^\\(.*\\):L\\([0-9]+\\)$" text)
      (list :file (match-string 1 text)
            :line-start (string-to-number (match-string 2 text))))
     ((string-match "^\\(.*?\\):\\([0-9]+\\)\\(?:-\\([0-9]+\\)\\)?$" text)
      (list :file (match-string 1 text)
            :line-start (string-to-number (match-string 2 text))))
     ((string-match-p "[./]" text)
      (list :file text))
     (t nil))))


(defun ai-code--project-file-candidates (filename)
  "Return possible project file matches for FILENAME."
  (when-let* ((raw filename)
              (filename (ai-code-session-link--normalize-file raw))
              ((not (string-empty-p filename))))
    (let* ((root (ai-code--session-project-root))
           (project-files (ai-code-session-link--project-files root))
           (exact-root (expand-file-name filename root))
           (exact-default (expand-file-name filename default-directory))
           (matches
            (append
             (when (and (file-name-absolute-p filename) (file-exists-p filename))
               (list filename))
             (when (file-exists-p exact-root)
               (list exact-root))
             (when (file-exists-p exact-default)
               (list exact-default))
             (ai-code-session-link--matching-project-files
              filename root project-files))))
      (delete-dups matches))))

(defun ai-code--read-session-link-candidate (prompt candidates)
  "Read one item from CANDIDATES using PROMPT."
  (let* ((choices (delete-dups (copy-sequence candidates)))
         (default (car choices)))
    (when choices
      (completing-read prompt choices nil t nil nil default))))

(defun ai-code--existing-absolute-session-path (filename)
  "Return an existing absolute local path for FILENAME, or nil."
  (when-let* ((normalized (ai-code-session-link--normalize-file filename)))
    (when (file-name-absolute-p normalized)
      (ai-code-session-link--resolve-existing-local-path normalized nil))))

(defun ai-code--find-project-file (filename)
  "Locate FILENAME within the current project."
  (or (ai-code--existing-absolute-session-path filename)
        (when-let* ((candidates (ai-code--project-file-candidates filename)))
          (if (= (length candidates) 1)
              (car candidates)
            (ai-code--read-session-link-candidate
             (format "Choose file for %s: " filename)
             candidates)))))

(defun ai-code-session-navigate-link-at-mouse (event)
  "Navigate to the session link clicked by mouse EVENT."
  (interactive "e")
  (let* ((start (event-start event))
         (window (posn-window start))
         (position (posn-point start)))
    (when (window-live-p window)
      (select-window window)
      (when (integer-or-marker-p position)
        (goto-char position)
        (ai-code-session-navigate-link-at-point)))))

(defun ai-code--session-image-link-file-at-point (text)
  "Return the resolved image file for session link TEXT at point."
  (and text
       (ai-code-session-link--image-preview-link-file text)))

(defun ai-code--open-session-link-file (text link &optional resolved-file)
  "Open the file described by LINK for session link TEXT.
Optional RESOLVED-FILE is an existing absolute file path to open directly."
  (when-let* ((file (plist-get link :file))
              (abs-file (or resolved-file
                            (ai-code--find-project-file file))))
    (find-file-other-window abs-file)
    (when-let* ((line-start (plist-get link :line-start)))
      (goto-char (point-min))
      (forward-line (1- line-start))
      (when-let* ((column-start (plist-get link :column-start)))
        (when (> column-start 0)
          (move-to-column (1- column-start)))))
    (message "Navigated to %s" text)
    t))

;;;###autoload
(defun ai-code-session-navigate-link-at-point ()
  "Navigate to the file or URL session link at point."
  (interactive)
  (let* ((text (ai-code--session-link-text-at-point))
         (link (and text (ai-code--parse-session-link text)))
         (image-file
          (and link
               (ai-code--session-image-link-file-at-point text))))
    (cond
     ((not link)
      (message "No code link found at point"))
     ((when-let* ((url (plist-get link :url)))
        (browse-url url)
        (message "Opened URL: %s" url)
        t))
     ((ai-code--open-session-link-file text link image-file))
     (t
      (message "No code link found at point")))))

(provide 'ai-code-input)

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