;;; config.el --- Visit the Emacs configuration file  -*- lexical-binding: t; -*-

;; Copyright (C) 2026 Mikhail Kirillov <w96k@runbox.com>

;; Author: Mikhail Kirillov <w96k@runbox.com>
;; Version: 0.1.0
;; Package-Requires: ((emacs "27.1"))
;; Keywords: convenience
;; URL: https://github.com/w96k/config.el

;; This file is NOT part of GNU Emacs.

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License.
;;
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.
;;
;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <http://www.gnu.org/licenses/>.

;;; Commentary:

;; Provides `config-visit' to quickly open the main Emacs
;; configuration file (init.el or .emacs).

;;; Code:

(require 'cl-lib)
(require 'vc)
(require 'url-util)

(defgroup config nil
  "Visit and inspect the Emacs configuration file."
  :group 'convenience
  :prefix "config-")

(defface config-packages-button
  '((t :inherit button))
  "Face for package action buttons in config-packages buffer."
  :group 'config)

(defface config-packages-button-hover
  '((t :inherit highlight))
  "Face used when mouse hovers over a package action button."
  :group 'config)

(defun config--locate-init-file ()
  "Return the path to the main Emacs init file, or nil if not found."
  (let ((candidates
         (list (expand-file-name "init.el" user-emacs-directory)
               (expand-file-name ".emacs" (expand-file-name "~"))
               (expand-file-name ".emacs.el" (expand-file-name "~")))))
    (cl-some (lambda (f) (and (file-readable-p f) f)) candidates)))

(defun config--visit-or-create (file)
  "Visit FILE, creating it with an empty body if it does not exist."
  (if (file-readable-p file)
      (find-file file)
    (if (y-or-n-p (format "File not found: %s.  Create it? " file))
        (progn
          (make-directory (file-name-directory file) t)
          (write-region "" nil file)
          (find-file file))
      (user-error "Aborted"))))

;;;###autoload
(defun config-visit ()
  "Visit the main Emacs configuration file."
  (interactive)
  (let ((file (config--locate-init-file)))
    (if file
        (find-file file)
      (config--visit-or-create (expand-file-name "init.el" user-emacs-directory)))))

;;;###autoload
(defun config-visit-early-init ()
  "Visit the early initialization file."
  (interactive)
  (config--visit-or-create (expand-file-name "early-init.el" user-emacs-directory)))

(defun config--in-comment-p ()
  "Return non-nil if `match-beginning' 0 is inside an Elisp comment.
Checks whether a semicolon appears before the match on the same line."
  (save-excursion
    (goto-char (match-beginning 0))
    (string-match-p ";" (buffer-substring (line-beginning-position) (point)))))

(defun config--scan-use-packages (file)
  "Scan FILE for `use-package' declarations."
  (with-temp-buffer
    (insert-file-contents file)
    (goto-char (point-min))
    (let ((packages nil))
      (while (re-search-forward "(use-package[ \t]+\\(\\(?:\\sw\\|\\s_\\)+\\)" nil t)
        (unless (config--in-comment-p)
          (let ((name (intern (match-string 1)))
                (line (line-number-at-pos)))
            (push (list name file line) packages))))
      (nreverse packages))))

(defun config--config-directory ()
  "Return the directory containing the Emacs configuration files."
  (or (and user-emacs-directory
           (file-directory-p user-emacs-directory)
           (file-name-as-directory user-emacs-directory))
      (let ((init (config--locate-init-file)))
        (and init (file-name-directory init)))))

