;;; $DOOMDIR/config.el -*- lexical-binding: t; -*-

;;; ============================================================
;;; USER IDENTITY
;;; ============================================================

(setq user-full-name  "Martin Sukany"
      user-mail-address "martin@sukany.cz")

;; CalDAV calendar IDs (edit here to update sync targets)
(defconst my/caldav-url       "https://cal.apps.sukany.cz/dav.php/calendars/martin")
(defconst my/caldav-id-suky   "default")
(defconst my/caldav-id-placeholders "4C748EE5-ECFF-4D4A-A72E-6DE37BAADEB3")
(defconst my/caldav-id-family "family")
(defconst my/caldav-id-klara  "klara")

;; Trust all TLS certificates (corporate MITM proxy with intermediate CA)
(setq gnutls-verify-error nil)
(setq tls-checktrust nil)
(setq network-security-level 'low)


;;; ============================================================
;;; THEME & FONT
;;; ============================================================

(setq doom-theme 'modus-vivendi-deuteranopia
      doom-variable-pitch-font nil)

;; Font: JetBrains Mono preferred; fallback to Menlo (always on macOS)
;; Install: brew install --cask font-jetbrains-mono
(setq doom-font (if (find-font (font-spec :name "JetBrains Mono"))
                    (font-spec :family "JetBrains Mono" :size 14)
                  (font-spec :family "Menlo" :size 14)))

(setq display-line-numbers-type t)

;; Start Emacs maximized on every launch (macOS: fills screen, keeps menu bar)
(add-to-list 'initial-frame-alist '(fullscreen . maximized))


;;; ============================================================
;;; UI & DISPLAY
;;; ============================================================

(setq doom-modeline-refresh-rate 1.0)

(setq which-key-idle-delay 0.8
      which-key-idle-secondary-delay 0.05)

;; Centered cursor mode — safe with macOS Zoom set to "Follow mouse cursor"
;; (NOT "Follow keyboard focus" — that causes viewport jumping)
(use-package! centered-cursor-mode
  :config
  (setq ccm-vpos-init 0.5
        ccm-step-size 2
        ccm-recenter-at-end-of-file t)
  (define-globalized-minor-mode my/global-ccm
    centered-cursor-mode
    (lambda ()
      (unless (memq major-mode '(vterm-mode eshell-mode term-mode
                                  treemacs-mode pdf-view-mode))
        (centered-cursor-mode 1))))
  (my/global-ccm +1))


;;; ============================================================
;;; MACOS / PLATFORM
;;; ============================================================

(setq mouse-autoselect-window nil
      focus-follows-mouse nil
      select-enable-clipboard t
      select-enable-primary t
      inhibit-splash-screen t)

