;;; live-replace.el --- Replace regexp with live preview -*- lexical-binding: t; -*-

(defface live-replace-match
  '((t :inherit lazy-highlight))
  "Face for live-replace pattern matches.")

(defface live-replace-replacement
  '((t :inherit match))
  "Face for live-replace replacement preview text.")

(defvar live-replace--overlays nil
  "Active preview overlays.")

(defvar live-replace--timer nil
  "Idle timer for debounced updates.")

(defvar live-replace--source-buffer nil
  "The buffer being replaced in.")

(defvar live-replace--region-beg nil
  "Start of replacement region, or nil for whole buffer.")

(defvar live-replace--region-end nil
  "End of replacement region, or nil for whole buffer.")

(defun live-replace--parse (input)
  "Parse INPUT into (PATTERN . REPLACEMENT).
Split on the first unescaped `/'.  Return nil if no separator found."
  (when (string-match "\\(?:^\\|[^\\]\\)/" input)
    (let ((sep (1+ (match-beginning 0))))
      ;; handle case where `/` is the very first char
      (when (and (= (match-beginning 0) 0)
                 (= (aref input 0) ?/))
        (setq sep 0))
      (cons (substring input 0 sep)
            (substring input (1+ sep))))))

(defun live-replace--clear-overlays ()
  "Remove all preview overlays."
  (mapc #'delete-overlay live-replace--overlays)
  (setq live-replace--overlays nil))

(defun live-replace--update ()
  "Update preview overlays from current minibuffer contents."
  (setq live-replace--timer nil)
  (live-replace--clear-overlays)
  (let* ((input (minibuffer-contents-no-properties))
         (parsed (live-replace--parse input)))
    (when parsed
      (let ((pattern (car parsed))
            (replacement (cdr parsed)))
        (when (> (length pattern) 0)
          (with-current-buffer live-replace--source-buffer
            (condition-case nil
                (let* ((win (minibuffer-selected-window))
                       (beg (or live-replace--region-beg
                                (if win (window-start win) (point-min))))
                       (end (or live-replace--region-end
                                (if win (window-end win t) (point-max)))))
                  (save-excursion
                    (goto-char beg)
                    (while (and (re-search-forward pattern end t)
                                (not (= (match-beginning 0) (match-end 0)
                                        )))
                      (let ((ov (make-overlay (match-beginning 0) (match-end 0))))
                        (overlay-put ov 'face 'live-replace-match)
                        (overlay-put ov 'priority 1000)
                        (when (> (length replacement) 0)
                          (let ((repl (condition-case nil
                                          (match-substitute-replacement replacement)
                                        (error replacement))))
                            (put-text-property 0 (length repl)
                                               'face 'live-replace-replacement
                                               repl)
                            (overlay-put ov 'after-string repl)))
                        (push ov live-replace--overlays))
                      ;; advance past zero-width matches
                      (when (= (match-beginning 0) (match-end 0))
                        (forward-char)))))
              (invalid-regexp nil))))))))

(defun live-replace--after-change (&rest _)
  "Debounced hook for minibuffer changes."
  (when live-replace--timer
    (cancel-timer live-replace--timer))
  (setq live-replace--timer
        (run-with-idle-timer 0 nil #'live-replace--update)))

(defun live-replace--cleanup ()
  "Clean up overlays and timer."
  (when live-replace--timer
    (cancel-timer live-replace--timer)
    (setq live-replace--timer nil))
  (live-replace--clear-overlays))

;;;###autoload
(defun live-replace-regexp (beg end)
  "Replace regexp with live preview.
Prompts for PATTERN/REPLACEMENT.  With an active region, restrict
replacement to BEG..END; otherwise operate on the whole buffer."
  (interactive
   (if (use-region-p)
       (list (region-beginning) (region-end))
     (list nil nil)))
  (setq live-replace--source-buffer (current-buffer)
        live-replace--region-beg beg
        live-replace--region-end end)
  (let ((input (unwind-protect
                   (minibuffer-with-setup-hook
                       (lambda ()
                         (add-hook 'after-change-functions
                                   #'live-replace--after-change nil t))
                     (read-from-minibuffer "Replace (pattern/replacement): "))
                 (live-replace--cleanup))))
    (when (and input (> (length input) 0))
      (let ((parsed (live-replace--parse input)))
        (if (and parsed (> (length (car parsed)) 0))
            (let ((pattern (car parsed))
                  (replacement (cdr parsed))
                  (count 0))
              (save-excursion
                (goto-char (or beg (point-min)))
                (while (re-search-forward pattern (or end (point-max)) t)
                  (replace-match replacement)
                  (cl-incf count)))
              (message "Replaced %d occurrence%s" count
                       (if (= count 1) "" "s")))
          (message "No replacement separator found"))))))

(provide 'live-replace)
;;; live-replace.el ends here