;;;###autoload
(defcustom config-scan-exclude-directories '("elpa" "straight" "quelpa" "eln-cache")
  "Directory names to exclude when scanning for `use-package' declarations."
  :type '(repeat string)
  :group 'config)

(defun config--file-in-excluded-dir-p (file dir)
  "Return non-nil if FILE is under an excluded subdirectory of DIR."
  (let ((relative (file-relative-name file dir)))
    (catch 'excluded
      (dolist (exclude config-scan-exclude-directories)
        (when (string-match-p (concat "^" (regexp-quote exclude) "/") relative)
          (throw 'excluded t))))))

(defun config--collect-packages ()
  "Collect all `use-package' declarations from .el files in the config directory."
  (let ((dir (config--config-directory)))
    (if (not dir)
        (user-error "No Emacs configuration directory found")
      (let ((packages nil)
            (files (directory-files-recursively dir "\\.el\\'")))
        (dolist (file files)
          (unless (config--file-in-excluded-dir-p file dir)
            (setq packages (nconc packages (config--scan-use-packages file)))))
        packages))))

(defun config--package-installed-p (name)
  "Return non-nil if package NAME is installed or available."
  (or (and (fboundp 'package-installed-p)
           (condition-case nil
               (package-installed-p name)
             (error nil)))
      (locate-library (symbol-name name))))

(defvar config-packages-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "RET") 'config-packages-visit)
    (define-key map (kbd "<mouse-2>") 'config-packages-visit)
    (define-key map (kbd "<return>") 'config-packages-visit)
    (define-key map (kbd "g") 'config-packages-revert)
    (define-key map (kbd "q") 'quit-window)
    map)
  "Keymap for `config-packages-mode'.")

(define-derived-mode config-packages-mode tabulated-list-mode "Config-Packages"
  "Major mode for listing use-package declarations from Emacs config."
  (setq tabulated-list-format [("Package" 20 t) ("Installed" 17 t) ("File" 40 t) ("Line" 5 t)])
  (setq tabulated-list-revert-hook '(config-packages--build-entries-set))
  (setq tabulated-list-sort-key (cons "Package" nil))
  (tabulated-list-init-header))

(defun config-packages-visit ()
  "Visit the file and jump to the use-package line on the current row."
  (interactive)
  (let ((col (get-text-property (point) 'tabulated-list-column-name)))
    (cond
     ((and (equal col "Installed")
           (let* ((id (tabulated-list-get-id))
                  (name (nth 0 id)))
             (not (config--package-installed-p name))))
      (config-packages-install))
     ((and (equal col "Installed")
           (let* ((id (tabulated-list-get-id))
                  (name (nth 0 id)))
             (config--package-installed-p name)))
      (config-packages-remove))
     (t
      (let* ((id (tabulated-list-get-id))
             (file (nth 1 id))
             (line (nth 2 id)))
        (when file
          (find-file file)
          (when line
            (goto-char (point-min))
            (forward-line (1- line)))))))))

(defun config-packages-install ()
  "Install the package on the current row of the packages list."
  (interactive)
  (let* ((id (tabulated-list-get-id))
         (name (nth 0 id)))
    (unless name
      (user-error "No package on current row"))
    (config-install-package (symbol-name name))
    (config-packages-revert)))

(defun config-packages-remove ()
  "Remove the package on the current row of the packages list."
  (interactive)
  (let* ((id (tabulated-list-get-id))
         (name (nth 0 id)))
    (unless name
      (user-error "No package on current row"))
    (unless (fboundp 'package-delete)
      (user-error "package.el is not available; cannot remove packages"))
    (let ((desc (cadr (assq name package-alist))))
      (if desc
          (progn
            (package-delete desc)
            (message "Package \"%s\" removed" name))
        (user-error "Package \"%s\" not found in package-alist" name))))
  (config-packages-revert))

(defun config-packages--build-entries ()
  "Build tabulated-list-entries from collected use-package declarations."
  (let ((packages (config--collect-packages)))
    (mapcar (lambda (pkg)
              (list (list (nth 0 pkg) (nth 1 pkg) (nth 2 pkg))
                    (vector (propertize (symbol-name (nth 0 pkg))
                                        'face 'link
                                        'follow-link t
                                        'mouse-face 'highlight)
                            (if (config--package-installed-p (nth 0 pkg))
                                (concat "yes "
                                        (propertize "remove"
                                                    'face 'config-packages-button
                                                    'follow-link t
                                                    'mouse-face 'config-packages-button-hover))
                              (concat "no "
                                      (propertize "install"
                                                  'face 'config-packages-button
                                                  'follow-link t
                                                  'mouse-face 'config-packages-button-hover)))
                            (nth 1 pkg)
                            (number-to-string (nth 2 pkg)))))
            packages)))

(defun config-packages--build-entries-set ()
  "Rebuild `tabulated-list-entries' from config.
Suitable for use as a `tabulated-list-revert-hook' function."
  (setq tabulated-list-entries (config-packages--build-entries)))

(defun config-packages-revert ()
  "Re-scan config and refresh the packages list."
  (interactive)
  (config-packages--build-entries-set)
  (tabulated-list-revert))

;;;###autoload
(defun config-list-packages ()
  "List every `use-package' declaration found in .el files in the config directory."
  (interactive)
  (let* ((buf (get-buffer-create "*Config Packages*")))
    (with-current-buffer buf
      (config-packages-mode)
      (setq tabulated-list-entries (config-packages--build-entries))
      (tabulated-list-print))
    (pop-to-buffer buf)))

(defun config--scan-non-packages (file)
  "Scan FILE for top-level forms that are not `use-package' declarations.
Returns a list of (FORM-HEAD FILE LINE) where FORM-HEAD is a string
preview of the form (up to 40 chars), FILE is the file path, and LINE
is the line number.  Skips comments, blank lines, and `use-package' forms."
  (with-temp-buffer
    (insert-file-contents file)
    (goto-char (point-min))
    (let ((forms nil))
      (while (re-search-forward "^(\\(\\(?:\\sw\\|\\s_\\)+\\)" nil t)
        (unless (config--in-comment-p)
          (let ((head (match-string 1))
                (line (line-number-at-pos))
                (start (match-beginning 0)))
            (unless (string= head "use-package")
              (let ((preview (config--form-preview start)))
                (push (list preview file line) forms))))))
      (nreverse forms))))

(defun config--form-preview (start)
  "Return a short preview string of the form starting at START.
Truncates at 40 characters."
  (save-excursion
    (goto-char start)
    (let ((beg (point)))
      (condition-case nil
          (forward-sexp 1)
        (scan-error (end-of-line)))
      (let ((text (buffer-substring-no-properties beg (point))))
        (if (> (length text) 40)
            (concat (substring text 0 37) "...")
          text)))))

(defun config--collect-non-packages ()
  "Collect all top-level forms that are not `use-package' from config files."
  (let ((dir (config--config-directory)))
    (if (not dir)
        (user-error "No Emacs configuration directory found")
      (let ((forms nil)
            (files (directory-files-recursively dir "\\.el\\'")))
        (dolist (file files)
          (unless (config--file-in-excluded-dir-p file dir)
            (setq forms (nconc forms (config--scan-non-packages file)))))
        forms))))

(defvar config-non-packages-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "RET") 'config-non-packages-visit)
    (define-key map (kbd "<mouse-2>") 'config-non-packages-visit)
    (define-key map (kbd "<return>") 'config-non-packages-visit)
    (define-key map (kbd "g") 'config-non-packages-revert)
    (define-key map (kbd "q") 'quit-window)
    map)
  "Keymap for `config-non-packages-mode'.")

(define-derived-mode config-non-packages-mode tabulated-list-mode "Config-Non-Packages"
  "Major mode for listing code outside `use-package' blocks in Emacs config."
  (setq tabulated-list-format [("Form" 40 t) ("File" 40 t) ("Line" 5 t)])
  (setq tabulated-list-revert-hook '(config-non-packages--build-entries-set))
  (setq tabulated-list-sort-key (cons "Form" nil))
  (tabulated-list-init-header))

(defun config-non-packages-visit ()
  "Visit the file and jump to the form on the current row."
  (interactive)
  (let* ((id (tabulated-list-get-id))
         (file (nth 1 id))
         (line (nth 2 id)))
    (when file
      (find-file file)
      (when line
        (goto-char (point-min))
        (forward-line (1- line))))))

(defun config-non-packages--build-entries ()
  "Build tabulated-list-entries from collected non-package forms."
  (let ((forms (config--collect-non-packages)))
    (mapcar (lambda (form)
              (list (list (nth 0 form) (nth 1 form) (nth 2 form))
                    (vector (propertize (nth 0 form)
                                        'face 'link
                                        'follow-link t
                                        'mouse-face 'highlight)
                            (nth 1 form)
                            (number-to-string (nth 2 form)))))
            forms)))

(defun config-non-packages--build-entries-set ()
  "Rebuild `tabulated-list-entries' for non-packages list.
Suitable for use as a `tabulated-list-revert-hook' function."
  (setq tabulated-list-entries (config-non-packages--build-entries)))

(defun config-non-packages-revert ()
  "Re-scan config and refresh the non-packages list."
  (interactive)
  (config-non-packages--build-entries-set)
  (tabulated-list-revert))

;;;###autoload
(defun config-list-non-packages ()
  "List top-level code outside `use-package' blocks in the config directory."
  (interactive)
  (let ((buf (get-buffer-create "*Config Non-Packages*")))
    (with-current-buffer buf
      (config-non-packages-mode)
      (setq tabulated-list-entries (config-non-packages--build-entries))
      (tabulated-list-print))
    (pop-to-buffer buf)))

(defun config--uninstalled-packages ()
  "Return a list of package names declared in config but not installed.
Each element is a string (the package name)."
  (let* ((packages (config--collect-packages)))
    (delq nil (mapcar (lambda (pkg)
                        (let ((name (nth 0 pkg)))
                          (unless (config--package-installed-p name)
                            (symbol-name name))))
                      packages))))

;;;###autoload
(defun config-install-package (name)
  "Install package NAME from the package archive."
  (interactive
   (let ((names (config--uninstalled-packages)))
     (unless names
       (user-error "All declared packages are already installed"))
     (list (completing-read "Install package: " names nil t))))
  (unless (fboundp 'package-install)
    (user-error "package.el is not available; cannot install packages"))
  (unless (and (boundp 'package-archive-contents) package-archive-contents)
    (message "Refreshing package archives...")
    (package-refresh-contents)
    (message "Refreshing package archives...done"))
  (let ((pkg-sym (intern name)))
    (if (package-installed-p pkg-sym)
        (message "Package \"%s\" is already installed" name)
      (package-install pkg-sym)
      (message "Package \"%s\" installed successfully" name))))

(defcustom config-package-archives
  '(("melpa" . "https://melpa.org/packages/")
    ("wkzelpa" . "https://elpa.wkz-devel.org/packages/"))
  "Package archives to offer adding when package installation fails.
Each element is (NAME . URL), matching `package-archives' format."
  :type '(alist :key-type string :value-type string)
  :group 'config)

(defun config--missing-archives ()
  "Return a list of entries from `config-package-archives' not in `package-archives'."
  (delq nil (mapcar (lambda (entry)
                      (unless (assoc (car entry) package-archives)
                        entry))
                    config-package-archives)))

(defun config--offer-add-missing-archives ()
  "Offer to add missing package archives and refresh.
Returns non-nil if archives were added and contents refreshed."
  (let ((missing (config--missing-archives)))
    (when missing
      (if (y-or-n-p (format "Some packages were not found.  Add missing package archives (%s)? "
                             (mapconcat #'car missing ", ")))
          (progn
            (dolist (entry missing)
              (add-to-list 'package-archives entry t))
            (message "Refreshing package archives...")
            (package-refresh-contents)
            (message "Refreshing package archives...done")
            t)
        nil))))

;;;###autoload
(defun config-install-all-packages ()
  "Install all packages declared in config but not yet installed.
Refreshes package archives, then installs each missing package.
If a package is not found, offers to add missing package archives
\(MELPA, wkzelpa) and retries."
  (interactive)
  (let ((names (config--uninstalled-packages)))
    (if (not names)
        (message "All declared packages are already installed")
      (unless (fboundp 'package-install)
        (user-error "package.el is not available; cannot install packages"))
      (unless (and (boundp 'package-archive-contents) package-archive-contents)
        (message "Refreshing package archives...")
        (package-refresh-contents)
        (message "Refreshing package archives...done"))
      (let ((failed nil)
            (installed 0))
        (dolist (name names)
          (let ((pkg-sym (intern name)))
            (unless (package-installed-p pkg-sym)
              (condition-case nil
                  (progn
                    (package-install pkg-sym)
                    (setq installed (1+ installed))
                    (message "Package \"%s\" installed successfully" name))
                (error
                 (push name failed))))))
        (when (and failed (config--offer-add-missing-archives))
          (dolist (name failed)
            (let ((pkg-sym (intern name)))
              (unless (package-installed-p pkg-sym)
                (condition-case nil
                    (progn
                      (package-install pkg-sym)
                      (setq installed (1+ installed))
                      (message "Package \"%s\" installed successfully" name))
                  (error
                   (message "Package \"%s\" could not be installed" name)))))))
        (if (> installed 0)
            (message "Installed %d package(s)" installed)
          (message "No packages were installed"))))))

(defun config--installed-declared-packages ()
  "Return a list of package names declared in config and installed.
Each element is a string (the package name)."
  (let* ((packages (config--collect-packages)))
    (delq nil (mapcar (lambda (pkg)
                        (let ((name (nth 0 pkg)))
                          (when (config--package-installed-p name)
                            (symbol-name name))))
                      packages))))

;;;###autoload
(defun config-delete-package (name)
  "Delete installed package NAME declared in the Emacs configuration."
  (interactive
   (let ((names (config--installed-declared-packages)))
     (unless names
       (user-error "No installed declared packages to delete"))
     (list (completing-read "Delete package: " names nil t))))
  (unless (fboundp 'package-delete)
    (user-error "package.el is not available; cannot delete packages"))
  (let ((pkg-sym (intern name)))
    (unless (config--package-installed-p pkg-sym)
      (user-error "Package \"%s\" is not installed" name))
    (let ((desc (cadr (assq pkg-sym package-alist))))
      (if desc
          (progn
            (package-delete desc)
            (message "Package \"%s\" deleted" name))
        (user-error "Package \"%s\" not found in package-alist" name)))))

;;;###autoload
(defun config-delete-all-packages ()
  "Delete all installed packages declared in the Emacs configuration."
  (interactive)
  (let ((names (config--installed-declared-packages)))
    (if (not names)
        (message "No installed declared packages to delete")
      (unless (fboundp 'package-delete)
        (user-error "package.el is not available; cannot delete packages"))
      (dolist (name names)
        (let ((pkg-sym (intern name)))
          (when (config--package-installed-p pkg-sym)
            (let ((desc (cadr (assq pkg-sym package-alist))))
              (when desc
                (package-delete desc)
                (message "Package \"%s\" deleted" name))))))
      (message "Deleted %d package(s)" (length names)))))

;;;###autoload
(defun config-visit-package (name)
  "Visit the `use-package' declaration for package NAME."
  (interactive
   (let* ((packages (config--collect-packages))
          (names (delete-dups (mapcar (lambda (p) (symbol-name (nth 0 p))) packages))))
     (list (completing-read "Package: " names nil t))))
  (let* ((packages (config--collect-packages))
         (match (cl-find-if (lambda (p) (equal (symbol-name (nth 0 p)) name)) packages)))
    (if match
        (progn
          (find-file (nth 1 match))
          (goto-char (point-min))
          (forward-line (1- (nth 2 match))))
      (user-error "Package \"%s\" not found in config" name))))

;;;###autoload
(defcustom config-vcs-directory nil
  "VCS repository directory for Emacs configuration.
Follows GNU Stow conventions: contains a .emacs.d/ subdirectory
whose files are symlinked into `user-emacs-directory'."
  :type '(choice (const :tag "Not configured" nil)
                 (directory :tag "VCS directory"))
  :group 'config)

(defun config--vcs-emacs-dir ()
  "Return the .emacs.d/ path inside `config-vcs-directory'."
  (when config-vcs-directory
    (expand-file-name ".emacs.d/" config-vcs-directory)))

;;;###autoload
(defun config-vcs ()
  "Show VCS status of the Emacs configuration repository.
If `config-vcs-directory' is not set, tries to detect an existing
VCS repository from symlinks in `user-emacs-directory'."
  (interactive)
  (when (and (not config-vcs-directory)
             (not (config--detect-vcs-directory)))
    (if (y-or-n-p "No VCS directory configured.  Set up one? ")
        (config-vcs-setup)
      (user-error "Aborted")))
  (cond
   ((not config-vcs-directory)
    (user-error "No VCS directory configured"))
   ((not (file-directory-p config-vcs-directory))
    (user-error "VCS directory %s does not exist" config-vcs-directory))
   (t
    (let ((backend (vc-responsible-backend config-vcs-directory)))
      (if backend
          (vc-dir config-vcs-directory backend)
        (if (y-or-n-p (format "No VCS repository in %s.  Initialize? "
                               config-vcs-directory))
            (config-vcs-setup)
          (user-error "Aborted")))))))

(defun config--detect-vcs-directory ()
  "Detect VCS directory from symlinks in `user-emacs-directory'.
Checks if config files (init.el, early-init.el, custom-settings.el)
are symlinks pointing into a VCS repository.  Returns the VCS
directory path if detected, or nil if no VCS directory is found."
  (let ((dir (config--config-directory)))
    (when dir
      (catch 'found
        (dolist (basename '("init.el" "early-init.el" "custom-settings.el"))
          (let* ((link-path (expand-file-name basename dir))
                 (target (and (file-symlink-p link-path)
                              (file-exists-p link-path)
                              (file-truename link-path))))
            (when target
              (let* ((target-dir (file-name-directory target))
                     (vcs-dir (and target-dir
                                   (string-match-p "/\\.emacs\\.d/?$" target-dir)
                                   (expand-file-name "../" target-dir))))
                (when (and vcs-dir
                           (file-directory-p vcs-dir)
                           (condition-case nil
                               (vc-responsible-backend vcs-dir)
                             (error nil)))
                  (throw 'found (file-name-as-directory vcs-dir)))))))))))

(defun config--vcs-maybe-copy (src dst)
  "Copy SRC to DST if SRC exists and DST does not, after user confirmation."
  (when (and src
             (file-readable-p src)
             (not (file-exists-p dst))
             (y-or-n-p (format "Copy %s to %s? " src dst)))
    (copy-file src dst t)))

(defun config--read-vc-backend ()
  "Prompt for a VCS backend from `vc-handled-backends' with completion."
  (intern (completing-read "VCS backend: "
                           (mapcar #'symbol-name vc-handled-backends)
                           nil t)))

;;;###autoload
(defun config-vcs-setup ()
  "Initialize a VCS repository for Emacs configuration tracking.
If `config-vcs-directory' is already set, use it.  Otherwise, detect
an existing VCS repository from symlinks in `user-emacs-directory'.
If a repository is detected, set `config-vcs-directory' and create
any missing symlinks.  If no repository is found, prompt for a
directory and initialize a new one."
  (interactive)
  (let* ((detected (config--detect-vcs-directory))
         (vcs-dir (or config-vcs-directory
                     detected
                     (file-name-as-directory
                      (read-directory-name "VCS repository directory: "
                                           (file-name-as-directory
                                            (expand-file-name "~/projects/emacs-config"))))))
         (vcs-emacs-dir (expand-file-name ".emacs.d/" vcs-dir))
         (backend (condition-case nil
                      (vc-responsible-backend vcs-dir)
                    (error nil))))
    (when (and (file-exists-p vcs-dir)
               (not (file-directory-p vcs-dir)))
      (user-error "%s is not a directory" vcs-dir))
    (make-directory vcs-emacs-dir t)
    (unless backend
      (let ((chosen-backend (config--read-vc-backend)))
        (save-excursion
          (let ((default-directory vcs-dir))
            (vc-create-repo chosen-backend)))))
    (setq config-vcs-directory vcs-dir)
    (config--vcs-maybe-copy (config--locate-init-file)
                            (expand-file-name "init.el" vcs-emacs-dir))
    (dolist (basename '("early-init.el" "custom-settings.el"))
      (config--vcs-maybe-copy (expand-file-name basename user-emacs-directory)
                              (expand-file-name basename vcs-emacs-dir)))
    (config--vcs-symlink-config-files vcs-emacs-dir)
    (message "VCS repository initialized in %s" vcs-dir)))

(defun config--vcs-symlink-config-files (vcs-emacs-dir)
  "Create symlinks from `user-emacs-directory' to VCS-EMACS-DIR."
  (dolist (file (directory-files vcs-emacs-dir nil "\\.el\\'"))
    (let* ((vcs-file (expand-file-name file vcs-emacs-dir))
           (link-path (expand-file-name file user-emacs-directory)))
      (when (or (not (file-exists-p link-path))
                (y-or-n-p (format "Replace %s with a symlink? " link-path)))
        (when (file-exists-p link-path)
          (delete-file link-path))
        (make-directory (file-name-directory link-path) t)
        (make-symbolic-link vcs-file link-path)
        (message "Created symlink %s -> %s" link-path vcs-file)))))

(defun config--remote-url-valid-p (url)
  "Return non-nil if URL looks like a valid remote repository URL.
Accepts HTTP(S) and SSH URLs such as:
  https://github.com/user/repo.git
  git@github.com:user/repo.git
  ssh://git@example.com/repo.git"
  (or (string-match-p "\\`https?://" url)
      (string-match-p "\\`ssh://" url)
      (string-match-p "\\`[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:" url)))

(defun config--vc-add-remote (dir url)
  "Add URL as a remote named \"origin\" to the VCS repository at DIR.
Uses `vc' to determine the backend and calls the appropriate command.
Returns the backend symbol on success, or nil on failure."
  (let ((backend (condition-case nil (vc-responsible-backend dir) (error nil))))
    (cond
     ((eq backend 'Git)
      (let ((default-directory dir))
        (condition-case nil
            (vc-git-command nil 0 nil "remote" "add" "origin" url)
          (error (vc-git-command nil 0 nil "remote" "set-url" "origin" url))))
      'Git)
     ((eq backend 'Hg)
      (let ((default-directory dir))
        (condition-case nil
            (vc-hg-command nil 0 nil "paths" "--config" (format "paths.default=%s" url))
          (error (vc-hg-command nil 0 nil "paths" "--config" (format "paths.default=%s" url)))))
      'Hg)
     (t
      (when backend
        (condition-case nil
            (vc-do-command nil 0 (vc-backend-command backend) "remote" "add" "origin" url)
          (error nil)))
      backend))))

(defun config--detect-vc-backend (url)
  "Return the likely VC backend symbol for URL, or nil if undecided.
Detects Git from \".git\" suffix or \"git@\" prefix."
  (cond
   ((string-match-p "\\.git\\'" url) 'Git)
   ((string-match-p "\\`git@" url) 'Git)
   (t nil)))

(defun config--vc-clone (url dir)
  "Clone repository from URL into DIR.
When a backend can be detected from the URL, pass it to `vc-clone'.
Otherwise try each backend in `vc-handled-backends' individually
until one succeeds.  Returns the directory on success, or nil."
  (make-directory dir t)
  (let ((backend (config--detect-vc-backend url)))
    (if backend
        (condition-case err
            (let ((result (vc-clone url backend dir)))
              (if result
                  (expand-file-name (file-name-as-directory result))
                dir))
          (error
           (message "Clone with %s failed: %s" backend (error-message-string err))
           nil))
      (catch 'cloned
        (dolist (b vc-handled-backends)
          (condition-case err
              (let ((result (vc-clone url b dir)))
                (when result
                  (throw 'cloned (expand-file-name (file-name-as-directory result)))))
            (error nil)))
        nil))))

(defun config--url-has-credentials-p (url)
  "Return non-nil if URL has embedded credentials (user:password@host)."
  (string-match-p "\\`https?://[^@]+@" url))

(defun config--url-with-credentials (url user password)
  "Embed USER and PASSWORD into URL for HTTP(S) authentication.
Returns a new URL like https://user:password@host/path."
  (if (string-match "\\`\\(https?://\\)\\(.*\\)" url)
      (concat (match-string 1 url)
              (url-hexify-string user) ":" (url-hexify-string password)
              "@" (match-string 2 url))
    url))

(defun config--vc-reset-remote-url (dir url)
  "Reset the origin remote URL in DIR to URL, removing any embedded credentials."
  (let ((default-directory dir))
    (condition-case nil
        (vc-git-command nil 0 nil "remote" "set-url" "origin" url)
      (error nil))))

;;;###autoload
(defun config-clone (url)
  "Clone a remote repository and set it as the VCS directory.

Prompts for a remote URL (HTTP(S) or SSH) and a target directory,
then clones the repository using `vc-clone'.  For HTTP(S) URLs,
prompts for username and password if authentication is required.
After cloning, the remote URL is reset to remove any embedded
credentials.  On success, sets `config-vcs-directory' to the
cloned directory."
  (interactive
   (list (read-string "Remote URL (https or ssh): ")))
  (unless (config--remote-url-valid-p url)
    (user-error "Invalid remote URL: %s.  Expected http(s) or ssh URL" url))
  (let* ((default-dir (file-name-as-directory
                       (expand-file-name "~/projects/emacs-config")))
         (dir (read-directory-name "Clone into directory: " default-dir))
         (clone-url url))
    (when (and (file-exists-p dir) (not (file-directory-p dir)))
      (user-error "%s is not a directory" dir))
    (when (and (string-match-p "\\`https?://" url)
               (not (config--url-has-credentials-p url)))
      (let ((user (read-string "Username (empty for no auth): ")))
        (unless (string-empty-p user)
          (let ((password (read-passwd "Password: ")))
            (setq clone-url (config--url-with-credentials url user password))))))
    (let ((cloned-dir (config--vc-clone clone-url dir)))
      (if cloned-dir
          (progn
            (when (not (equal clone-url url))
              (config--vc-reset-remote-url cloned-dir url))
            (setq config-vcs-directory cloned-dir)
            (message "Cloned %s into %s" url cloned-dir))
        (user-error "Failed to clone %s" url)))))

;;;###autoload
(defun config-remote (url)
  "Set the remote URL for the Emacs configuration VCS repository.

Requires `config-vcs-directory' to be set and point to an existing
VCS repository.  Adds or updates the remote named \"origin\" to URL."
  (interactive
   (list (read-string "Remote URL (https or ssh): ")))
  (unless (config--remote-url-valid-p url)
    (user-error "Invalid remote URL: %s.  Expected http(s) or ssh URL" url))
  (cond
   ((not config-vcs-directory)
    (user-error "No VCS directory configured.  Run M-x config-clone or M-x config-vcs-setup first"))
   ((not (file-directory-p config-vcs-directory))
    (user-error "VCS directory %s does not exist" config-vcs-directory))
   (t
    (let ((backend (condition-case nil
                       (vc-responsible-backend config-vcs-directory)
                     (error nil))))
      (unless backend
        (user-error "No VCS repository in %s.  Run M-x config-clone or M-x config-vcs-setup first"
                     config-vcs-directory))
      (config--vc-add-remote config-vcs-directory url)
      (message "Remote origin set to %s" url)))))

;;;###autoload
(defun config-vcs-add-files ()
  "Add a file or directory to the VCS repository and create symlinks.
Prompts for a file or directory.  Copies it to `config-vcs-directory'
(mirroring the path from the home directory) and replaces the
original with a symlink.  For directories, each file inside is
copied and symlinked individually (1-to-1)."
  (interactive)
  (unless config-vcs-directory
    (user-error "No VCS directory configured.  Run M-x config-vcs-setup first"))
  (let* ((target (expand-file-name (read-file-name "File or directory to add: ")))
         (home-dir (file-name-as-directory (expand-file-name "~"))))
    (unless (string-prefix-p home-dir target)
      (user-error "File must be under %s" home-dir))
    (let* ((rel-path (file-relative-name target home-dir))
           (vcs-path (expand-file-name rel-path config-vcs-directory)))
      (if (file-directory-p target)
          (config--vcs-add-directory target vcs-path)
        (config--vcs-add-file target vcs-path)))))

(defun config--vcs-add-file (src dst)
  "Copy file SRC to DST in VCS repo and replace SRC with a symlink."
  (when (or (not (file-exists-p dst))
            (y-or-n-p (format "%s already exists in repository. Overwrite? " dst)))
    (make-directory (file-name-directory dst) t)
    (copy-file src dst t)
    (when (y-or-n-p (format "Replace %s with a symlink? " src))
      (delete-file src)
      (make-symbolic-link dst src)
      (message "Added %s -> %s" src dst))))

(defun config--vcs-add-directory (src-dir dst-dir)
  "Copy files from SRC-DIR to DST-DIR and symlink."
  (let ((files (directory-files-recursively src-dir ".")))
    (dolist (src files)
      (let* ((rel (file-relative-name src src-dir))
             (dst (expand-file-name rel dst-dir)))
        (config--vcs-add-file src dst)))))

;;;###autoload
(defun config-commit ()
  "Stage all changes and commit in the configuration VCS repository."
  (interactive)
  (unless config-vcs-directory
    (user-error "No VCS directory configured.  Run M-x config-vcs-setup or M-x config-clone first"))
  (unless (file-directory-p config-vcs-directory)
    (user-error "VCS directory %s does not exist" config-vcs-directory))
  (let ((backend (condition-case nil
                     (vc-responsible-backend config-vcs-directory)
                   (error nil))))
    (unless backend
      (user-error "No VCS repository in %s" config-vcs-directory))
    (let ((msg (read-string "Commit message: ")))
      (when (string-empty-p msg)
        (user-error "Empty commit message"))
      (vc-checkin (list config-vcs-directory) backend msg))))

;;;###autoload
(defun config-push ()
  "Push commits from the configuration VCS repository to the remote."
  (interactive)
  (unless config-vcs-directory
    (user-error "No VCS directory configured.  Run M-x config-vcs-setup or M-x config-clone first"))
  (unless (file-directory-p config-vcs-directory)
    (user-error "VCS directory %s does not exist" config-vcs-directory))
  (let ((backend (condition-case nil
                     (vc-responsible-backend config-vcs-directory)
                   (error nil))))
    (unless backend
      (user-error "No VCS repository in %s" config-vcs-directory))
    (vc-push config-vcs-directory)
    (message "Pushed %s" config-vcs-directory)))

;;;###autoload
(defcustom config-pull-timeout 300
  "Timeout in seconds for `config-pull' before aborting the operation."
  :type 'integer
  :group 'config)

;;;###autoload
(defun config-pull ()
  "Fetch and update local branch from the configuration VCS repository remote.
Aborts with a warning if the operation exceeds `config-pull-timeout' seconds."
  (interactive)
  (unless config-vcs-directory
    (user-error "No VCS directory configured.  Run M-x config-vcs-setup or M-x config-clone first"))
  (unless (file-directory-p config-vcs-directory)
    (user-error "VCS directory %s does not exist" config-vcs-directory))
  (let ((backend (condition-case nil
                     (vc-responsible-backend config-vcs-directory)
                   (error nil))))
    (unless backend
      (user-error "No VCS repository in %s" config-vcs-directory))
    (with-timeout (config-pull-timeout
                   (user-error "Pull timed out after %s seconds" config-pull-timeout))
      (vc-pull config-vcs-directory))
    (message "Pulled %s" config-vcs-directory)))

(defun config--vc-changed-files (dir backend)
  "Return a list of VCS-tracked files with changes under DIR for BACKEND."
  (let ((default-directory dir))
    (cl-remove-if-not
     (lambda (f) (let ((state (vc-state f backend)))
                   (and state (not (eq state 'up-to-date)))))
     (vc-expand-dirs (list dir) backend))))

;;;###autoload
(defun config-diff ()
  "Show VCS diff for configuration files.
Uses `vc-diff' on the files tracked in `config-vcs-directory'.
If there are multiple changed files, their diffs are aggregated
into a single diff buffer."
  (interactive)
  (unless config-vcs-directory
    (user-error "No VCS directory configured.  Run M-x config-vcs-setup or M-x config-clone first"))
  (unless (file-directory-p config-vcs-directory)
    (user-error "VCS directory %s does not exist" config-vcs-directory))
  (let ((backend (condition-case nil
                     (vc-responsible-backend config-vcs-directory)
                   (error nil))))
    (unless backend
      (user-error "No VCS repository in %s" config-vcs-directory))
    (let ((files (config--vc-changed-files config-vcs-directory backend)))
      (if files
          (vc-diff files nil backend)
        (message "No changed files in %s" config-vcs-directory)))))

(provide 'config)
;;; config.el ends here