;; PATH: ensure GUI Emacs sees the same paths as the terminal.
;; macOS GUI apps do not inherit the shell PATH.
(let ((extra-paths '("/opt/local/bin"
                     "/opt/local/sbin"
                     "/opt/homebrew/bin"
                     "/opt/homebrew/sbin"
                     "/usr/local/bin"
                     "/Library/TeX/texbin")))
  (dolist (p (reverse extra-paths))
    (setenv "PATH" (concat p ":" (getenv "PATH")))
    (add-to-list 'exec-path p)))

;; macOS clipboard integration via pbcopy/pbpaste
(defun my/pbcopy (text &optional _push)
  "Send TEXT to the macOS clipboard using pbcopy."
  (let ((process-connection-type nil))
    (let ((proc (start-process "pbcopy" "*pbcopy*" "pbcopy")))
      (process-send-string proc text)
      (process-send-eof proc))))

(defun my/pbpaste ()
  "Return text from the macOS clipboard using pbpaste."
  (when (executable-find "pbpaste")
    (string-trim-right (shell-command-to-string "pbpaste"))))

(setq interprogram-cut-function  #'my/pbcopy
      interprogram-paste-function #'my/pbpaste)

;; Let Evil use the system clipboard
(after! evil
  (setq evil-want-clipboard t)
  ;; Ensure dashboard buffer starts in normal state (required for SPC leader)
  (evil-set-initial-state '+doom-dashboard-mode 'normal))

;; Standard macOS modifier keys for GUI Emacs
(when (display-graphic-p)
  (setq mac-command-modifier 'super
        mac-option-modifier 'meta
        mac-right-option-modifier 'none))

;; Disable mouse clicks in GUI (prevent accidental cursor movement)
;; Scroll wheel events are NOT disabled — scrolling works normally.
(when (display-graphic-p)
  (dolist (key '([mouse-1] [mouse-2] [mouse-3]
                 [double-mouse-1] [double-mouse-2] [double-mouse-3]
                 [triple-mouse-1] [triple-mouse-2] [triple-mouse-3]
                 [drag-mouse-1] [drag-mouse-2] [drag-mouse-3]
                 [down-mouse-1] [down-mouse-2] [down-mouse-3]))
    (global-set-key key #'ignore))
  (setq mouse-highlight nil))

;; macOS scroll wheel: standard mwheel.el with conservative settings.
;; pixel-scroll-precision-mode is NOT used (targets X11, breaks NS/Cocoa).
(when (display-graphic-p)
  (require 'mwheel)
  (setq mouse-wheel-scroll-amount    '(3 ((shift) . 1) ((meta) . 0) ((control) . text-scale))
        mouse-wheel-progressive-speed nil
        mouse-wheel-follow-mouse      t
        mouse-wheel-tilt-scroll       t
        mouse-wheel-flip-direction    nil)
  (global-set-key [wheel-up]   #'mwheel-scroll)
  (global-set-key [wheel-down] #'mwheel-scroll))

;; Cancel persp-mode's 2.5s cache timer after startup
;; (reduces unnecessary redraws that cause macOS Zoom to jump)
(run-with-timer 3 nil
  (lambda ()
    (when (and (boundp 'persp-frame-buffer-predicate-buffer-list-cache--timer)
               (timerp persp-frame-buffer-predicate-buffer-list-cache--timer))
      (cancel-timer persp-frame-buffer-predicate-buffer-list-cache--timer)
      (setq persp-frame-buffer-predicate-buffer-list-cache--timer nil))))


;;; ============================================================
;;; CLIPBOARD IMAGE PASTE (macOS — cmd+v)
;;; ============================================================
;; Requires: brew install pngpaste
;; cmd+v: clipboard has image → saves to attachments/ → inserts link
;;         clipboard has text  → normal paste (yank)

(defun my/clipboard-has-image-p ()
  "Return non-nil if macOS clipboard contains an image (requires pngpaste)."
  (and (executable-find "pngpaste")
       (let ((tmp (make-temp-file "emacs-imgcheck" nil ".png")))
         (prog1 (= 0 (call-process "pngpaste" nil nil nil tmp))
           (ignore-errors (delete-file tmp))))))

(defun my/paste-image-from-clipboard ()
  "Save clipboard image to attachments/ and insert link at point."
  (interactive)
  (let* ((base-dir   (if buffer-file-name
                         (file-name-directory (buffer-file-name))
                       default-directory))
         (attach-dir (expand-file-name "attachments" base-dir))
         (filename   (format-time-string "image-%Y%m%d-%H%M%S.png"))
         (filepath   (expand-file-name filename attach-dir))
         (relpath    (concat "attachments/" filename)))
    (make-directory attach-dir t)
    (if (= 0 (call-process "pngpaste" nil nil nil filepath))
        (progn
          (insert (pcase major-mode
                    ('org-mode      (format "[[./%s]]" relpath))
                    ('markdown-mode (format "![%s](./%s)" filename relpath))
                    (_              relpath)))
          (message "Image saved: %s" relpath))
      (error "pngpaste failed — no image in clipboard?"))))

(defun my/smart-paste ()
  "Paste image if clipboard has one, else normal yank."
  (interactive)
  (if (my/clipboard-has-image-p)
      (my/paste-image-from-clipboard)
    (yank)))

(map! :after org
      :map org-mode-map
      "s-v" #'my/smart-paste)
(map! :after markdown-mode
      :map markdown-mode-map
      "s-v" #'my/smart-paste)


;;; ============================================================
;;; PERFORMANCE & GC
;;; ============================================================

(setq gc-cons-threshold  (* 100 1024 1024)
      gc-cons-percentage 0.6)

(after! gcmh
  (setq gcmh-idle-delay 'auto
        gcmh-auto-idle-delay-factor 20
        gcmh-high-cons-threshold (* 200 1024 1024)))

(add-hook 'focus-out-hook #'garbage-collect)

;; Auto-save all buffers on idle (replaces noisy #file# autosave)
(setq auto-save-default nil)
(defun my/save-all-buffers () (save-some-buffers t))
(run-with-idle-timer 10 t #'my/save-all-buffers)

;; TLS: also allow TLS 1.2 fallback for self-signed local services
(setq gnutls-algorithm-priority "NORMAL:-VERS-TLS1.3")


;;; ============================================================
;;; ORG MODE — CORE
;;; ============================================================

;; Czech holidays only (no US/Hebrew/Christian defaults)
(setq calendar-holidays
      '((holiday-fixed  1  1 "New Year / Czech Independence Restoration Day")
        (holiday-easter-etc -2 "Good Friday")
        (holiday-easter-etc  1 "Easter Monday")
        (holiday-fixed  5  1 "Labour Day")
        (holiday-fixed  5  8 "Victory Day")
        (holiday-fixed  7  5 "Saints Cyril and Methodius Day")
        (holiday-fixed  7  6 "Jan Hus Day")
        (holiday-fixed  9 28 "Czech Statehood Day")
        (holiday-fixed 10 28 "Czechoslovak Independence Day")
        (holiday-fixed 11 17 "Freedom and Democracy Day")
        (holiday-fixed 12 24 "Christmas Eve")
        (holiday-fixed 12 25 "Christmas Day")
        (holiday-fixed 12 26 "St. Stephen's Day")))

(after! org
  (require 'ox-hugo)

  (setq org-directory "~/org/")
  (setq org-default-notes-file (expand-file-name "inbox.org" org-directory))

  (defun ms/org-file (name)
    "Return absolute path to NAME inside `org-directory`."
    (expand-file-name name org-directory))

  (setq org-todo-keywords
        '((sequence "TODO(t)" "NEXT(n)" "WAIT(w@/!)" "|" "DONE(d!)" "CANCELLED(c@)")))

  (setq org-log-done 'time)

  (setq org-refile-targets '((org-agenda-files :maxlevel . 5))
        org-outline-path-complete-in-steps nil
        org-refile-use-outline-path 'file)

  (defun my/project-org-file ()
    "Return path to ./project.org in current Projectile project, if it exists."
    (when-let ((root (projectile-project-root)))
      (let ((f (expand-file-name "project.org" root)))
        (when (file-exists-p f) f))))

  ;; Update all dynamic blocks before export
  (add-hook 'org-export-before-processing-hook
            (lambda (_backend) (org-update-all-dblocks)))

  ;; Visual: hide markup, pretty entities, compact tags
  (setq org-startup-indented nil    ; conflicts with org-modern star display
        org-startup-folded 'content      ; show all headings, hide body text
        org-hide-emphasis-markers t
        org-pretty-entities t
        org-ellipsis " ▾"
        org-auto-align-tags nil
        org-tags-column 0)

  (setq org-capture-restore-window-after-quit t))


;;; ============================================================
;;; ORG MODE — CAPTURE
;;; ============================================================

(after! org
  (setq org-capture-templates
        `(("i" "Inbox task" entry
           (file ,(ms/org-file "inbox.org"))
           "* TODO %?\n%U\n%a\n")

          ("n" "Note" entry
           (file+headline ,(ms/org-file "inbox.org") "Notes")
           "* %?\n%U\n%a\n")

          ("p" "Project task" entry
           (file ,(ms/org-file "inbox.org"))
           "* TODO %? :project:\n%U\n%a\n")

          ("s" "Clocked subtask" entry (clock)
           "* TODO %?\n%U\n%a\n%i"
           :empty-lines 1)

          ("j" "Journal" entry
           (file+olp+datetree ,(ms/org-file "journal.org"))
           "\n* %<%I:%M %p> - Journal :journal:\n\n%?\n\n"
           :clock-in :clock-resume
           :empty-lines 1)

          ("m" "Meeting" entry
           (file+olp+datetree ,(ms/org-file "journal.org"))
           "* %<%I:%M %p> - %^{Meeting title} :meetings:\nContext: %a\n\n%?\n\n"
           :clock-in :clock-resume
           :empty-lines 1)

          ("e" "Checking Email" entry
           (file+olp+datetree ,(ms/org-file "journal.org"))
           "* Checking Email :email:\n\n%?"
           :clock-in :clock-resume
           :empty-lines 1)

          ("w" "Weight" table-line
           (file+headline ,(ms/org-file "metrics.org") "Weight")
           "| %U | %^{Weight} | %^{Notes} |"
           :kill-buffer t)

          ("c" "Calendar event" entry
           (file ,(ms/org-file "calendar_outbox.org"))
           "* %?\n%(my/calfw-capture-timestamp)\n"
           :empty-lines 1))))

;; Ensure ox-icalendar exports active timestamps with times correctly
(after! ox-icalendar
  (setq org-icalendar-with-timestamps 'active
        org-icalendar-timezone "Europe/Prague"))


;;; ============================================================
;;; ORG MODE — AGENDA
;;; ============================================================

(after! org
  (setq org-agenda-files (list org-directory
                               (expand-file-name "projects" org-directory)
                               (expand-file-name "roam"     org-directory)
                               (expand-file-name "notes"    org-directory))))


;;; ============================================================
;;; ORG MODE — LATEX EXPORT
;;; ============================================================

;; Table export filter: tabular -> tabularx with auto-width Y columns.
;; Skipped for beamer exports (beamer uses adjustbox on plain tabular).
;; Y column type defined in document.org template (RaggedRight + auto-width X).
(defun my/org-latex-col-to-lyyy (spec)
  "Convert tabular column SPEC to lYYY: first col l, rest Y (auto-width)."
  (let ((ncols (max 1 (length (replace-regexp-in-string "[^lrcLRCpP]" "" spec)))))
    (if (= ncols 1) "Y"
      (concat "l" (make-string (1- ncols) ?Y)))))

(defun my/org-latex-fix-tabularx (table backend _info)
  "Convert tabular/tabularx -> tabularx{linewidth}{lYYY} in LaTeX output.
Skip for beamer exports — beamer uses adjustbox on plain tabular."
  (when (and (stringp table)
             (not (org-export-derived-backend-p backend 'beamer)))
    (with-temp-buffer
      (insert table)
      (goto-char (point-min))
      (while (re-search-forward "\\\\begin{tabular[x]?}{\\([^}]*\\)}" nil t)
        (let* ((spec (match-string 1))
               (new-spec (my/org-latex-col-to-lyyy spec))
               (repl (concat "\\begin{tabularx}{\\linewidth}{" new-spec "}")))
          (replace-match repl t t)))
      (goto-char (point-min))
      (while (re-search-forward "\\\\end{tabular}" nil t)
        (replace-match "\\end{tabularx}" t t))
      (buffer-string))))

(with-eval-after-load 'ox-latex
  (add-to-list 'org-export-filter-table-functions #'my/org-latex-fix-tabularx)
  (add-to-list 'org-latex-packages-alist '("" "tabularx")))

(defun my/org-ensure-tabularx-filter (backend)
  "Force ox-latex load and register tabularx filter before LaTeX export."
  (when (org-export-derived-backend-p backend 'latex)
    (require 'ox-latex)
    (add-to-list 'org-export-filter-table-functions #'my/org-latex-fix-tabularx)
    (add-to-list 'org-latex-packages-alist '("" "tabularx"))))

(add-hook 'org-export-before-processing-hook #'my/org-ensure-tabularx-filter)

;; Ensure latexmk doesn't hang on errors
(after! org
  (setq org-latex-pdf-process
        '("latexmk -f -pdf -%latex -interaction=nonstopmode -output-directory=%o %f")))

;; LaTeX aux file cleanup after export
(with-eval-after-load 'ox-latex
  (setq org-latex-remove-logfiles t
        org-latex-logfiles-extensions
        '("aux" "bbl" "bcf" "blg" "fdb_latexmk" "fls" "idx" "log"
          "nav" "out" "ptc" "run.xml" "snm" "synctex.gz" "toc" "vrb" "xdv")))

;; Export directory routing: all exports go to ~/exports/<type>/
(defun my/org-export-directory (extension)
  "Return ~/exports/<type>/ directory for file EXTENSION, creating it if needed."
  (let* ((ext (downcase (string-trim-left extension "\\.")))
         (subdir (if (string= ext "tex") "pdf" ext))
         (dir (expand-file-name (concat "exports/" subdir "/") "~")))
    (make-directory dir t)
    dir))

(defun my/org-export-output-file-name (orig extension &optional subtreep pub-dir)
  "Route org exports to ~/exports/<type>/ unless pub-dir is already set."
  (funcall orig extension subtreep (or pub-dir (my/org-export-directory extension))))

(advice-add 'org-export-output-file-name :around #'my/org-export-output-file-name)


;;; ============================================================
;;; ORG MODE — CUSTOM BEHAVIOR
;;; ============================================================

;; Also trigger on post-command-hook in agenda buffers (catches Evil j/k,
;; super-agenda navigation, and any other motion commands)
(add-hook 'org-agenda-mode-hook
          (lambda ()
            (add-hook 'post-command-hook
                      #'my/org-agenda-goto-task-name nil t)))

;; Org buffer: snap cursor past TODO keyword/priority on headings in normal state
(defun my/org-heading-snap-past-keyword ()
  "In Evil normal state, snap cursor past TODO keyword and priority on org headings."
  (when (and (derived-mode-p 'org-mode)
             (evil-normal-state-p)
             (org-at-heading-p))
    (let* ((kw-re (regexp-opt (my/org-agenda-all-keywords) 'words))
           (task-start
            (save-excursion
              (beginning-of-line)
              (skip-chars-forward "* ")
              (when (looking-at kw-re)
                (goto-char (match-end 0))
                (skip-chars-forward " \t")
                (when (looking-at "\\[#.\\][ \t]+")
                  (goto-char (match-end 0)))
                (point)))))
      (when (and task-start (< (point) task-start))
        (goto-char task-start)))))

(add-hook 'org-mode-hook
          (lambda ()
            (add-hook 'post-command-hook
                      #'my/org-heading-snap-past-keyword
                      nil t)))


;;; ============================================================
;;; GPTEL — AI INTEGRATION (OpenWebUI / OpenRouter)
;;; ============================================================

(use-package! gptel
  :config
  ;; API key from environment variable (no secrets in config)
  (defun my/openwebui-key ()
    (or (getenv "OPENWEBUI_API_KEY")
        (user-error "Missing OPENWEBUI_API_KEY env var")))

  ;; Fetch available models from OpenWebUI /api/models
  (defun my/openwebui-fetch-model-ids ()
    "Return list of model ids from OpenWebUI /api/models."
    (require 'url)
    (require 'json)
    (let* ((url-request-method "GET")
           (url-request-extra-headers
            `(("Authorization" . ,(concat "Bearer " (funcall #'my/openwebui-key))))))
      (with-current-buffer (url-retrieve-synchronously
                            "https://ai.apps.sukany.cz/api/models" t t 15)
        (goto-char (point-min))
        (re-search-forward "\n\n" nil 'move)
        (let* ((json-object-type 'alist)
               (json-array-type  'list)
               (json-key-type    'symbol)
               (obj  (json-read))
               (data (alist-get 'data obj))
               (ids  (delq nil (mapcar (lambda (it) (alist-get 'id it)) data))))
          (kill-buffer (current-buffer))
          ids))))

  (defvar my/openwebui-models-cache nil)

  (defun my/openwebui-models ()
    "Return cached list of model ids; falls back to a minimal list on failure."
    (or my/openwebui-models-cache
        (setq my/openwebui-models-cache
              (condition-case err
                  (my/openwebui-fetch-model-ids)
                (error
                 (message "OpenWebUI models fetch failed: %s" err)
                 '("openai/gpt-4o-mini" "openai/gpt-4.1-mini"))))))

  (defun my/openwebui-refresh-models ()
    "Clear model cache and refetch from OpenWebUI."
    (interactive)
    (setq my/openwebui-models-cache nil)
    (message "OpenWebUI models refreshed: %d" (length (my/openwebui-models))))

  ;; Register OpenWebUI as an OpenAI-compatible backend
  (setq gptel-backend
        (gptel-make-openai "OpenWebUI"
          :host "ai.apps.sukany.cz"
          :protocol "https"
          :key #'my/openwebui-key
          :endpoint "/api/chat/completions"
          :stream t
          :curl-args '("--http1.1")
          :models (my/openwebui-models)))

  ;; Default model
  (let* ((models (my/openwebui-models))
         (preferred "openai/gpt-5-mini"))
    (setq gptel-model (if (member preferred models) preferred (car models))))

  ;; Presets for quick task-specific model switching
  (gptel-make-preset 'fast
    :description "Default (fast/cheap) — everyday work"
    :backend "OpenWebUI"
    :model "openai/gpt-4o-mini"
    :system "Reply in Czech. Be specific and step-by-step. No fluff."
    :temperature 0.2)

  (gptel-make-preset 'coding
    :description "Code / refactor / review"
    :backend "OpenWebUI"
    :model "openai/gpt-4.1-mini"
    :system "You are a strict code reviewer. Propose concrete changes and flag risks."
    :temperature 0.1)

  (gptel-make-preset 'deep
    :description "Complex analysis / architecture"
    :backend "OpenWebUI"
    :model "openai/gpt-4.1"
    :system "Work systematically. Provide alternatives, tradeoffs, and a recommendation."
    :temperature 0.2))

;; CLI helper: call gptel from emacs --batch
(defun my/gptel-cli (prompt &optional model system)
  "Send PROMPT via gptel and print response to stdout."
  (require 'gptel)
  (let* ((done nil)
         (result nil)
         (gptel-model (or model gptel-model))
         (gptel--system-message (or system gptel--system-message)))
    (gptel-request prompt
      :callback (lambda (response _info)
                  (setq result response)
                  (setq done t)))
    (while (not done) (accept-process-output nil 0.05))
    (princ result)))

;; GPTel keybindings under SPC o g
(after! gptel
  (map! :leader
        (:prefix ("o g" . "GPTel")
         :desc "Send (region or buffer)"   "s" #'gptel-send
         :desc "Menu (model/scope/preset)" "m" #'gptel-menu
         :desc "Chat buffer"               "c" #'gptel
         :desc "Abort request"             "x" #'gptel-abort
         :desc "Refresh OpenWebUI models"  "R" #'my/openwebui-refresh-models)))

;; GPTel region rewrite & org heading prompt
(after! gptel
  (defun my/gptel-rewrite-region (beg end)
    "Send selected region to GPTel with 'improve text' instruction and replace with response."
    (interactive "r")
    (let ((text (buffer-substring-no-properties beg end)))
      (gptel-request
       (concat "Improve the following text. Return ONLY the improved text, nothing else:\n\n" text)
       :callback (lambda (response info)
                   (if response
                       (save-excursion
                         (delete-region beg end)
                         (goto-char beg)
                         (insert response)
                         (message "GPTel: text improved"))
                     (message "GPTel rewrite failed: %s" (plist-get info :status)))))))

  (defun my/gptel-org-heading-prompt ()
    "Send current org heading + content as context to GPTel chat."
    (interactive)
    (unless (derived-mode-p 'org-mode)
      (user-error "Only available in org-mode"))
    (let* ((heading (org-get-heading t t t t))
           (content (save-excursion
                      (org-back-to-heading t)
                      (let ((beg (point)))
                        (org-end-of-subtree t t)
                        (buffer-substring-no-properties beg (point))))))
      (gptel content)
      (message "GPTel: heading '%s' sent as context" heading)))

  (map! :leader
        (:prefix ("o g" . "GPTel")
         :desc "Rewrite region"        "r" #'my/gptel-rewrite-region
         :desc "Org heading -> GPTel"  "p" #'my/gptel-org-heading-prompt)))


;;; ============================================================
;;; COMPLETION — CORFU + CAPE
;;; ============================================================

(after! corfu
  (setq corfu-auto t
        corfu-auto-delay 2.0
        corfu-auto-prefix 3         ; need 3+ chars before popup
        corfu-cycle t
        corfu-preselect 'prompt
        corfu-quit-no-match 'separator
        corfu-preview-current nil)
  ;; Re-set delay after global-corfu-mode to override Doom defaults
  (add-hook 'global-corfu-mode-hook
            (lambda () (setq corfu-auto-delay 2.0)))
  (global-corfu-mode))

(use-package! cape
  :after corfu
  :config
  (defun martin/cape-capf-setup-text ()
    "Cape sources for text modes (org, markdown) — no Elisp symbols."
    (add-to-list 'completion-at-point-functions #'cape-dabbrev 0)
    (add-to-list 'completion-at-point-functions #'cape-file 0))
  (defun martin/cape-capf-setup-prog ()
    "Cape sources for prog modes — includes Elisp symbols only in Elisp."
    (add-to-list 'completion-at-point-functions #'cape-dabbrev 0)
    (add-to-list 'completion-at-point-functions #'cape-file 0)
    (add-to-list 'completion-at-point-functions #'cape-keyword 0)
    (when (derived-mode-p 'emacs-lisp-mode)
      (add-to-list 'completion-at-point-functions #'cape-elisp-symbol 0)))
  (add-hook 'text-mode-hook #'martin/cape-capf-setup-text)
  (add-hook 'prog-mode-hook #'martin/cape-capf-setup-prog))

;; Yasnippet-capf first in CAPF list so corfu shows/expands snippets
(after! (yasnippet corfu)
  (dolist (hook '(org-mode-hook markdown-mode-hook text-mode-hook prog-mode-hook))
    (add-hook hook
      (lambda ()
        (add-hook 'completion-at-point-functions #'yasnippet-capf 0 t)))))

;; Corfu popup in terminal — only for Emacs < 31 (31+ handles it natively)
(use-package! corfu-terminal
  :when (and (not (display-graphic-p))
             (< emacs-major-version 31))
  :after corfu
  :config
  (corfu-terminal-mode +1))


;;; ============================================================
;;; EMAIL — MU4E
;;; ============================================================

(add-to-list 'load-path
             (expand-file-name "/opt/homebrew/opt/mu/share/emacs/site-lisp/mu/mu4e"))

(after! mu4e
  ;; --- Mailbox layout ---
  (setq mu4e-maildir                  "~/.mail"
        mu4e-get-mail-command         "mbsync personal"
        mu4e-update-interval          300
        mu4e-change-filenames-when-moving t
        mu4e-view-show-images         t
        mu4e-sent-folder   "/personal/Sent"
        mu4e-drafts-folder "/personal/Drafts"
        mu4e-trash-folder  "/personal/Trash"
        mu4e-refile-folder "/personal/Archive")

  ;; --- Headers view ---
  (setq mu4e-headers-show-threads     t
        mu4e-headers-include-related  t
        mu4e-use-fancy-chars          t
        mu4e-headers-mark-for-thread  t
        mu4e-headers-fields '((:human-date  . 12)
                               (:flags       . 6)
                               (:from-or-to . 25)
                               (:subject))
        mu4e-headers-sort-field      :date
        mu4e-headers-sort-direction  'descending
        ;; Thread prefixes — fancy Unicode
        mu4e-headers-thread-single-orphan-prefix '("─>" . "─▶ ")
        mu4e-headers-thread-orphan-prefix        '("┬>" . "┬▶ ")
        mu4e-headers-thread-connection-prefix    '("│"  . "│ ")
        mu4e-headers-thread-first-child-prefix   '("├>" . "├▶ ")
        mu4e-headers-thread-last-child-prefix    '("└>" . "└▶ ")
        mu4e-headers-thread-duplicate-prefix     '("="  . "≡ "))

  ;; --- Bookmarks ---
  ;; Keys: u=Unread i=Inbox d=Today w=Week
  ;; Note: ?d for Today avoids conflict with maildir shortcut ?t=Trash
  (setq mu4e-bookmarks
        '((:name "Unread"
           :query "flag:unread AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent AND NOT maildir:/personal/Drafts AND NOT maildir:/personal/Spam"
           :key ?u)
          (:name "Inbox"  :query "maildir:/personal/INBOX" :key ?i)
          (:name "Today"  :query "date:today AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?d)
          (:name "Week"   :query "date:7d..now AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?w)))

  ;; --- Maildir shortcuts (jump with 'j') ---
  ;; Keys: i=INBOX s=Sent T=Trash a=Archive
  ;; ?T (uppercase) for Trash avoids conflict with bookmark ?t (was Today)
  (setq mu4e-maildir-shortcuts
        '(("/personal/INBOX"   . ?i)
          ("/personal/Sent"    . ?s)
          ("/personal/Trash"   . ?T)
          ("/personal/Archive" . ?a)))

  ;; --- Sending ---
  (setq sendmail-program               "msmtp"
        message-send-mail-function     #'message-send-mail-with-sendmail
        mail-specify-envelope-from     t
        message-sendmail-envelope-from 'header)

  ;; --- Compose / reply ---
  ;; message-cite-function is a message-mode setting but configured here
  ;; because it only matters in the context of mu4e replies.
  (setq message-cite-function  #'message-cite-original-without-signature
        message-signature-file (expand-file-name "~/.mail/signature")
        message-signature      t)

  ;; --- Citation colors ---
  ;; gnus-cite-*         : colors in the view (read) buffer
  ;; message-cited-text-*: colors in the compose (reply) buffer
  ;; Both use the same Dracula palette for visual consistency.
  ;; Duplicate face names are intentional — gnus and message-mode
  ;; use separate face systems even though they render the same content.
  (setq gnus-cite-face-list
        '(gnus-cite-1 gnus-cite-2 gnus-cite-3 gnus-cite-4))
  (custom-set-faces!
    '(gnus-cite-1          :foreground "#8be9fd" :italic t)  ; cyan   — level 1
    '(gnus-cite-2          :foreground "#bd93f9" :italic t)  ; purple — level 2
    '(gnus-cite-3          :foreground "#6272a4" :italic t)  ; blue   — level 3
    '(gnus-cite-4          :foreground "#44475a" :italic t)  ; grey   — level 4+
    '(message-cited-text-1 :foreground "#8be9fd" :italic t)
    '(message-cited-text-2 :foreground "#bd93f9" :italic t)
    '(message-cited-text-3 :foreground "#6272a4" :italic t)
    '(message-cited-text-4 :foreground "#44475a" :italic t))

  ;; --- View: skip to message body ---
  ;; gnus-article-prepare-hook fires when the article buffer is ready,
  ;; covering both the initial render and navigation between messages.
  ;; No need for mu4e-view-mode-hook (that fires earlier, before content).
  (defun my/mu4e-view-goto-body ()
    "Position cursor at message body, skipping RFC 2822 headers."
    (run-with-idle-timer
     0.05 nil
     (lambda ()
       (when-let ((buf (get-buffer "*mu4e-article*")))
         (with-current-buffer buf
           (goto-char (point-min))
           (while (and (not (eobp))
                       (looking-at "^\([A-Za-z-]+:\|[ \t]\)"))
             (forward-line 1))
           (while (and (not (eobp)) (looking-at "^\s-*$"))
             (forward-line 1)))))))
  (add-hook 'gnus-article-prepare-hook #'my/mu4e-view-goto-body)

  ;; --- Headers: keep cursor on subject column after j/k ---
  (defun my/mu4e-goto-subject (&rest _)
    "Move cursor to the start of the subject text in a headers line."
    (when (derived-mode-p 'mu4e-headers-mode)
      (let* ((msg (mu4e-message-at-point t))
             (subject (when msg (mu4e-message-field msg :subject))))
        (when (and subject (> (length subject) 0))
          (beginning-of-line)
          (let ((needle (substring subject 0 (min 10 (length subject)))))
            (when (search-forward needle (line-end-position) t)
              (goto-char (match-beginning 0))))))))
  (advice-add 'mu4e-headers-next :after #'my/mu4e-goto-subject)
  (advice-add 'mu4e-headers-prev :after #'my/mu4e-goto-subject)

  ;; zT = toggle thread view (plain T marks the thread for bulk action)
  (evil-define-key 'normal mu4e-headers-mode-map
    (kbd "zT") #'mu4e-headers-toggle-threading))

;;; ============================================================
;;; RSS — ELFEED
;;; ============================================================


(after! org
  (setq rmh-elfeed-org-files
        (list (expand-file-name "elfeed.org" org-directory))))

(after! elfeed
  (require 'elfeed-org)
  (elfeed-org))


;;; ============================================================
;;; PLANTUML
;;; ============================================================

(add-to-list 'auto-mode-alist '("\\.puml\\'"     . plantuml-mode))
(add-to-list 'auto-mode-alist '("\\.plantuml\\'" . plantuml-mode))

(after! plantuml-mode
  (setq plantuml-default-exec-mode 'server
        plantuml-server-url        "https://www.plantuml.com/plantuml"
        plantuml-output-type       "svg"
        plantuml-verbose           t))

(defun my/plantuml-encode-hex (text)
  "Encode TEXT using PlantUML HEX encoding (~h + hex(UTF-8 bytes))."
  (let* ((utf8 (encode-coding-string text 'utf-8 t)))
    (concat "~h"
            (apply #'concat
                   (mapcar (lambda (b) (format "%02x" b))
                           (append utf8 nil))))))

(defun my/plantuml-fix-png-header (file)
  "Strip any bytes before the PNG signature in FILE."
  (let ((sig (unibyte-string #x89 ?P ?N ?G ?\r ?\n #x1a ?\n)))
    (with-temp-buffer
      (set-buffer-multibyte nil)
      (insert-file-contents-literally file)
      (goto-char (point-min))
      (unless (looking-at (regexp-quote sig))
        (let ((pos (search-forward sig nil t)))
          (unless pos (user-error "PNG signature not found"))
          (delete-region (point-min) (- pos (length sig)))
          (let ((coding-system-for-write 'binary))
            (write-region (point-min) (point-max) file nil 'silent)))))))

(defun my/plantuml-render-server (type)
  "Render current .puml buffer via PlantUML server to TYPE (png or svg)."
  (interactive (list (completing-read "Type: " '("png" "svg") nil t "png")))
  (unless buffer-file-name (user-error "Open .puml as a file first"))
  (let* ((text    (buffer-substring-no-properties (point-min) (point-max)))
         (encoded (my/plantuml-encode-hex text))
         (server  (string-remove-suffix "/" plantuml-server-url))
         (url     (format "%s/%s/%s" server type encoded))
         (out     (concat (file-name-sans-extension buffer-file-name) "." type)))
    (url-copy-file url out t)
    (when (string-equal type "png")
      (my/plantuml-fix-png-header out))
    (message "PlantUML saved: %s" out)
    out))

(after! plantuml-mode
  (define-key plantuml-mode-map (kbd "C-c C-p")
              (lambda () (interactive) (my/plantuml-render-server "png")))
  (define-key plantuml-mode-map (kbd "C-c C-s")
              (lambda () (interactive) (my/plantuml-render-server "svg"))))


;;; ============================================================
;;; TRAMP & REMOTE
;;; ============================================================

(after! tramp
  (setq projectile-git-command "git ls-files -zco --exclude-standard"))

;; Disable VC and Projectile over TRAMP — main cause of hangs
(setq vc-ignore-dir-regexp
      (format "%s\\|%s" vc-ignore-dir-regexp tramp-file-name-regexp))

(advice-add 'projectile-project-root :around
            (lambda (orig-fn &rest args)
              (unless (file-remote-p default-directory)
                (apply orig-fn args))))

(setq remote-file-name-inhibit-cache nil
      tramp-verbose 1)


;;; ============================================================
;;; DIRED & DIRVISH
;;; ============================================================

;; Always hide file details (permissions, size, date) for VoiceOver.
;; Toggle visibility with ( in dired/dirvish buffers.
;; Three layers of insurance: dirvish-hide-details, dired-mode-hook,
;; and dired-after-readin-hook (catches late buffer setup).
(add-hook 'dired-mode-hook #'dired-hide-details-mode)
;; Attach marked files to mail compose buffer via C-c RET C-a
(add-hook 'dired-mode-hook #'turn-on-gnus-dired-mode)
(add-hook 'dired-after-readin-hook
          (lambda () (unless dired-hide-details-mode
                       (dired-hide-details-mode 1))))

;; Emacs 31 may not autoload dired-read-dir-and-switches early enough
(require 'dired)
;; macOS: use GNU ls (coreutils) for dired/dirvish sorting support
(setq insert-directory-program "gls")

(after! dired
  (put 'dired-find-alternate-file 'disabled nil)
  (map! :map dired-mode-map
        "RET" #'dired-find-alternate-file
        "^"   #'dired-up-directory))


;; Dirvish — modern dired replacement
(use-package! dirvish
  :init (dirvish-override-dired-mode)
  :config
  (setq dirvish-hide-details t
        dirvish-mode-line-format '(:left (sort symlink) :right (omit yank index))
        dirvish-attributes '(vc-state subtree-state nerd-icons collapse git-msg)
        dirvish-side-width 35)
  (defun my/dirvish-toggle-details ()
    "Toggle file-time and file-size dirvish attributes."
    (interactive)
    (if (memq 'file-size dirvish-attributes)
        (setq-local dirvish-attributes
                    (seq-remove (lambda (a) (memq a '(file-time file-size)))
                                dirvish-attributes))
      (setq-local dirvish-attributes
                  (append dirvish-attributes '(file-time file-size))))
    (revert-buffer))
  (map! :map dirvish-mode-map
        :n "D" #'my/dirvish-toggle-details
        :n "q" #'dirvish-quit
        :n "h" #'dired-up-directory
        :n "l" #'dired-find-file
        :n "s" #'dirvish-quicksort
        :n "S" #'dirvish-setup-menu
        :n "TAB" #'dirvish-subtree-toggle
        :n "M-f" #'dirvish-history-go-forward
        :n "M-b" #'dirvish-history-go-backward))


;;; ============================================================
;;; PROJECTILE
;;; ============================================================

(after! projectile
  (setq projectile-enable-caching    nil
        projectile-indexing-method   'alien)
  (when (executable-find "fd")
    (setq projectile-generic-command
          "fd . -0 --type f --hidden --follow --exclude .git --color=never"))
  (dolist (dir '("node_modules" "__pycache__" ".terraform" "vendor"))
    (add-to-list 'projectile-globally-ignored-directories dir)))


;;; ============================================================
;;; PYTHON
;;; ============================================================

(setq python-shell-interpreter "python3")
(after! org
  (setq org-babel-python-command "python3")
  (require 'ob-python))

(after! python
  (map! :map python-mode-map
        :localleader
        :desc "Format (ruff)"  "f" (cmd! (compile (concat "ruff format " (buffer-file-name))
                                                   (revert-buffer t t t)))
        :desc "Run file"       "r" (cmd! (compile (concat "python3 " (buffer-file-name))))))


;;; ============================================================
;;; DEVELOPER WORKFLOW — Perl, Go, Ansible, Terraform, Docker
;;; ============================================================

;;; --- Perl (cperl-mode) ---

(add-to-list 'auto-mode-alist '("\\.pl\\'"  . cperl-mode))
(add-to-list 'auto-mode-alist '("\\.pm\\'"  . cperl-mode))
(add-to-list 'auto-mode-alist '("\\.t\\'"   . cperl-mode))

(after! cperl-mode
  (setq cperl-indent-level 4
        cperl-close-paren-offset -4
        cperl-continued-statement-offset 4
        cperl-indent-parens-as-block t
        cperl-tab-always-indent t))

(use-package! reformatter
  :config
  (reformatter-define perl-tidy
    :program "perltidy"
    :args '("-st" "-se" "--quiet")))

(after! cperl-mode
  (map! :map cperl-mode-map
        :localleader
        :desc "Format (perltidy)"  "f" #'perl-tidy-buffer
        :desc "Run file"           "r" (cmd! (compile (concat "perl " (buffer-file-name))))
        :desc "Debug (perldb)"     "d" #'perldb)
  (add-hook 'cperl-mode-hook
            (lambda ()
              (when (fboundp 'flycheck-select-checker)
                (flycheck-select-checker 'perl)))))

;;; --- Go ---

(after! go-mode
  (setq gofmt-command "goimports")
  (add-hook 'go-mode-hook
            (lambda ()
              (add-hook 'before-save-hook #'gofmt-before-save nil t)))
  (map! :map go-mode-map
        :localleader
        :desc "Format (goimports)" "f" #'gofmt
        :desc "Run file"           "r" (cmd! (compile (concat "go run " (buffer-file-name))))
        :desc "Test"               "t" (cmd! (compile "go test ./..."))
        :desc "Build"              "b" (cmd! (compile "go build ./..."))))

;;; --- Ansible / YAML ---

(after! yaml-mode
  (map! :map yaml-mode-map
        :localleader
        :desc "Run playbook" "r" (cmd! (compile (concat "ansible-playbook " (buffer-file-name))))))

(after! flycheck
  (flycheck-define-checker yaml-ansible-lint
    "Ansible linter."
    :command ("ansible-lint" "--parseable" source)
    :error-patterns
    ((warning line-start (file-name) ":" line ": " (message) line-end))
    :modes (yaml-mode))
  (add-to-list 'flycheck-checkers 'yaml-ansible-lint t))

;;; --- Terraform ---

(after! terraform-mode
  (map! :map terraform-mode-map
        :localleader
        :desc "Format (terraform fmt)" "f" (cmd! (compile "terraform fmt" t)
                                                  (revert-buffer t t t))
        :desc "Init"                   "i" (cmd! (compile "terraform init"))
        :desc "Plan"                   "p" (cmd! (compile "terraform plan"))))

;;; --- Dockerfile / Podman ---

(after! dockerfile-mode
  (map! :map dockerfile-mode-map
        :localleader
        :desc "Build (podman)" "b" (cmd! (let ((tag (read-string "Image tag: " "myapp:latest")))
                                           (compile (format "podman build -t %s -f %s ."
                                                            tag (buffer-file-name)))))
        :desc "Run (podman)"   "r" (cmd! (let ((img (read-string "Image name: " "myapp:latest")))
                                           (compile (format "podman run --rm %s" img))))))

(after! flycheck
  (when (executable-find "hadolint")
    (flycheck-define-checker dockerfile-hadolint
      "Dockerfile linter using hadolint."
      :command ("hadolint" "--no-color" "-")
      :standard-input t
      :error-patterns
      ((info line-start (one-or-more not-newline) ":" line " " (id (one-or-more not-newline)) " " (message) line-end)
       (warning line-start (one-or-more not-newline) ":" line " " (id (one-or-more not-newline)) " " (message) line-end)
       (error line-start (one-or-more not-newline) ":" line " " (id (one-or-more not-newline)) " " (message) line-end))
      :modes (dockerfile-mode))
    (add-to-list 'flycheck-checkers 'dockerfile-hadolint t)))


;;; ============================================================
;;; ACCESSIBILITY — EMACSPEAK
;;; ============================================================
;;; Default: OFF. Toggle with SPC t s (on) / SPC t S (off).

(defconst my/emacspeak-dir     (expand-file-name "~/.emacspeak"))
(defconst my/emacspeak-wrapper (expand-file-name "~/.local/bin/emacspeak-mac"))

(setq dtk-program my/emacspeak-wrapper)

(defvar my/emacspeak-loaded  nil)
(defvar my/emacspeak-enabled nil)
(defvar my/emacspeak-inhibit-server t
  "When non-nil, Emacspeak server will not start/restart.")

(defun my/emacspeak--ensure-loaded ()
  "Load Emacspeak once, safely, without breaking Doom startup."
  (unless my/emacspeak-loaded
    (setq my/emacspeak-loaded t)
    (setq emacspeak-directory my/emacspeak-dir)
    (load-file (expand-file-name "lisp/emacspeak-setup.el" emacspeak-directory))
    (with-eval-after-load 'dtk-speak
      (dolist (fn '(dtk-initialize dtk-start-process dtk-speak))
        (when (fboundp fn)
          (advice-add
           fn :around
           (lambda (orig &rest args)
             (if my/emacspeak-inhibit-server
                 nil
               (apply orig args)))))))))

(defun my/emacspeak-on ()
  "Enable speech and allow TTS server to start."
  (interactive)
  (setq my/emacspeak-inhibit-server nil)
  (my/emacspeak--ensure-loaded)
  (setq my/emacspeak-enabled t)
  (when (fboundp 'dtk-restart) (ignore-errors (dtk-restart)))
  (when (fboundp 'dtk-speak)   (ignore-errors (dtk-speak "Emacspeak on.")))
  (message "Emacspeak ON"))

(defun my/emacspeak-off ()
  "Disable speech and prevent auto-restart."
  (interactive)
  (setq my/emacspeak-enabled nil
        my/emacspeak-inhibit-server t)
  (when (fboundp 'dtk-stop) (ignore-errors (dtk-stop)))
  (when (boundp 'dtk-speaker-process)
    (let ((p dtk-speaker-process))
      (when (processp p)
        (ignore-errors (set-process-sentinel p nil))
        (ignore-errors (delete-process p))))
    (setq dtk-speaker-process nil))
  (message "Emacspeak OFF (server restart inhibited)"))


(with-eval-after-load 'dtk-speak
  (setq dtk-speech-rate-base 300)
  (setq-default dtk-punctuation-mode 'none))

(with-eval-after-load 'emacspeak
  (setq-default emacspeak-character-echo nil
                emacspeak-word-echo      t
                emacspeak-line-echo      t))

(setq dtk-default-speech-rate 400)
(with-eval-after-load 'dtk-speak
  (defun my/dtk-apply-global-default-rate (&rest _)
    "Apply global default speech rate after TTS init/restart."
    (when (fboundp 'dtk-set-rate)
      (ignore-errors (dtk-set-rate dtk-default-speech-rate t))))
  (advice-add 'dtk-initialize :after #'my/dtk-apply-global-default-rate))


;;; ============================================================
;;; ACCESSIBILITY — GLOBAL ZOOM (SPC z)
;;; ============================================================
;; Scales the global `default' face — all buffers, help, menus included.
;; Modeline + header-line pinned to base size. Step: x1.5 per step.
;; SPC z +/= zoom in, SPC z - zoom out, SPC z 0 reset, SPC z z restore.

(defvar my/zoom-base-height 140)
(defvar my/zoom-steps 0)
(defvar my/zoom-saved-steps nil)

(defvar my/zoom-pinned-faces
  '(mode-line mode-line-inactive mode-line-active
    header-line tab-bar tab-bar-tab tab-bar-tab-inactive))

(defun my/zoom-pin-ui ()
  "Set all pinned UI faces to base height."
  (dolist (face my/zoom-pinned-faces)
    (when (facep face)
      (set-face-attribute face nil :height my/zoom-base-height))))

(after! which-key
  (setq which-key-side-window-max-height 0.90
        which-key-max-display-columns    nil))

(defun my/zoom--apply (steps)
  "Scale global default face to base x 1.5^STEPS and re-pin UI faces."
  (let ((new-h (max 80 (round (* my/zoom-base-height (expt 1.5 steps))))))
    (set-face-attribute 'default nil :height new-h)
    (my/zoom-pin-ui)
    (when (fboundp 'corfu--popup-hide)
      (ignore-errors (corfu--popup-hide)))
    (message "Zoom %+d  x%.2f  ~%dpt" steps (expt 1.5 steps) (/ new-h 10))
    (when (and (not (minibufferp)) (window-live-p (selected-window)))
      (scroll-right (window-hscroll))
      (recenter nil))))

(add-hook 'doom-after-init-hook
  (lambda ()
    (let ((h (face-attribute 'default :height nil t)))
      (when (and (integerp h) (> h 0))
        (setq my/zoom-base-height h)))))

(add-hook 'doom-load-theme-hook #'my/zoom-pin-ui)

(defun my/zoom-in ()  (interactive) (cl-incf my/zoom-steps) (my/zoom--apply my/zoom-steps))
(defun my/zoom-out () (interactive) (cl-decf my/zoom-steps) (my/zoom--apply my/zoom-steps))

(defun my/zoom-reset ()
  "Reset to default font size. Saves current level for restore."
  (interactive)
  (if (= my/zoom-steps 0)
      (message "Zoom: already at default")
    (setq my/zoom-saved-steps my/zoom-steps)
    (my/zoom--apply 0)
    (setq my/zoom-steps 0)
    (message "Zoom reset (SPC z z to restore %+d)" my/zoom-saved-steps)))

(defun my/zoom-restore ()
  "Restore zoom level saved before last reset."
  (interactive)
  (if (null my/zoom-saved-steps)
      (message "Zoom: nothing to restore")
    (my/zoom--apply my/zoom-saved-steps)
    (setq my/zoom-steps my/zoom-saved-steps
          my/zoom-saved-steps nil)))

(setq hscroll-margin 3
      hscroll-step   1
      scroll-conservatively 101
      scroll-margin 2)


;;; ============================================================
;;; MATRIX — EMENT.EL
;;; ============================================================
;; Matrix client. SPC o M (open -> matrix).
;; SPC o m is taken by Doom's mu4e module, hence uppercase M.

(setq ement-save-sessions t
      ement-sessions-file (expand-file-name "ement-sessions.el" doom-private-dir))

(after! ement
  (setq ement-auto-sync t
        ement-room-timestamp-format "%H:%M"
        ement-room-show-avatars nil
        ement-room-username-display-property '(raise 0)
        ement-notify-mentions-p t
        ement-notify-dingalings-p nil))

(defun my/ement-open-after-sync (&rest _)
  "Open room list after ement finishes initial sync. Self-removing."
  (remove-hook 'ement-after-initial-sync-hook #'my/ement-open-after-sync)
  (when (fboundp 'ement-list-rooms)
    (ement-list-rooms)))

(defun my/ement-maybe-restore ()
  "Restore saved ement session silently on startup."
  (require 'ement)
  (condition-case err
      (let ((sf (expand-file-name ement-sessions-file)))
        (when (file-readable-p sf)
          (unless ement-sessions
            (when (fboundp 'ement--load-sessions)
              (setq ement-sessions (ement--load-sessions))))
          (if (fboundp 'ement--reconnect)
              (dolist (entry ement-sessions)
                (ement--reconnect (cdr entry)))
            (when ement-sessions
              (ement-connect :user-id (caar ement-sessions)
                             :homeserver "https://matrix.apps.sukany.cz")))))
    (error (message "ement startup restore: %s" (error-message-string err)))))

(defun my/ement-open ()
  "Open Matrix panel: show room list (connect/restore if needed)."
  (interactive)
  (require 'ement)
  (cond
   ((and (boundp 'ement-sessions) ement-sessions)
    (ement-list-rooms))
   ((file-readable-p (expand-file-name ement-sessions-file))
    (add-hook 'ement-after-initial-sync-hook #'my/ement-open-after-sync)
    (my/ement-maybe-restore))
   (t
    (add-hook 'ement-after-initial-sync-hook #'my/ement-open-after-sync)
    (call-interactively #'ement-connect))))

(add-hook 'doom-after-init-hook #'my/ement-maybe-restore)

(map! :leader
      (:prefix ("o M" . "Matrix")
       :desc "Open panel"           "o" #'my/ement-open
       :desc "Connect"              "c" #'ement-connect
       :desc "Disconnect"           "C" #'ement-disconnect
       :desc "List rooms"           "l" #'ement-list-rooms
       :desc "Open room"            "r" #'ement-view-room
       :desc "Direct message"       "d" #'ement-send-direct-message
       :desc "Join room"            "j" #'ement-join-room
       :desc "Notifications"        "n" #'ement-notifications
       :desc "Mentions"             "m" #'ement-mentions))


;;; ============================================================
;;; PDF TOOLS
;;; ============================================================

(after! pdf-tools
  (pdf-tools-install :no-query))

(after! pdf-view
  (setq-default pdf-view-display-size 'fit-page)
  (setq pdf-view-use-scaling t
        pdf-view-use-imagemagick nil
        pdf-view-midnight-colors '("#d4d4d4" . "#1c1c1c")
        pdf-view-continuous t)
  (add-hook 'pdf-view-mode-hook (lambda () (display-line-numbers-mode -1)))
  (add-hook 'pdf-view-mode-hook #'auto-revert-mode))

(after! pdf-view
  (evil-define-key 'normal pdf-view-mode-map
    "j"  #'pdf-view-next-line-or-next-page
    "k"  #'pdf-view-previous-line-or-previous-page
    "J"  #'pdf-view-next-page
    "K"  #'pdf-view-previous-page
    "gg" #'pdf-view-first-page
    "G"  #'pdf-view-last-page
    "+"  #'pdf-view-enlarge
    "-"  #'pdf-view-shrink
    "="  #'pdf-view-fit-page-to-window
    "W"  #'pdf-view-fit-width-to-window
    "/"  #'isearch-forward
    "?"  #'isearch-backward
    "m"  #'pdf-view-midnight-minor-mode
    "a"  #'pdf-annot-add-highlight-markup-annotation
    "q"  #'quit-window))

;; Re-activate Evil normal state when switching to a pdf-view window
(defun my/pdf-view-ensure-normal-state (&rest _)
  "Activate evil-normal-state when landing on a pdf-view-mode buffer."
  (when (and (derived-mode-p 'pdf-view-mode)
             (called-interactively-p 'any))
    (evil-normal-state)))

(advice-add 'evil-window-next :after #'my/pdf-view-ensure-normal-state)
(advice-add 'evil-window-prev :after #'my/pdf-view-ensure-normal-state)
(advice-add 'other-window     :after #'my/pdf-view-ensure-normal-state)

;; Open PDFs from org export in Emacs instead of Preview.app
(when (eq system-type 'darwin)
  (setq org-file-apps
        '((auto-mode  . emacs)
          (directory  . emacs)
          ("\\.mm\\'" . default)
          ("\\.x?html?\\'" . default)
          ("\\.pdf\\'" . emacs))))


;;; ============================================================
;;; YASNIPPET — snippets from ~/org/snippets
;;; ============================================================

(after! yasnippet
  (push (expand-file-name "snippets/" org-directory) yas-snippet-dirs)
  (yas-reload-all)

  ;; Select default text when entering a snippet field in Evil
  (add-hook 'yas-before-expand-snippet-hook
            (lambda () (when (evil-normal-state-p) (evil-insert-state))))

  (add-hook 'yas-keymap-disable-hook
            (lambda () (not (yas--snippets-at-point))))

  (define-key yas-keymap (kbd "C-d")
    (lambda ()
      (interactive)
      (delete-region (yas-field-start (yas-current-field))
                     (yas-field-end (yas-current-field))))))


;;; ============================================================
;;; NAVIGATION — link-hint, avy
;;; ============================================================

(use-package! link-hint
  :defer t
  :commands (link-hint-open-link link-hint-copy-link))

(map! :leader
      (:prefix ("j" . "jump")
       :desc "Open link (link-hint)"  "k" #'link-hint-open-link
       :desc "Copy link URL"          "K" #'link-hint-copy-link
       :desc "Avy goto char-2"        "j" #'avy-goto-char-2
       :desc "Avy goto line"          "l" #'avy-goto-line))


;;; ============================================================
;;; WRITING — olivetti-mode
;;; ============================================================

(after! olivetti
  (setq olivetti-body-width 90))

(add-hook 'org-mode-hook      (lambda () (when buffer-file-name (olivetti-mode 1))))
(add-hook 'markdown-mode-hook (lambda () (when buffer-file-name (olivetti-mode 1))))
(add-hook 'text-mode-hook     (lambda () (when buffer-file-name (olivetti-mode 1))))

(map! :leader
      (:prefix ("t" . "toggle")
       :desc "Olivetti mode" "o" #'olivetti-mode))


;;; ============================================================
;;; ORG-MODERN
;;; ============================================================

(use-package! org-modern
  :hook (org-mode . org-modern-mode)
  :hook (org-agenda-finalize . org-modern-agenda)
  :config
  (setq org-modern-star ["◉"]
        org-modern-hide-stars "·"
        org-modern-table nil))


;;; ============================================================
;;; ORG-FRAGTOG — auto-render LaTeX fragments
;;; ============================================================

(use-package! org-fragtog
  :after org
  :hook (org-mode . my/org-fragtog-maybe))

(defun my/org-fragtog-maybe ()
  "Enable org-fragtog-mode only in file-backed buffers."
  (when buffer-file-name
    (org-fragtog-mode 1)))


;;; ============================================================
;;; ORG-SUPER-AGENDA
;;; ============================================================

(use-package! org-super-agenda
  :after org-agenda
  :config
  ;; Agenda: start from today, no past days, no duplicates
  (setq org-agenda-start-on-weekday nil
        org-agenda-start-day "0d"
        org-agenda-span 7
        org-agenda-skip-scheduled-if-done t
        org-agenda-skip-deadline-if-done t
        org-agenda-skip-scheduled-if-deadline-is-shown t
        org-agenda-skip-deadline-prewarning-if-scheduled 'pre-scheduled)

  ;; Sorting: priority first, then deadline, then scheduled
  (setq org-agenda-sorting-strategy
        '((agenda priority-down deadline-up scheduled-up)
          (todo priority-down deadline-up)
          (tags priority-down deadline-up)))

  (setq org-super-agenda-groups
        '((:name "Overdue"
           :deadline past)
          (:name "Due today"
           :deadline today)
          (:name "Scheduled today"
           :scheduled today)
          (:name "Due soon"
           :deadline future)
          (:name "Waiting"
           :todo "WAIT")
          (:name "Kyndryl"
           :tag ("kyndryl" "work"))
          (:name "ZTJ"
           :tag "ztj")
          (:name "Other"
           :anything t))))

(after! org-super-agenda
  (org-super-agenda-mode 1)
  ;; Fix: org-super-agenda applies its own keymap to group headers
  ;; via text properties, overriding org-agenda keybindings.
  ;; Setting it to nil disables the header keymap entirely so all
  ;; standard agenda keys (m, B, t, etc.) work on every line.
  (setq org-super-agenda-header-map nil))


;;; ============================================================
;;; ORG-NOTER — PDF annotations
;;; ============================================================

(use-package! org-noter
  :after (:any org pdf-view)
  :config
  (setq org-noter-notes-window-location 'horizontal-split
        org-noter-notes-search-path (list (expand-file-name "annotations/" org-directory))
        org-noter-default-notes-file-names '("notes.org")
        org-noter-auto-save-last-location t
        org-noter-insert-note-no-questions nil
        org-noter-use-indirect-buffer nil
        org-noter-always-create-frame nil))

;; Smart org-noter launcher: repairs broken notes files and starts from PDF window
(defun my/org-noter-start ()
  "Start org-noter for the PDF visible in the current frame."
  (interactive)
  (require 'org-noter)
  (let* ((pdf-win (if (derived-mode-p 'pdf-view-mode)
                      (selected-window)
                    (cl-find-if (lambda (w)
                                  (with-current-buffer (window-buffer w)
                                    (derived-mode-p 'pdf-view-mode)))
                                (window-list (selected-frame)))))
         (pdf-path (when pdf-win
                     (with-current-buffer (window-buffer pdf-win)
                       (buffer-file-name)))))
    (unless pdf-path
      (user-error "No PDF buffer visible — open a PDF first"))
    (let* ((base       (file-name-base pdf-path))
           (notes-name (concat base ".org"))
           (notes-dir  (car org-noter-notes-search-path))
           (target     (expand-file-name notes-name notes-dir)))
      (make-directory notes-dir t)
      ;; Repair or create notes file with correct NOTER_DOCUMENT path
      (with-current-buffer (find-file-noselect target)
        (let ((modified nil))
          (save-excursion
            (goto-char (point-min))
            (if (re-search-forward
                 (concat "^[ \t]*:" org-noter-property-doc-file ":[ \t]*\\(.*\\)$")
                 nil t)
                (let* ((stored   (string-trim (match-string 1)))
                       (expanded (if (file-name-absolute-p stored)
                                     stored
                                   (expand-file-name stored (file-name-directory target)))))
                  (unless (and (file-exists-p expanded)
                               (file-equal-p expanded pdf-path))
                    (replace-match
                     (concat ":" org-noter-property-doc-file ": " pdf-path) t t)
                    (setq modified t)))
              (goto-char (point-min))
              (insert (format "* Notes: %s  :noexport:\n:PROPERTIES:\n:%s: %s\n:END:\n\n"
                              base org-noter-property-doc-file pdf-path))
              (setq modified t)))
          (when modified (save-buffer))))
      ;; Start from the PDF window for proper frame split
      (cl-letf* ((orig-cr (symbol-function 'completing-read))
                 ((symbol-function 'completing-read)
                  (lambda (prompt collection &rest args)
                    (cond
                     ((string-match-p "name" prompt)                  notes-name)
                     ((string-match-p "save\\|where\\|Which" prompt)  target)
                     (t (apply orig-cr prompt collection args))))))
        (with-selected-window pdf-win
          (org-noter))))))

(map! :leader
      (:prefix ("o" . "open")
       :desc "org-noter"             "n" #'my/org-noter-start
       :desc "org-noter insert note" "N" #'org-noter-insert-note))


;;; ============================================================
;;; ORG-CALDAV — CalDAV sync (4 calendars)
;;; ============================================================
;; Baikal server: cal.apps.sukany.cz
;; Credentials: ~/.authinfo (machine cal.apps.sukany.cz login martin password ...)
;;
;; Files:
;;   ~/org/calendar_outbox.org   — events to upload (in agenda-files)
;;   ~/org/caldav/suky.org       — downloaded from Suky calendar (NOT in agenda)
;;   ~/org/caldav/placeholders.org — Placeholders calendar (NOT in agenda)
;;   ~/org/caldav/family.org     — Family calendar (NOT in agenda)
;;   ~/org/caldav/klara.org      — Klara's personal calendar (NOT in agenda)
;;
;; caldav/ files are kept out of agenda to avoid polluting it with
;; historical events. View them via calfw (SPC o C).

(use-package! org-caldav
  :commands my/org-caldav-sync
  :config
  (setq org-caldav-days-in-past 1825  ; 5 years back
        org-caldav-delete-org-entries 'never
        org-caldav-delete-calendar-entries 'never
        org-caldav-save-directory "~/org/")

  ;; Error handler: catch errors during cal->org event update
  ;; so sync state is saved even if individual events fail
  (advice-add 'org-caldav-update-events-in-org :around
              (lambda (orig-fn &rest args)
                "Catch errors during cal->org sync; log and return so sync state is saved."
                (condition-case err
                    (apply orig-fn args)
                  (error
                   (message "org-caldav: update-events-in-org error (sync continues): %S" err)
                   (org-caldav-debug-print 1 (format "update-events-in-org error: %S" err))))))

  (defun my/org-caldav-sync ()
    "Sync 4 CalDAV calendars: Suky (twoway), Placeholders, Family, Klara (read-only)."
    (interactive)

    ;; Ensure caldav/ directory and inbox files exist
    (make-directory "~/org/caldav" t)
    (dolist (f '("~/org/caldav/suky.org"
                 "~/org/caldav/placeholders.org"
                 "~/org/caldav/family.org"
                 "~/org/caldav/klara.org"))
      (unless (file-exists-p (expand-file-name f))
        (with-temp-file (expand-file-name f)
          (insert "#+TITLE: CalDAV sync\n#+STARTUP: overview\n"))))

    ;; 1. Suky (twoway): download -> suky.org, upload from calendar_outbox.org
    (setq org-caldav-url           my/caldav-url
          org-caldav-calendar-id   my/caldav-id-suky
          org-caldav-inbox         "~/org/caldav/suky.org"
          org-caldav-files         '("~/org/calendar_outbox.org")
          org-caldav-sync-direction 'twoway)
    (org-caldav-sync)

    ;; 2. Placeholders (read-only)
    (setq org-caldav-url           my/caldav-url
          org-caldav-calendar-id   my/caldav-id-placeholders
          org-caldav-inbox         "~/org/caldav/placeholders.org"
          org-caldav-files         nil
          org-caldav-sync-direction 'cal->org)
    (org-caldav-sync)

    ;; 3. Family (read-only, shared via Baikal ACL)
    (setq org-caldav-url           my/caldav-url
          org-caldav-calendar-id   my/caldav-id-family
          org-caldav-inbox         "~/org/caldav/family.org"
          org-caldav-files         nil
          org-caldav-sync-direction 'cal->org)
    (org-caldav-sync)

    ;; 4. Klara (read-only, shared via Baikal ACL)
    (setq org-caldav-url           my/caldav-url
          org-caldav-calendar-id   my/caldav-id-klara
          org-caldav-inbox         "~/org/caldav/klara.org"
          org-caldav-files         nil
          org-caldav-sync-direction 'cal->org)
    (org-caldav-sync)

    (message "CalDAV sync done: Suky + Placeholders + Family + Klara")))



;;; ============================================================
;;; CALFW — visual calendar
;;; ============================================================

(use-package! calfw :demand t)
(use-package! calfw-org
  :demand t
  :config
  ;; org-capture integration: "a" to add event on selected date
  ;; Store date from calfw before capture starts
  (defvar my/calfw-capture-date nil "Date selected in calfw for capture.")

  (defun my/calfw-capture ()
    "Start org-capture with calfw date context."
    (interactive)
    (setq my/calfw-capture-date (calfw-cursor-to-nearest-date))
    (org-capture nil "c"))

  ;; Prompts for start/end time; empty = all-day event
  ;; Uses active timestamp with time range for timed events,
  ;; or date-range (-->--) for multi-day events.
  (defun my/calfw-capture-timestamp ()
    "Build org timestamp for selected calfw date with optional time prompt.
Formats matching what org-caldav/ox-icalendar export correctly:
  - All-day single: <2026-02-26 Thu>
  - Timed single day: <2026-02-26 Thu 14:00-15:30>
  - All-day multi: <2026-02-26 Thu>--<2026-02-28 Sat>
  - Timed multi-day: <2026-02-26 Thu 15:00>--<2026-02-28 Sat 22:00>"
    (let* ((date (or my/calfw-capture-date (calendar-current-date)))
           (m (nth 0 date))
           (d (nth 1 date))
           (y (nth 2 date))
           (dow (format-time-string "%a" (encode-time 0 0 12 d m y)))
           (start-time (read-string "Start time (HH:MM, empty=all-day): "))
           (end-input (unless (string-empty-p start-time)
                        (read-string "End time or end date (HH:MM / YYYY-MM-DD / YYYY-MM-DD HH:MM, empty=same day open): "))))
      (cond
       ;; All-day event (no time given)
       ((string-empty-p start-time)
        (let ((end-date (read-string "End date for multi-day (YYYY-MM-DD, empty=single day): ")))
          (if (string-empty-p end-date)
              (format "<%04d-%02d-%02d %s>" y m d dow)
            (let* ((parsed (parse-time-string (concat end-date " 00:00")))
                   (ed (nth 3 parsed)) (em (nth 4 parsed)) (ey (nth 5 parsed))
                   (edow (format-time-string "%a" (encode-time 0 0 12 ed em ey))))
              (format "<%04d-%02d-%02d %s>--<%04d-%02d-%02d %s>" y m d dow ey em ed edow)))))
       ;; Timed event - check if end contains a date (multi-day)
       ((and end-input (not (string-empty-p end-input))
             (string-match "^[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" end-input))
        ;; Multi-day with times
        (let* ((end-time-part (if (string-match " \\([0-9]\\{2\\}:[0-9]\\{2\\}\\)" end-input)
                                  (match-string 1 end-input) ""))
               (end-date-part (substring end-input 0 10))
               (parsed (parse-time-string (concat end-date-part " 00:00")))
               (ed (nth 3 parsed)) (em (nth 4 parsed)) (ey (nth 5 parsed))
               (edow (format-time-string "%a" (encode-time 0 0 12 ed em ey))))
          (if (string-empty-p end-time-part)
              (format "<%04d-%02d-%02d %s %s>--<%04d-%02d-%02d %s>"
                      y m d dow start-time ey em ed edow)
            (format "<%04d-%02d-%02d %s %s>--<%04d-%02d-%02d %s %s>"
                    y m d dow start-time ey em ed edow end-time-part))))
       ;; Same-day timed event with end time (HH:MM)
       ((and end-input (not (string-empty-p end-input)))
        (format "<%04d-%02d-%02d %s %s-%s>" y m d dow start-time end-input))
       ;; Same-day timed event, no end time
       (t
        (format "<%04d-%02d-%02d %s %s>" y m d dow start-time)))))

  (setq calfw-org-capture-template
        '("c" "Calendar event" entry
          (file "~/org/calendar_outbox.org")
          "* %?\n%(my/calfw-capture-timestamp)\n"))

  ;; Evil normal state for calfw (SPC = Doom leader works)
  (evil-set-initial-state 'calfw-calendar-mode 'normal)
  (evil-set-initial-state 'calfw-details-mode 'normal)
  (evil-define-key 'normal calfw-calendar-mode-map
    "h"   #'calfw-navi-previous-day-command
    "l"   #'calfw-navi-next-day-command
    "j"   #'calfw-navi-next-week-command
    "k"   #'calfw-navi-previous-week-command
    "n"   #'calfw-navi-next-week-command
    "p"   #'calfw-navi-previous-week-command
    "t"   #'calfw-navi-goto-today-command
    "g"   #'calfw-refresh-calendar-buffer
    "W"   #'calfw-change-view-week
    "M"   #'calfw-change-view-month
    "D"   #'calfw-change-view-day
    "TAB" #'calfw-navi-next-item-command
    "a"   #'my/calfw-capture
    "q"   #'bury-buffer
    "x"   #'calfw-org-clean-exit
    "RET" #'calfw-org-open-agenda-day
    "<"   #'calfw-navi-prev-view
    ">"   #'calfw-navi-next-view)

  ;; Sort periods (same-day events from org-caldav active-range format)
  (defun my/calfw-event-time-int (evt)
    "Return start-time as HHMM integer, or nil."
    (when-let ((st (calfw-event-start-time evt)))
      (+ (* 100 (car st)) (cadr st))))

  (defun my/calfw-sort-periods (result)
    "Sort periods sublist in RESULT by start-time."
    (mapcar (lambda (item)
              (if (and (listp item) (eq 'periods (car item)))
                  (cons 'periods
                        (sort (copy-sequence (cdr item))
                              (lambda (a b)
                                (let ((ta (my/calfw-event-time-int a))
                                      (tb (my/calfw-event-time-int b)))
                                  (cond ((and ta tb) (< ta tb))
                                        (ta t)
                                        (t nil))))))
                item))
            result))

  (defun my/calfw-sorted-file-source (name file color)
    "Create calfw-org file source with sorted periods."
    (let ((base (calfw-org-create-file-source name file color)))
      (make-calfw-source
       :name  (calfw-source-name base)
       :color (calfw-source-color base)
       :data  (lambda (begin end)
                (my/calfw-sort-periods
                 (funcall (calfw-source-data base) begin end))))))

  (defun my/calfw-sorter (x y)
    (let* ((ex (get-text-property 0 'cfw:event x))
           (ey (get-text-property 0 'cfw:event y))
           (ta (or (and ex (my/calfw-event-time-int ex))
                   (get-text-property 0 'time-of-day x)))
           (tb (or (and ey (my/calfw-event-time-int ey))
                   (get-text-property 0 'time-of-day y))))
      (cond ((and ta tb) (< ta tb)) (ta t) (tb nil) (t (string-lessp x y)))))

  (defun my/open-calendar ()
    "Open calfw with org-agenda + CalDAV sources (Suky, Klara, Family)."
    (interactive)
    (condition-case err
        (let* ((cd (expand-file-name "~/org/caldav/"))
               (sources (delq nil
                 (list
                  (calfw-org-create-source nil "Agenda" "SeaGreen4")
                  (when (file-exists-p (concat cd "suky.org"))
                    (my/calfw-sorted-file-source "Suky"   (concat cd "suky.org")   "SteelBlue"))
                  (when (file-exists-p (concat cd "klara.org"))
                    (my/calfw-sorted-file-source "Klara"  (concat cd "klara.org")  "Gold"))
                  (when (file-exists-p (concat cd "family.org"))
                    (my/calfw-sorted-file-source "Family" (concat cd "family.org") "ForestGreen"))))))
          (calfw-open-calendar-buffer
           :contents-sources sources
           :view 'month
           :custom-map calfw-org-custom-map
           :sorter #'my/calfw-sorter))
      (error
       (message "calfw multi-source: %s — fallback" (error-message-string err))
       (calfw-org-open-calendar))))

  (map! :leader "o C" #'my/open-calendar))


;;; ============================================================
;;; EXTENSIONS — envrc, embark, wgrep, kubel
;;; ============================================================

(use-package! envrc
  :hook (after-init . envrc-global-mode))

(after! embark
  (setq embark-prompter #'embark-keymap-prompter)
  (map! "C-." #'embark-act
        (:map minibuffer-local-map "C-." #'embark-act)))

(after! wgrep
  (setq wgrep-auto-save-buffer t))

(use-package! kubel
  :commands kubel
  :config
  (map! :map kubel-mode-map
        :n "g" #'kubel-get-resource-details
        :n "E" #'kubel-exec-pod
        :n "l" #'kubel-get-pod-logs
        :n "d" #'kubel-describe-resource
        :n "D" #'kubel-delete-resource))


;;; ============================================================
;;; ORG-CLOCK — time tracking
;;; ============================================================

(after! org
  (setq org-clock-persist 'history
        org-clock-in-resume t
        org-clock-out-remove-zero-time-clocks t
        org-clock-report-include-clocking-task t
        org-duration-format 'h:mm)
  (org-clock-persistence-insinuate)
  (map! :map org-mode-map
        :localleader
        "C i" #'org-clock-in
        "C o" #'org-clock-out
        "C r" #'org-clock-report
        "C d" #'org-clock-display))


;;; ============================================================
;;; COMBOBULATE — tree-sitter structural editing
;;; ============================================================

(use-package! combobulate
  :hook ((python-mode . combobulate-mode)
         (python-ts-mode . combobulate-mode)
         (go-mode . combobulate-mode)
         (go-ts-mode . combobulate-mode)
         (js-mode . combobulate-mode)
         (typescript-mode . combobulate-mode))
  :config
  (map! :map combobulate-mode-map
        :n "C-M-n" #'combobulate-navigate-next
        :n "C-M-p" #'combobulate-navigate-previous
        :n "C-M-u" #'combobulate-navigate-up
        :n "C-M-d" #'combobulate-navigate-down))


;;; ============================================================
;;; MISC EXTENSIONS — iedit, vundo, breadcrumb
;;; ============================================================

(use-package! iedit :commands iedit-mode)

(use-package! vundo
  :commands vundo
  :config (setq vundo-glyph-alist vundo-unicode-symbols))

(use-package! breadcrumb
  :hook ((prog-mode . breadcrumb-local-mode)
         (cperl-mode . breadcrumb-local-mode)))


;;; ============================================================
;;; EVIL — ORG TABLE CELL TEXT OBJECTS (di| ci| vi|)
;;; ============================================================

(after! evil-org
  (evil-org-set-key-theme '(navigation insert textobjects additional calendar))

  (evil-define-text-object evil-org-inner-table-cell (count &optional beg end type)
    "Inner org table cell (content between pipes)."
    (when (org-at-table-p)
      (let ((b (save-excursion
                 (search-backward "|")
                 (forward-char 1)
                 (skip-chars-forward " ")
                 (point)))
            (e (save-excursion
                 (search-forward "|")
                 (backward-char 1)
                 (skip-chars-backward " ")
                 (point))))
        (list b e))))

  (define-key evil-inner-text-objects-map "|" #'evil-org-inner-table-cell)
  (define-key evil-outer-text-objects-map "|" #'evil-org-inner-table-cell))


;;; ============================================================
;;; GIT — git-link, Forge (Gitea)
;;; ============================================================

(use-package! git-link
  :defer t
  :config
  (setq git-link-default-branch "master")
  (add-to-list 'git-link-remote-alist
               '("git\\.apps\\.sukany\\.cz" git-link-gitea))
  (add-to-list 'git-link-commit-remote-alist
               '("git\\.apps\\.sukany\\.cz" git-link-commit-gitea)))

(map! :leader
      (:prefix ("g" . "git")
       :desc "Copy git link"        "y" #'git-link
       :desc "Copy git link commit" "Y" #'git-link-commit))

(after! forge
  (add-to-list 'forge-alist
               '("git.apps.sukany.cz" "git.apps.sukany.cz/api/v1" "git.apps.sukany.cz" forge-gitea-repository)))


;;; ============================================================
;;; BIBLIOGRAPHY — citar + biblio + org-cite
;;; ============================================================
;; Global .bib: ~/org/references.bib (always available)
;; Per-project: add #+BIBLIOGRAPHY: ./refs.bib in org file header
;;
;; Workflow:
;;   SPC B n → create new .bib (prompted for path)
;;   SPC B s → search CrossRef → in results press 'i' to save to .bib
;;   SPC B i → insert citation in org file
;;   Export: org-cite + CSL auto-formats references
;;
;; Per-project setup: put .dir-locals.el in project root:
;;   ((org-mode . ((citar-bibliography . ("./refs.bib")))))

;; Global bibliography paths (setq! required for Doom's custom setter)
(after! citar
  (setq! citar-bibliography '("~/org/references.bib"))
  (setq! citar-notes-paths '("~/org/notes/"))
  (setq! citar-library-paths '("~/org/papers/"))

  ;; Auto-create global .bib if missing
  (let ((bib (expand-file-name "~/org/references.bib")))
    (unless (file-exists-p bib)
      (make-directory (file-name-directory bib) t)
      (with-temp-file bib (insert "% Global bibliography\n\n")))))

;; org-cite: CSL processor (no BibLaTeX toolchain needed)
(after! oc
  (require 'citeproc)
  (setq org-cite-global-bibliography '("~/org/references.bib")
        org-cite-export-processors '((latex csl)
                                     (html csl)
                                     (t csl))))

;; biblio.el: polite CrossRef access (required by their API policy)
(setq biblio-crossref-user-email-address "martin@sukany.cz")

;; BibTeX editing defaults
(setq bibtex-dialect 'biblatex
      bibtex-autokey-year-length 4
      bibtex-autokey-name-year-separator ""
      bibtex-autokey-year-title-separator "-"
      bibtex-autokey-titleword-length 5
      bibtex-autokey-titlewords 3)

;; Helper: create new .bib file for a project
(defun my/bib-new ()
  "Create a new .bib file at prompted path and open it."
  (interactive)
  (let ((path (read-file-name "New .bib file: " default-directory nil nil "refs.bib")))
    (make-directory (file-name-directory path) t)
    (unless (file-exists-p path)
      (with-temp-file path
        (insert (format "%% Bibliography: %s\n%% Add entries via SPC B s (search) or SPC B m (manual)\n\n"
                        (file-name-nondirectory path)))))
    (find-file path)
    (message "Opened %s — search online (SPC B s) or add manual entry (SPC B m)" path)))

;; Helper: import from DOI → appends to currently open .bib or global
(defun my/bib-import-doi ()
  "Fetch BibTeX for a DOI and insert at point (open a .bib file first)."
  (interactive)
  (require 'biblio)
  (require 'biblio-doi)
  (let ((doi (read-string "DOI: ")))
    (biblio-doi-insert-bibtex doi)))

;; Helper: insert a manual BibTeX entry template
(defun my/bib-insert-manual ()
  "Insert a BibTeX entry template at point for manual editing."
  (interactive)
  (let ((type (completing-read "Entry type: "
                '("article" "book" "inproceedings" "misc" "online"
                  "techreport" "thesis" "manual") nil t)))
    (insert (pcase type
              ("article"
               "@article{KEY,\n  author    = {},\n  title     = {},\n  journal   = {},\n  year      = {},\n  volume    = {},\n  pages     = {},\n  doi       = {},\n}\n")
              ("book"
               "@book{KEY,\n  author    = {},\n  title     = {},\n  publisher = {},\n  year      = {},\n  isbn      = {},\n}\n")
              ("inproceedings"
               "@inproceedings{KEY,\n  author    = {},\n  title     = {},\n  booktitle = {},\n  year      = {},\n  pages     = {},\n  doi       = {},\n}\n")
              ("misc"
               "@misc{KEY,\n  author    = {},\n  title     = {},\n  year      = {},\n  url       = {},\n  note      = {},\n}\n")
              ("online"
               "@online{KEY,\n  author    = {},\n  title     = {},\n  url       = {},\n  urldate   = {},\n  year      = {},\n}\n")
              ("techreport"
               "@techreport{KEY,\n  author      = {},\n  title       = {},\n  institution = {},\n  year        = {},\n  number      = {},\n}\n")
              ("thesis"
               "@thesis{KEY,\n  author    = {},\n  title     = {},\n  school    = {},\n  year      = {},\n  type      = {phdthesis},\n}\n")
              ("manual"
               "@manual{KEY,\n  title        = {},\n  organization = {},\n  year         = {},\n  url          = {},\n}\n")))
    ;; Position cursor at KEY for immediate editing
    (search-backward "KEY" nil t)
    (message "Fill in KEY and fields. KEY = unique citation identifier (e.g. burns2016).")))

;; biblio-lookup is the universal search command (prompts for backend)
;; crossref-lookup, arxiv-lookup, dblp-lookup are backend-specific
(map! :leader
      (:prefix ("B" . "bibliography")
       :desc "Browse references"        "b" #'citar-open
       :desc "Insert citation [cite:]"  "i" #'citar-insert-citation
       :desc "Open notes for reference" "n" #'citar-open-notes
       :desc "Refresh bibliography"     "r" #'citar-refresh
       :desc "New .bib file"            "N" #'my/bib-new
       :desc "Search online"            "s" (cmd! (require 'biblio) (call-interactively #'biblio-lookup))
       :desc "Import from DOI"          "D" #'my/bib-import-doi
       :desc "Insert manual entry"      "m" #'my/bib-insert-manual
       :desc "Open global .bib"         "f" (cmd! (find-file (expand-file-name "~/org/references.bib")))))


;;; ============================================================
;;; ORG-ANKI — flashcards
;;; ============================================================

(use-package! org-anki
  :commands (org-anki-sync-entry org-anki-sync-all org-anki-delete-entry)
  :config
  (setq org-anki-default-deck "Emacs")
  (map! :map org-mode-map
        :localleader
        "A s" #'org-anki-sync-entry
        "A S" #'org-anki-sync-all
        "A d" #'org-anki-delete-entry))


;;; ============================================================
;;; ORG-QL — query language for org
;;; ============================================================

(use-package! org-ql
  :commands (org-ql-search org-ql-view org-ql-find)
  :config
  (map! :leader
        "s q" #'org-ql-search
        "s Q" #'org-ql-view))


;;; ============================================================
;;; ORG-ROAM-UI — visual graph
;;; ============================================================

(use-package! org-roam-ui
  :after org-roam
  :commands org-roam-ui-mode
  :config
  (setq org-roam-ui-sync-theme t
        org-roam-ui-follow t
        org-roam-ui-update-on-save t))


;;; ============================================================
;;; GRAMMAR CHECK — LanguageTool
;;; ============================================================

(after! langtool
  (setq langtool-language-tool-jar
        (expand-file-name "~/languagetool/languagetool-commandline.jar")
        langtool-default-language "cs"
        langtool-mother-tongue "cs"))


;;; ============================================================
;;; KEYBINDINGS — central reference
;;; ============================================================
;;; Standalone bindings (package-independent, safe to define top-level).
;;
;; The following bindings are defined near their packages (load-order sensitive):
;;   SPC o r   — elfeed           (elfeed section)
;;   SPC o n/N — org-noter        (org-noter section)
;;   SPC o C   — calendar/calfw   (calfw section)
;;   SPC j k/K — link-hint        (link-hint section)
;;   SPC t o   — olivetti         (olivetti section)
;;   SPC z +/= — zoom in/out      (keybindings section below)
;;   SPC o M   — Matrix/ement     (Matrix section)
;;   SPC B b/i — bibliography/citar (bibliography section)
;;   SPC s q/Q — org-ql           (org-ql section)
;;   SPC | di| ci| vi| — table cell objects (evil-org section)

(map! :leader
      (:prefix ("z" . "zoom")
       :desc "Zoom in  (x1.5)"     "+" #'my/zoom-in
       :desc "Zoom in  (x1.5)"     "=" #'my/zoom-in
       :desc "Zoom out (x1.5)"     "-" #'my/zoom-out
       :desc "Reset"                "0" #'my/zoom-reset
       :desc "Restore previous"     "z" #'my/zoom-restore))

(map! :leader :desc "Elfeed" "o r" #'elfeed)
(map! :leader
      (:prefix ("t" . "toggle")
       :desc "Speech ON"  "s" #'my/emacspeak-on
       :desc "Speech OFF" "S" #'my/emacspeak-off))
(map! :leader
      (:prefix ("h" . "help")
       :desc "Describe bindings (buffer-local)" "B" #'describe-bindings))
(map! :leader "o k" #'kubel)
(map! :leader "s e" #'iedit-mode)
(map! :leader "u" #'vundo)
(map! :leader "n r u" #'org-roam-ui-mode)
(map! :leader
      "t g" #'langtool-check
      "t G" #'langtool-check-done)
(map! :leader "o c" #'my/org-caldav-sync)

;; gls
(setq insert-directory-program "gls")


;; Always enable macOS accessibility (VoiceOver + Zoom cursor tracking).
;; Override auto-detection so the AX tree is available from startup
;; regardless of whether an AT is currently active.
(when (eq system-type 'darwin)
  (setq ns-accessibility-enabled t))
