;;; tiles.el --- Tagged Instant Lightweight Emacs Snippets -*- lexical-binding: t; -*-

;; Author: Claudiu Tănăselia <https://www.tanaselia.ro>
;; Package-Version: 20260603.1458
;; Package-Revision: 754b11764f82
;; Package-Requires: ((emacs "27.1"))
;; Keywords: files, text, convenience
;; URL: https://github.com/ctanas/tiles

;; SPDX-License-Identifier: GPL-3.0-or-later

;;; Commentary:
;; TILES (Tagged Instant Lightweight Emacs Snippets) is a note-taking
;; system where each note is a title-less, single paragraph stored
;; in its own file.  Notes are organized through tags and bold keywords
;; rather than hierarchies or titles.  A dashboard offers a bird's-eye
;; view of notes and allows note manipulation: create, edit, delete,
;; stitch view mode, and more.
;;
;; Note file format (TYYYYMMDDHHMMSS.org):
;;
;;   This is the paragraph.  *Bold words* are searchable keywords.
;;   /Italic/ and [[links]] are supported.  Inline footnotes[fn:: like
;;   this] are stripped from previews.  Org Mode format is recommended.
;;
;;   && This is a private paragraph.  It is hidden from previews,
;;   stitched views, and dynamic blocks.  Only visible when expanding
;;   a note with TAB in the dashboard or editing the file directly.
;;
;;   tag1/tag2/tag3
;;
;; The last non-empty line is always the tag line; everything above it
;; (separated by a blank line) is content.  Multi-paragraph notes work,
;; but they are discouraged and not considered to be in the spirit of
;; TILES.  Paragraphs starting with && are private (hidden from all
;; views except TAB expansion and direct editing).
;;
;; Features:
;; - Atomic notes: one paragraph per file, no titles required
;; - Timestamp naming: files named TYYYYMMDDHHMMSS.org
;; - Tag-based organization via slash-separated tag lines
;; - Keyword extraction: bold words (*word*) are searchable; hyphens
;;   are normalized to spaces for matching (e.g. *Falcon-9* and
;;   *Falcon 9* are the same keyword) but note content is unchanged
;; - Dashboard (tiles-show-notes): chronological listing with
;;   color-coded timestamps, inline org preview, editable split,
;;   tag/keyword filtering, tag exclusion, and date editing
;; - Moon phase in dashboard status line (days until next New/Full Moon)
;; - Tag search with AND/OR logic (/ = AND, space = OR)
;; - Keyword search with AND/OR/NOT (/ = AND, space = OR, ! = NOT)
;; - Two-panel search view with live preview
;; - Stitched view: search results as flowing text
;; - Quick capture via minibuffer (tiles-quick, tiles-yank)
;; - Touch command to bump a note's timestamp (tiles-touch)
;; - Private paragraphs: && prefix hides content from all views
;;   except TAB expansion and direct file editing
;; - Focus mode: distraction-free editing with centered ~80-char
;;   body, (tiles-focus-mode, enabled by default)
;; - Tag/keyword listing with occurrence counts, sorting, and keyword rename
;; - Tag-line fontification: tag line shown in red when editing notes
;; - Unicode box-drawing dashboard separators (tiles-fancy-separators)
;; - In-memory cache with mtime invalidation for fast repeated access
;; - Org dynamic blocks for embedding note lists/contents in org files
;;
;; Global keybindings (under C-c m):
;;   C-c m m   - Dashboard (tiles-show-notes)
;;   C-c m n   - New note (tiles-new)
;;   C-c m q   - Quick capture via minibuffer (tiles-quick)
;;   C-c m y   - Quick capture from clipboard (tiles-yank)
;;   C-c m t   - Tag search (tiles-tag-search); insert tag at point in capture buffers
;;   C-c m k   - Keyword search (tiles-keyword-search)
;;
;; Dashboard keybindings:
;;   n/p       - Navigate notes
;;   SPC       - Open editable preview split (updates on navigation)
;;   RET       - Open note file
;;   M-RET     - Open note file read-only
;;   TAB       - Toggle expanded view (private &&, keywords, stats)
;;   M-up/down - Reorder notes
;;   d         - Change note date/timestamp
;;   u         - Touch (update timestamp to now)
;;   D         - Delete note (with confirmation)
;;   t         - Filter by tag
;;   k         - Filter by keyword
;;   T         - List all tags
;;   K         - List all keywords
;;   F         - Exclude tags (hide notes with specified tags)
;;   c         - Clear search filter (keeps exclusion)
;;   C         - Clear tag exclusion (keeps search filter)
;;   f         - Toggle raw preview (strip org formatting)
;;   +         - Load next batch of notes
;;   0         - Stitch displayed notes (respects reordering)
;;   l         - New note (same as C-c m n)
;;   g         - Refresh
;;   q         - Quit
;;
;; Capture keybindings:
;;   C-c C-c   - Save note (prompts for tags if missing)
;;   C-c C-k   - Cancel and discard
;;
;; Search view keybindings:
;;   n/p       - Navigate results
;;   RET       - Open note
;;   SPC       - Toggle to stitched view
;;   r         - Refine search
;;   t/k       - Switch to tag/keyword search
;;   q         - Quit
;;
;; Stitched view keybindings:
;;   n/p       - Jump between note boundaries
;;   RET/e     - Open source file at point
;;   SPC       - Toggle back to two-panel view
;;   r         - Refine search
;;   q         - Quit
;;
;; Tag search syntax:
;;   /         - AND (all tags in group must match)
;;   space     - OR (any group matches)
;;   Example: "b218/lx2026 misc" = (b218 AND lx2026) OR misc
;;
;; Keyword search syntax:
;;   /         - AND (all terms in group must match)
;;   space     - OR (any group matches)
;;   !prefix   - NOT (term must NOT match)
;;   Example: "emacs Lisp" = Emacs OR Lisp
;;   Example: "emacs/Lisp" = Emacs AND Lisp
;;   Example: "emacs/!Lisp" = Emacs but not Lisp
;;
;; Other commands (M-x):
;;   tiles-touch       - Update a note's timestamp to now (renames file)
;;   tiles-focus-mode  - Toggle distraction-free editing (~80-char centered)
;;   tiles-list-tags   - Browse all unique tags with occurrence counts
;;   tiles-list-keywords - Browse all unique keywords with occurrence counts
;;   tiles-list-rename - Rename keyword under cursor across all notes (R in keyword list)
;;   tiles-clear-cache - Force reload of all note data from disk
;;   tiles-show-notes  - Open the dashboard (also C-c m m)
;;   tiles-new         - Create a new note (also C-c m n)
;;   tiles-capture     - Alias for tiles-new
;;   tiles-quick       - Quick capture via minibuffer (also C-c m q)
;;   tiles-yank        - Quick capture from clipboard (also C-c m y)
;;   tiles-tag-search  - Search by tags (also C-c m t)
;;   tiles-keyword-search - Search by keywords (also C-c m k)
;;
;; Org dynamic blocks:
;;   #+BEGIN: tiles-notes :tags "b218/lx2026" :sort "newest" :limit 10
;;   (generates a linked list of matching notes)
;;   #+END:
;;
;;   #+BEGIN: tiles-files :tags "journal" :separator "\n-----\n"
;;   (embeds note contents directly)
;;   #+END:
;;
;; Update blocks with C-c C-x C-u, insert with C-c C-x x.
;;
;; Changelog:
;;
;;   0.5.2 - Keyword search now supports AND (/) and NOT (!) alongside the
;;           existing space-as-OR.  Tag rename: R in the tag list renames a
;;           tag across all notes (parallel to keyword rename).  New
;;           dashboard key w copies the selected note (private && stripped)
;;           to the kill ring.  HTML anchor in tiles-files dblocks is now
;;           opt-in via tiles-dblock-html-anchor, with per-tag suppression
;;           via tiles-dblock-no-anchor-tags.  Polish: g refresh preserves
;;           cursor position; tiles--strip-org-markup also handles
;;           underline/strikethrough/code/verbatim; touch logic deduplicated.
;;   0.5.1 - Similar notes: press m in a read-only tile to list the top
;;           tiles-similar-count notes most similar to it (TF-IDF over
;;           shared content tokens, bold *keywords* boosted). q closes
;;           the read-only buffer.
;;   0.5   - Persistent on-disk cache (tiles-cache-file), reverse tag and
;;           keyword indices for O(matching) search and dashboard filter,
;;           file-notify watch (tiles-watch-files) to catch external edits.
;;   0.4   - First MELPA release;
;;   0.3.5 - Tag mode control: tiles-tag-mode can be `unrestricted' (default),
;;           `inhibit' (no tags, tag search/filter disabled), a list of allowed
;;           tag strings (completion-based, first element is default), or
;;           (required-one-of TAG...) where any tags are accepted but at least
;;           one from the list must be present.
;;           Fix: deleting the last note now refreshes the dashboard to an empty
;;           state instead of leaving the deleted note visible.
;;   0.3.4 - Keyword rename: R in keyword list renames a keyword across all notes.
;;   0.3.3 - Unicode box-drawing dashboard separators, tag-line fontification,
;;           focus mode from stitched view.
;;   0.3.2 - Focus mode for distraction-free editing (~80-char centered).
;;           Interactive tag/keyword lists with occurrence counts and
;;           sorting (alphabetical/occurrence, ascending/descending).
;;           Dashboard keybindings: T list tags, K list keywords,
;;           u touch, L new note with focus mode.  Stitch confirmation
;;           when no filter is active.
;;   0.3.1 - Red & indicator for notes with private paragraphs.
;;   0.3   - Private paragraphs: paragraphs starting with && are hidden
;;           from dashboard previews, stitched views, search panels, and
;;           dynamic blocks.  Only visible when expanding a note with TAB
;;           in the dashboard or editing the file directly.
;;   0.2   - Initial public release.

;;; Code:

(require 'org)
(require 'lunar)
(require 'face-remap)

(declare-function file-notify-add-watch "filenotify" (file flags callback))
(declare-function file-notify-rm-watch "filenotify" (descriptor))

(defconst tiles-version "0.5.2"
  "Current version of TILES.")

;;; Customization

(defgroup tiles nil
  "Tagged Instant Lightweight Emacs Snippets."
  :group 'text
  :prefix "tiles-")

(defcustom tiles-directory "~/notes/tiles/"
  "Directory where TILES notes are stored.
Notes are loaded recursively from subdirectories."
  :type 'directory
  :group 'tiles)

(defcustom tiles-preview-length 105
  "Maximum number of characters for the inline note preview in the dashboard."
  :type 'integer
  :group 'tiles)

(defcustom tiles-preview-raw t
  "When non-nil, strip all `org-mode' formatting from dashboard previews.
Shows plain text without bold, italic, links, or footnotes markup."
  :type 'boolean
  :group 'tiles)

(defcustom tiles-dashboard-limit 50
  "Maximum number of notes to display per page in the dashboard.
Press `+' to load the next batch.  Set to nil for unlimited."
  :type '(choice integer (const nil))
  :group 'tiles)

(defcustom tiles-line-padding 22
  "Extra padding added to the line width beyond preview and tags.
The total dashboard line width is `tiles-preview-length' + this value
+ the length of the longest tag string."
  :type 'integer
  :group 'tiles)

(defcustom tiles-focus-default t
  "When non-nil, enable focus mode by default when creating new notes.
Set to nil to disable: (setq tiles-focus-default nil)"
  :type 'boolean
  :group 'tiles)

(defcustom tiles-fancy-separators t
  "When non-nil, use Unicode box-drawing characters for dashboard separators.
Uses ═ and ─ instead of = and -.  Set to nil for plain ASCII."
  :type 'boolean
  :group 'tiles)

(defcustom tiles-year-subfolders nil
  "When non-nil, save new notes in a per-year subfolder inside `tiles-directory'.
For example, a note created in 2026 is saved to TILES-DIR/2026/.
Existing notes in any location are always found (loading is always recursive)."
  :type 'boolean
  :group 'tiles)

(defcustom tiles-cache-file
  (locate-user-emacs-file "tiles-cache.el")
  "File used to persist the parsed-note cache between Emacs sessions.
Set to nil to disable persistence; the cache will then be rebuilt on
demand each session.  Entries are revalidated against file mtime on
access, so a stale cache file is always safe."
  :type '(choice file (const :tag "Disabled" nil))
  :group 'tiles)

(defcustom tiles-watch-files t
  "When non-nil, use `file-notify' to watch `tiles-directory' for changes.
Lets TILES detect external edits (e.g. from another Emacs, mobile sync,
or git pull) and invalidate its caches automatically."
  :type 'boolean
  :group 'tiles)

(defcustom tiles-tag-mode 'unrestricted
  "Controls how tags are handled in TILES.

Possible values:
- `unrestricted' (default): tags work normally; any tag string is accepted.
- `inhibit': tags are disabled.  Capture does not prompt for tags, notes
  are saved with a placeholder tag internally, and tag-based search and
  filtering in the dashboard are disabled.
- A list of strings: only those tags are accepted.  Prompts use completion
  restricted to the list.  The first element is applied as the default when
  the user provides no input.
- (required-one-of TAG...): any tags are accepted, but at least one from
  the list must be present.  Prompts suggest the required tags via
  completion (free input is still allowed).  Example:
  (required-one-of \"work\" \"personal\" \"journal\")"
  :type '(choice (const :tag "Unrestricted (default)" unrestricted)
                 (const :tag "Inhibit tags" inhibit)
                 (repeat :tag "Allowed tags list (only these accepted)" string)
                 (sexp :tag "At least one required (free input otherwise): (required-one-of \"tag1\" ...)"))
  :group 'tiles)

(defcustom tiles-dblock-html-anchor nil
  "When non-nil, `tiles-files' dblocks emit an HTML anchor before each note.
The anchor is an `@@html:<a id=\"...\">@@' Org export snippet that lets
exported HTML link to individual notes by timestamp.  Notes whose tags
include any member of `tiles-dblock-no-anchor-tags' skip the anchor."
  :type 'boolean
  :group 'tiles)

(defcustom tiles-dblock-no-anchor-tags nil
  "List of tag strings that suppress the HTML anchor in `tiles-files' dblocks.
Only meaningful when `tiles-dblock-html-anchor' is non-nil.  A note that
has any of these tags will not get an HTML anchor."
  :type '(repeat string)
  :group 'tiles)

;;; Internal Variables

(defvar tiles--current-search-results nil
  "Current list of search results (file paths).")

(defvar tiles--current-search-query nil
  "Current search query string.")

(defvar tiles--current-search-type nil
  "Current search type: `tag' or `keyword'.")

(defvar tiles--cache (make-hash-table :test 'equal)
  "Cache of parsed note data, keyed by filepath.
Values are (MTIME . PARSED-DATA).")

(defvar tiles--tag-index (make-hash-table :test 'equal)
  "Reverse index mapping each tag string to a list of filepaths.")

(defvar tiles--keyword-index (make-hash-table :test 'equal)
  "Reverse index mapping each normalized keyword to a list of filepaths.")

(defvar tiles--file-index-record (make-hash-table :test 'equal)
  "Snapshot of the (TAGS . KEYWORDS) indexed for each filepath.
Used to remove a file cleanly from `tiles--tag-index' and
`tiles--keyword-index' when it is re-parsed or evicted.")

(defvar tiles--content-token-cache (make-hash-table :test 'equal)
  "Cache of (MTIME . TOKEN-LIST) per filepath for content-token similarity.")

(defvar tiles--cache-loaded nil
  "Non-nil after the persistent cache has been loaded this session.")

(defvar tiles--index-complete nil
  "Non-nil when the reverse index is known to cover every on-disk note.")

(defvar tiles--file-watches nil
  "Alist of (DIR . DESCRIPTOR) for active `file-notify' watches.")

(defvar tiles-debug nil
  "When non-nil, display performance diagnostics after each dashboard load.
Enable at runtime with (setq tiles-debug t).  Off by default.")

(defvar tiles--debug-cache-hits 0
  "Cache hit count during the last dashboard load.  Internal debug counter.")

(defvar tiles--debug-cache-misses 0
  "Cache miss count (disk reads) during the last dashboard load.
Internal debug counter.")

(defvar tiles--preview-buffer-name "*Tile Preview*"
  "Name of the preview buffer.")

(defvar tiles--search-buffer-name "*Tiles Search Results*"
  "Name of the search results buffer.")

(defvar tiles--stitched-buffer-name "*Tiles Stitched View*"
  "Name of the stitched view buffer.")

(defvar tiles--notes-buffer-name "*Tiles Notes*"
  "Name of the notes viewer buffer.")

(defvar tiles--notes-filter nil
  "Current dashboard filter as (TYPE . QUERY) or nil.
TYPE is `tag' or `keyword'.")

(defvar tiles--notes-exclude nil
  "List of tags to exclude from dashboard display and search.
Set by F in the dashboard, cleared by c.")

(defvar-local tiles--line-target-width 135
  "Computed target width for note lines in the dashboard.
Equal to `tiles-preview-length' + `tiles-line-padding' +
longest tag string length.")
(put 'tiles--line-target-width 'permanent-local t)

(defvar tiles--notes-page 0
  "Current page offset in the dashboard (0-indexed).")

(defvar tiles--preview-file nil
  "Filepath currently shown in the preview split.")

;;; Utility Functions

(defun tiles--next-lunar-event ()
  "Return a string describing the next New Moon or Full Moon.
Uses Emacs's built-in lunar phase computation."
  (let* ((now (current-time))
         (today (decode-time now))
         (month (nth 4 today))
         (year (nth 5 today))
         (best-name nil)
         (best-date nil)
         (best-days nil))
    ;; Check this month and next 2 months
    (catch 'found
      (dolist (m (list (list month year)
                       (list (1+ (mod month 12)) (if (= month 12) (1+ year) year))
                       (list (1+ (mod (1+ month) 12)) (if (>= month 11) (1+ year) year))))
        (let ((phases (lunar-phase-list (car m) (cadr m))))
          (dolist (phase phases)
            (let* ((date-list (car phase))
                   (raw-phase (nth 2 phase))
                   (name (if (stringp raw-phase) raw-phase
                           (nth raw-phase '("New Moon" "First Quarter Moon"
                                            "Full Moon" "Last Quarter Moon"))))
                   (phase-time (encode-time 0 0 12
                                            (nth 1 date-list)
                                            (nth 0 date-list)
                                            (nth 2 date-list)))
                   (today-days (time-to-days now))
                   (phase-days (time-to-days phase-time))
                   (diff-days (- phase-days today-days)))
              (when (and name (>= diff-days 0)
                         (or (string-match-p "New Moon" name)
                             (string-match-p "Full Moon" name))
                         (or (not best-days) (< diff-days best-days)))
                (setq best-name name
                      best-date (format-time-string "%a, %d %B %Y" phase-time)
                      best-days diff-days)))))))
    (when best-name
      (let ((short (if (string-match-p "New" best-name) "NEW" "FULL")))
        (cond
         ((= best-days 0)
          (format "%s MOON TODAY!" short))
         ((= best-days 1)
          (format "%s MOON TOMORROW!" short))
         (t
          (format "%d days until %s: %s" best-days best-name best-date)))))))

(defun tiles--ensure-directory ()
  "Ensure `tiles-directory' exists."
  (unless (file-exists-p tiles-directory)
    (make-directory tiles-directory t)))

(defun tiles--note-save-directory ()
  "Return the directory where new notes should be saved.
When `tiles-year-subfolders' is non-nil, returns a YYYY subfolder inside
`tiles-directory', creating it if needed.  Otherwise returns `tiles-directory'."
  (if tiles-year-subfolders
      (let ((dir (expand-file-name (format-time-string "%Y") tiles-directory)))
        (unless (file-exists-p dir)
          (make-directory dir t))
        dir)
    (expand-file-name tiles-directory)))

(defun tiles--generate-timestamp ()
  "Generate a timestamp string for file naming."
  (format-time-string "T%Y%m%d%H%M%S"))

(defun tiles--generate-filename ()
  "Generate a new filename with timestamp."
  (concat (tiles--generate-timestamp) ".org"))

(defun tiles--get-all-tile-files ()
  "Get all tile files from `tiles-directory' and subdirectories.
Returned list is sorted newest first."
  (when (file-exists-p tiles-directory)
    (let ((files (directory-files-recursively tiles-directory "^T[0-9]\\{14\\}\\.org$")))
      (sort files (lambda (a b)
                    (string> (file-name-nondirectory a)
                             (file-name-nondirectory b)))))))

(defun tiles--parse-note-file (filepath)
  "Parse a TILES note file at FILEPATH.
Returns a plist with :content, :tags, and :keywords.
Results are cached and only re-parsed when the file's mtime changes."
  (when (file-exists-p filepath)
    (let* ((mtime (file-attribute-modification-time (file-attributes filepath)))
           (cached (gethash filepath tiles--cache)))
      (if (and cached (equal (car cached) mtime))
          (progn
            (when tiles-debug
              (setq tiles--debug-cache-hits (1+ tiles--debug-cache-hits)))
            (cdr cached))
        (progn
          (when tiles-debug
            (setq tiles--debug-cache-misses (1+ tiles--debug-cache-misses)))
          (let ((result (tiles--parse-note-file-uncached filepath)))
            (puthash filepath (cons mtime result) tiles--cache)
            (tiles--index-record filepath result)
            result))))))

(defun tiles--parse-note-file-uncached (filepath)
  "Parse a TILES note file at FILEPATH without caching.
The last non-empty line is always treated as the tag line.
Everything before it (minus the trailing blank separator) is content."
  (with-temp-buffer
    (insert-file-contents filepath)
    (let* ((content (buffer-string))
           (lines (split-string content "\n"))
           (tag-line nil)
           (paragraph-lines nil))
      ;; Find the last non-empty line as the tag line
      (let ((i (1- (length lines))))
        (while (and (>= i 0) (string-empty-p (string-trim (nth i lines))))
          (setq i (1- i)))
        (when (>= i 0)
          (setq tag-line (string-trim (nth i lines)))
          ;; Everything before it is content (strip trailing blank lines)
          (setq i (1- i))
          (while (and (>= i 0) (string-empty-p (string-trim (nth i lines))))
            (setq i (1- i)))
          (setq paragraph-lines (seq-take lines (1+ i)))))
      (let* ((paragraph (mapconcat #'identity paragraph-lines "\n"))
             (tags (when tag-line
                     (split-string tag-line "/" t "[ \t]+")))
             (keywords (tiles--extract-keywords
                       (tiles--strip-private-paragraphs paragraph))))
        (list :content paragraph
              :tags tags
              :keywords keywords
              :filepath filepath)))))

(defun tiles-clear-cache ()
  "Clear the tiles note cache and reverse index.
Removes the persistent cache file as well."
  (interactive)
  (clrhash tiles--cache)
  (clrhash tiles--tag-index)
  (clrhash tiles--keyword-index)
  (clrhash tiles--file-index-record)
  (clrhash tiles--content-token-cache)
  (setq tiles--index-complete nil)
  (setq tiles--cache-loaded nil)
  (when (and tiles-cache-file (file-exists-p tiles-cache-file))
    (delete-file tiles-cache-file))
  (message "Tiles cache cleared"))

;;; Reverse index, persistent cache, and file-notify

(defun tiles--index-record (filepath note-data)
  "Add or refresh FILEPATH's entries in the reverse indices using NOTE-DATA."
  (tiles--index-forget filepath)
  (let ((tags (plist-get note-data :tags))
        (keywords (plist-get note-data :keywords)))
    (dolist (tag tags)
      (let ((files (gethash tag tiles--tag-index)))
        (unless (member filepath files)
          (puthash tag (cons filepath files) tiles--tag-index))))
    (dolist (kw keywords)
      (let ((files (gethash kw tiles--keyword-index)))
        (unless (member filepath files)
          (puthash kw (cons filepath files) tiles--keyword-index))))
    (puthash filepath (cons tags keywords) tiles--file-index-record)))

(defun tiles--index-forget (filepath)
  "Remove FILEPATH from the tag and keyword indices."
  (let ((rec (gethash filepath tiles--file-index-record)))
    (when rec
      (dolist (tag (car rec))
        (let ((files (delete filepath (gethash tag tiles--tag-index))))
          (if files
              (puthash tag files tiles--tag-index)
            (remhash tag tiles--tag-index))))
      (dolist (kw (cdr rec))
        (let ((files (delete filepath (gethash kw tiles--keyword-index))))
          (if files
              (puthash kw files tiles--keyword-index)
            (remhash kw tiles--keyword-index))))
      (remhash filepath tiles--file-index-record))))

(defun tiles--evict-file (filepath)
  "Remove FILEPATH from cache and reverse indices entirely."
  (tiles--index-forget filepath)
  (remhash filepath tiles--cache))

(defun tiles--load-disk-cache-once ()
  "Load the persistent cache from `tiles-cache-file' on the first call.
Subsequent calls are no-ops.  Entries are revalidated by mtime when first
accessed, so a stale file is always safe."
  (unless tiles--cache-loaded
    (setq tiles--cache-loaded t)
    (when (and tiles-cache-file (file-exists-p tiles-cache-file))
      (condition-case err
          (let* ((data (with-temp-buffer
                         (insert-file-contents tiles-cache-file)
                         (read (current-buffer))))
                 (version (plist-get data :version))
                 (entries (plist-get data :entries)))
            (when (eq version 1)
              (dolist (entry entries)
                (let* ((file (car entry))
                       (mtime-and-parsed (cdr entry)))
                  (when (and (stringp file) (file-exists-p file))
                    (puthash file mtime-and-parsed tiles--cache)
                    (tiles--index-record file (cdr mtime-and-parsed)))))))
        (error
         (message "Tiles: failed to load cache from %s: %s"
                  tiles-cache-file (error-message-string err)))))))

(defun tiles--save-disk-cache ()
  "Persist `tiles--cache' to `tiles-cache-file' if enabled."
  (when (and tiles-cache-file
             (> (hash-table-count tiles--cache) 0))
    (condition-case err
        (let (entries)
          (maphash (lambda (file v) (push (cons file v) entries))
                   tiles--cache)
          (let ((dir (file-name-directory tiles-cache-file)))
            (when dir (make-directory dir t)))
          (with-temp-file tiles-cache-file
            (let ((print-level nil) (print-length nil))
              (prin1 (list :version 1 :entries entries) (current-buffer)))))
      (error
       (message "Tiles: failed to save cache to %s: %s"
                tiles-cache-file (error-message-string err))))))

(defun tiles--ensure-index ()
  "Ensure the reverse index covers all current on-disk notes.
Loads the persistent cache, parses any uncached files, evicts entries
whose files have disappeared, and installs `file-notify' watches."
  (tiles--load-disk-cache-once)
  (unless tiles--index-complete
    (let ((files (tiles--get-all-tile-files))
          (current (make-hash-table :test 'equal)))
      (dolist (file files)
        (puthash file t current)
        (tiles--parse-note-file file))
      (let (stale)
        (maphash (lambda (file _)
                   (unless (gethash file current)
                     (push file stale)))
                 tiles--cache)
        (dolist (file stale)
          (tiles--evict-file file)))
      (setq tiles--index-complete t)
      (when tiles-watch-files
        (tiles--start-file-watch)))))

(defun tiles--files-with-tag-substring (qtag)
  "Return files whose tags contain QTAG as a substring."
  (let (out)
    (maphash (lambda (tag files)
               (when (string-match-p (regexp-quote qtag) tag)
                 (setq out (append files out))))
             tiles--tag-index)
    (delete-dups out)))

(defun tiles--files-with-keyword-substring (qkw)
  "Return files whose keywords contain QKW (normalized) as a substring."
  (let ((normalized (tiles--normalize-keyword qkw))
        out)
    (maphash (lambda (kw files)
               (when (string-match-p (regexp-quote normalized) kw)
                 (setq out (append files out))))
             tiles--keyword-index)
    (delete-dups out)))

(defun tiles--files-matching-tag-query (query-and-groups)
  "Return files matching tag QUERY-AND-GROUPS via the reverse index.
AND within each group, OR across groups."
  (tiles--ensure-index)
  (let (result)
    (dolist (and-group query-and-groups)
      (when and-group
        (let ((files (tiles--files-with-tag-substring (car and-group))))
          (dolist (qtag (cdr and-group))
            (setq files (seq-intersection
                         files
                         (tiles--files-with-tag-substring qtag)
                         #'equal)))
          (setq result (append files result)))))
    (delete-dups result)))

(defun tiles--files-matching-keyword-and-group (and-group)
  "Return files matching every term in AND-GROUP.
AND-GROUP is a list of (NEGATE . TERM) pairs as produced by
`tiles--parse-keyword-query'.  Positive terms intersect; negative terms
are subtracted.  An all-negative group starts from the universe of all
indexed files."
  (let ((positive (seq-filter (lambda (t1) (not (car t1))) and-group))
        (negative (seq-filter #'car and-group))
        files)
    (cond
     (positive
      (setq files (tiles--files-with-keyword-substring (cdr (car positive))))
      (dolist (term (cdr positive))
        (setq files (seq-intersection
                     files
                     (tiles--files-with-keyword-substring (cdr term))
                     #'equal))))
     (negative
      (setq files (hash-table-keys tiles--file-index-record))))
    (dolist (term negative)
      (let ((excluded (tiles--files-with-keyword-substring (cdr term))))
        (setq files (seq-difference files excluded #'equal))))
    files))

(defun tiles--files-matching-keyword-query (query-and-groups)
  "Return files matching keyword QUERY-AND-GROUPS via the reverse index.
QUERY-AND-GROUPS is the output of `tiles--parse-keyword-query'.
AND within each group, OR across groups; `!'-prefixed terms negate."
  (tiles--ensure-index)
  (let (out)
    (dolist (and-group query-and-groups)
      (when and-group
        (setq out (append (tiles--files-matching-keyword-and-group and-group)
                          out))))
    (delete-dups out)))

(defun tiles--sort-newest-first (files)
  "Return FILES sorted by filename descending (newest first)."
  (sort (copy-sequence files)
        (lambda (a b)
          (string> (file-name-nondirectory a)
                   (file-name-nondirectory b)))))

(defun tiles--watched-directories ()
  "Return the list of directories that should be watched for changes."
  (let ((root (expand-file-name tiles-directory))
        dirs)
    (when (file-exists-p root)
      (push root dirs)
      (dolist (file (tiles--get-all-tile-files))
        (let ((dir (directory-file-name (file-name-directory file))))
          (unless (member dir dirs)
            (push dir dirs)))))
    dirs))

(defun tiles--start-file-watch ()
  "Install `file-notify' watches for all current note directories.
Replaces any watches installed by a previous call."
  (when (require 'filenotify nil t)
    (tiles--stop-file-watch)
    (dolist (dir (tiles--watched-directories))
      (condition-case nil
          (let ((desc (file-notify-add-watch
                       dir '(change attribute-change)
                       #'tiles--handle-file-event)))
            (push (cons dir desc) tiles--file-watches))
        (file-notify-error nil)))))

(defun tiles--stop-file-watch ()
  "Remove all active `file-notify' watches."
  (dolist (entry tiles--file-watches)
    (ignore-errors (file-notify-rm-watch (cdr entry))))
  (setq tiles--file-watches nil))

(defun tiles--tile-file-p (path)
  "Return non-nil if PATH names a TILES note file."
  (and (stringp path)
       (string-match-p "T[0-9]\\{14\\}\\.org\\'" path)))

(defun tiles--handle-file-event (event)
  "React to a `file-notify' EVENT touching a tile file."
  (let ((action (nth 1 event))
        (file (nth 2 event))
        (file2 (nth 3 event)))
    (cond
     ((eq action 'deleted)
      (when (tiles--tile-file-p file)
        (tiles--evict-file file)))
     ((eq action 'renamed)
      (when (tiles--tile-file-p file)
        (tiles--evict-file file))
      (when (tiles--tile-file-p file2)
        (remhash file2 tiles--cache)
        (when (file-exists-p file2)
          (tiles--parse-note-file file2))))
     ((memq action '(changed attribute-changed))
      (when (tiles--tile-file-p file)
        (remhash file tiles--cache)))
     ((eq action 'created)
      (cond
       ((tiles--tile-file-p file)
        (when (file-exists-p file)
          (tiles--parse-note-file file)))
       ((and file (file-directory-p file))
        (setq tiles--index-complete nil)))))))

(add-hook 'kill-emacs-hook #'tiles--save-disk-cache)

(defun tiles--private-paragraph-p (paragraph)
  "Return non-nil if PARAGRAPH start with &&."
  (string-prefix-p "&&" (string-trim-left paragraph)))

(defun tiles--strip-private-paragraphs (content)
  "Remove paragraphs starting with && from CONTENT."
  (let* ((paras (split-string content "\n\n+" t))
         (public (seq-remove #'tiles--private-paragraph-p paras)))
    (mapconcat #'identity public "\n\n")))

(defun tiles--extract-private-paragraphs (content)
  "Extract paragraphs starting with && from CONTENT, with && prefix removed."
  (let* ((paras (split-string content "\n\n+" t))
         (private (seq-filter #'tiles--private-paragraph-p paras)))
    (mapcar (lambda (p) (string-trim (substring (string-trim-left p) 2)))
            private)))

(defun tiles--normalize-keyword (kw)
  "Normalize keyword KW by replacing hyphens with spaces."
  (replace-regexp-in-string "-" " " kw))

(defun tiles--extract-keywords (text)
  "Extract bold keywords from TEXT.
Bold words in `org-mode' are surrounded by *asterisks*.
Hyphens are normalized to spaces so \"Falcon-9\" and \"Falcon 9\" are equivalent."
  (let ((keywords nil)
        (pos 0))
    (while (string-match "\\*\\([^*\n]+\\)\\*" text pos)
      (push (tiles--normalize-keyword (match-string 1 text)) keywords)
      (setq pos (match-end 0)))
    (delete-dups (nreverse keywords))))

(defun tiles--parse-tag-query (query)
  "Parse QUERY into AND-groups for tag matching.
`/' means AND (all tags must match), space means OR (any group matches).
\"b218/lx2026 misc\" becomes ((\"b218\" \"lx2026\") (\"misc\"))."
  (let ((or-parts (split-string query " " t)))
    (mapcar (lambda (part) (split-string part "/" t))
            or-parts)))

(defun tiles--tag-present-p (qtag note-tags)
  "Check if QTAG matches any tag in NOTE-TAGS."
  (seq-some (lambda (ntag)
              (string-match-p (regexp-quote qtag) ntag))
            note-tags))

(defun tiles--note-matches-tag-p (note-data query-and-groups)
  "Check if NOTE-DATA matches QUERY-AND-GROUPS.
QUERY-AND-GROUPS is a list of AND-groups.  Each AND-group is a list of tags
that must all be present.  Groups are combined with OR logic.
For example, ((\"b218\" \"lx2026\") (\"misc\")) means:
notes with both b218 AND lx2026, OR notes with misc."
  (let ((note-tags (plist-get note-data :tags)))
    (seq-some (lambda (and-group)
                (seq-every-p (lambda (qtag)
                               (tiles--tag-present-p qtag note-tags))
                             and-group))
              query-and-groups)))

(defun tiles--parse-keyword-query (query)
  "Parse QUERY into AND-groups for keyword matching.
`/' means AND (all terms in the group must match), space means OR (any
group matches).  A `!' prefix on a term negates it (the keyword must
NOT be present in the note).  Hyphens inside a term are preserved here;
normalization happens at match time.

Each AND-group is returned as a list of (NEGATE . TERM) cons cells.
For example, \"foo/!bar baz\" parses to
\((nil . \"foo\") (t . \"bar\")) ((nil . \"baz\"))."
  (mapcar (lambda (part)
            (mapcar (lambda (term)
                      (if (string-prefix-p "!" term)
                          (cons t (substring term 1))
                        (cons nil term)))
                    (split-string part "/" t)))
          (split-string query " " t)))

(defun tiles--keyword-present-p (qkw note-keywords)
  "Return non-nil if QKW (after normalization) matches any of NOTE-KEYWORDS."
  (let ((normalized (tiles--normalize-keyword qkw)))
    (seq-some (lambda (nkw)
                (string-match-p (regexp-quote normalized) nkw))
              note-keywords)))

(defun tiles--note-matches-keyword-p (note-data query-and-groups)
  "Check if NOTE-DATA matches QUERY-AND-GROUPS.
QUERY-AND-GROUPS is the output of `tiles--parse-keyword-query': a list
of AND-groups, each a list of (NEGATE . TERM) pairs.  All terms in a
group must match (positive terms present, negative terms absent);
groups are OR'd together."
  (let ((note-keywords (plist-get note-data :keywords)))
    (seq-some
     (lambda (and-group)
       (seq-every-p
        (lambda (term)
          (let ((has (tiles--keyword-present-p (cdr term) note-keywords)))
            (if (car term) (not has) has)))
        and-group))
     query-and-groups)))

(defun tiles--validate-note-format (content)
  "Validate that CONTENT follows the TILES note format.
Returns t if valid, or an error message string.
Checks that the last non-empty line is a tag line preceded by a blank line."
  (let* ((lines (split-string content "\n"))
         (i (1- (length lines))))
    ;; Skip trailing empty lines
    (while (and (>= i 0) (string-empty-p (string-trim (nth i lines))))
      (setq i (1- i)))
    (cond
     ((< i 0)
      "Note is empty")
     ((< i 2)
      "Note must have at least content, a blank line, and tags")
     ((not (string-match-p "^[a-zA-Z0-9/_-]+$" (string-trim (nth i lines))))
      "Last line must be tags (letters, numbers, hyphens, separated by /)")
     ((not (string-empty-p (string-trim (nth (1- i) lines))))
      "Tags must be preceded by a blank line")
     (t t))))

;;; Tag-line fontification

(defvar tiles--similar-region-start)

(defun tiles--tag-line-matcher (limit)
  "Font-lock matcher for the tag line (last non-empty line) in a tiles note.
Only matches when preceded by a blank line, per the TILES note format.
Searches up to LIMIT.  Returns non-nil and sets match data if found."
  (let ((tag-line-pos nil)
        (search-end (or (and (markerp tiles--similar-region-start)
                             (marker-position tiles--similar-region-start))
                        (point-max))))
    (save-excursion
      (goto-char search-end)
      (skip-chars-backward " \t\n")
      (beginning-of-line)
      (when (and (<= (point) limit)
                 (looking-at "^\\(.+\\)$")
                 (not (looking-at-p "^&&?[ \t]"))
                 (> (line-number-at-pos) 1)
                 (save-excursion
                   (forward-line -1)
                   (looking-at-p "^[ \t]*$")))
        (setq tag-line-pos (point))))
    (when (and tag-line-pos (>= tag-line-pos (point)))
      (goto-char tag-line-pos)
      (looking-at "^\\(.+\\)$"))))

(defun tiles--enable-tag-line-fontification ()
  "Enable red tag-line highlighting in the current buffer."
  (font-lock-add-keywords nil '((tiles--tag-line-matcher 1 'tiles-tags t)))
  (when font-lock-mode
    (font-lock-flush)))

;;; Tag mode helpers

(defun tiles--tags-inhibited-p ()
  "Return non-nil when tag usage is suppressed by `tiles-tag-mode'."
  (eq tiles-tag-mode 'inhibit))

(defun tiles--tags-restricted-p ()
  "Return non-nil when tags are restricted to the `tiles-tag-mode' list.
True only for the plain list-of-strings form, not `required-one-of'."
  (and (listp tiles-tag-mode)
       (or (null tiles-tag-mode) (stringp (car tiles-tag-mode)))))

(defun tiles--tags-required-one-of-p ()
  "Return non-nil when `tiles-tag-mode' is the (required-one-of ...) form."
  (and (consp tiles-tag-mode) (eq (car tiles-tag-mode) 'required-one-of)))

(defun tiles--tags-required-list ()
  "Return the list of tags at least one of which must be present.
Only meaningful when `tiles--tags-required-one-of-p' is non-nil."
  (cdr tiles-tag-mode))

(defun tiles--prompt-for-tags (&optional prompt)
  "Prompt for tags according to `tiles-tag-mode'.
Returns a slash-separated tag string, or nil when tags are inhibited.
PROMPT overrides the default prompt string."
  (cond
   ((tiles--tags-inhibited-p) nil)
   ((tiles--tags-restricted-p)
    (let* ((allowed tiles-tag-mode)
           (default (car allowed))
           (pr (or prompt (format "Tag (default: %s): " default)))
           (input (completing-read pr allowed nil t nil nil default)))
      (if (string-empty-p (string-trim input)) default input)))
   ((tiles--tags-required-one-of-p)
    (let* ((required (tiles--tags-required-list))
           (pr (or prompt (format "Tags (at least one of: %s): "
                                  (mapconcat #'identity required ", ")))))
      (read-string pr)))
   (t (read-string (or prompt "Tags (separated by /): ")))))

;;; Capture Mode

(defun tiles-insert-tag ()
  "Insert a tag at point using minibuffer completion.
In `inhibit' mode, displays a message instead.
In restricted mode, completes from the allowed list with `require-match'.
In `required-one-of' mode, suggests the required tags (free input allowed).
In unrestricted mode, suggests all tags found in existing notes (free input)."
  (interactive)
  (if (tiles--tags-inhibited-p)
      (message "Tags are inhibited (tiles-tag-mode is 'inhibit).  \
Set tiles-tag-mode to 'unrestricted or a tag list to enable them.")
    (let* ((candidates
            (cond
             ((tiles--tags-restricted-p) tiles-tag-mode)
             ((tiles--tags-required-one-of-p) (tiles--tags-required-list))
             (t (hash-table-keys (car (tiles--list-collect-data))))))
           (require-match (and (tiles--tags-restricted-p) t))
           (prompt
            (cond
             ((tiles--tags-required-one-of-p)
              (format "Tag (required: %s): "
                      (mapconcat #'identity (tiles--tags-required-list) ", ")))
             (t "Tag: ")))
           (tag (completing-read prompt candidates nil require-match)))
      (unless (string-empty-p (string-trim tag))
        (insert tag)))))

(defvar tiles-capture-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "C-c C-c") #'tiles-capture-save)
    (define-key map (kbd "C-c C-k") #'tiles-capture-cancel)
    map)
  "Keymap for `tiles-capture-mode'.")

(define-minor-mode tiles-capture-mode
  "Minor mode for capturing TILES notes."
  :lighter " Tiles-Capture"
  :keymap tiles-capture-mode-map
  (when tiles-capture-mode
    (tiles--enable-tag-line-fontification)
    (message "TILES: C-c C-c to save, C-c C-k to cancel, C-c m t to insert tag")))

;;;###autoload
(defun tiles-new ()
  "Create a new TILES note."
  (interactive)
  (tiles--ensure-directory)
  (let ((buf (get-buffer-create "*TILES New Note*")))
    (switch-to-buffer buf)
    (erase-buffer)
    (org-mode)
    (tiles-capture-mode 1)
    (insert "\n\n")
    (goto-char (point-min))
    (when tiles-focus-default
      (tiles-focus-mode 1))
    (if (tiles--tags-inhibited-p)
        (message "TILES: Write your note, then C-c C-c to save (C-c C-k to cancel)")
      (message "Write your note, prepend with && any meta paragraph (optional), place tag(s) on the last line (separated by /), then hit C-c"))))

;;;###autoload
(defalias 'tiles-capture #'tiles-new
  "Alias for `tiles-new'.")

(defun tiles-capture-save ()
  "Save the current TILES capture buffer as a new note."
  (interactive)
  (let ((content (buffer-string)))
    ;; Step 1: resolve tags per tiles-tag-mode
    (cond
     ((tiles--tags-inhibited-p)
      ;; Auto-insert placeholder tag when the format is not yet valid
      (unless (eq (tiles--validate-note-format content) t)
        (goto-char (point-max))
        (unless (looking-back "\n\n" nil)
          (unless (looking-back "\n" nil) (insert "\n"))
          (insert "\n"))
        (insert "-\n")
        (setq content (buffer-string))))
     (t
      ;; Prompt for tags if missing
      (let ((valid (tiles--validate-note-format content)))
        (unless (eq valid t)
          (let ((tags (tiles--prompt-for-tags (format "%s. Add tags: " valid))))
            (when (or (null tags) (string-empty-p (string-trim tags)))
              (user-error "Aborted: note needs tags"))
            (goto-char (point-max))
            (unless (looking-back "\n\n" nil)
              (unless (looking-back "\n" nil) (insert "\n"))
              (insert "\n"))
            (insert tags "\n")
            (setq content (buffer-string)))))))
    ;; Step 2: final format validation
    (let ((valid (tiles--validate-note-format content)))
      (unless (eq valid t)
        (user-error "%s" valid))
      ;; Step 3: validate tags per mode
      (when (or (tiles--tags-restricted-p) (tiles--tags-required-one-of-p))
        (let* ((lines (split-string content "\n"))
               (i (1- (length lines))))
          (while (and (>= i 0) (string-empty-p (string-trim (nth i lines))))
            (setq i (1- i)))
          (when (>= i 0)
            (let* ((tag-line (string-trim (nth i lines)))
                   (tags (split-string tag-line "/" t "[ \t]+")))
              (cond
               ((tiles--tags-restricted-p)
                (let ((invalid (seq-remove (lambda (tag) (member tag tiles-tag-mode)) tags)))
                  (when invalid
                    (user-error "Tag(s) not in allowed list: %s.  Allowed: %s"
                                (mapconcat #'identity invalid ", ")
                                (mapconcat #'identity tiles-tag-mode ", ")))))
               ((tiles--tags-required-one-of-p)
                (let ((required (tiles--tags-required-list)))
                  (unless (seq-some (lambda (tag) (member tag required)) tags)
                    (user-error "At least one of these tags is required: %s"
                                (mapconcat #'identity required ", "))))))))))
      ;; Step 4: save
      (when (bound-and-true-p tiles-focus-mode)
        (tiles-focus-mode -1))
      (tiles--ensure-directory)
      (let* ((save-dir (tiles--note-save-directory))
             (filename (tiles--generate-filename))
             (filepath (expand-file-name filename save-dir)))
        ;; Ensure we don't overwrite
        (while (file-exists-p filepath)
          (sleep-for 0.1)
          (setq filename (tiles--generate-filename))
          (setq filepath (expand-file-name filename save-dir)))
        (write-region content nil filepath)
        (tiles-capture-mode -1)
        (kill-buffer)
        ;; Refresh dashboard if it exists
        (when (get-buffer tiles--notes-buffer-name)
          (tiles-show-notes))
        (message "Saved: %s" filename)))))

(defun tiles-capture-cancel ()
  "Cancel the current TILES capture."
  (interactive)
  (when (yes-or-no-p "Discard this note? ")
    (tiles-capture-mode -1)
    (kill-buffer)
    (message "Note discarded")))

(defvar-local tiles--focus-saved-margins nil
  "Saved window margins before `tiles-focus-mode' was activated.")
(defvar-local tiles--focus-saved-header nil
  "Saved `header-line-format' before `tiles-focus-mode' was activated.")
(defvar-local tiles--focus-header-remap nil
  "Face remap cookie for `header-line' in `tiles-focus-mode'.")

(defun tiles--focus-reapply ()
  "Re-apply focus mode margins to all windows currently displaying this buffer.
Used as a buffer-local `window-configuration-change-hook' to restore margins
after switching away and back to a focus-mode buffer."
  (when (bound-and-true-p tiles-focus-mode)
    (dolist (win (get-buffer-window-list (current-buffer) nil t))
      (let* ((body-width 80)
             (win-width (window-total-width win))
             (margin (max 0 (/ (- win-width body-width) 2))))
        (set-window-margins win margin margin)
        (set-window-fringes win 0 0)))))

(define-minor-mode tiles-focus-mode
  "Minor mode for distraction-free note editing.
Centers the buffer content with approximately 80-character line width
and adds visual breathing room at the top via the header line."
  :lighter " Focus"
  (if tiles-focus-mode
      (let* ((body-width 80)
             (win (selected-window))
             (win-width (window-total-width win))
             (margin (max 0 (/ (- win-width body-width) 2))))
        (setq tiles--focus-saved-margins (window-margins win))
        (set-window-margins win margin margin)
        (set-window-fringes win 0 0)
        (setq-local word-wrap t)
        (setq-local truncate-lines nil)
        (visual-line-mode 1)
        ;; Add visual breathing room via header-line: it lives above the buffer
        ;; content area so cursor positioning in the buffer is unaffected.
        (setq tiles--focus-saved-header header-line-format)
        (setq tiles--focus-header-remap
              (face-remap-add-relative 'header-line
                :background (face-attribute 'default :background)
                :foreground (face-attribute 'default :foreground)
                :box nil :underline nil :overline nil))
        (setq-local header-line-format
                    (propertize " " 'display '(space :height 2.0)))
        ;; Re-apply margins whenever this buffer appears in a new window
        (add-hook 'window-configuration-change-hook #'tiles--focus-reapply nil t))
    ;; Deactivate: restore margins and header line
    (remove-hook 'window-configuration-change-hook #'tiles--focus-reapply t)
    (when tiles--focus-header-remap
      (face-remap-remove-relative tiles--focus-header-remap)
      (setq tiles--focus-header-remap nil))
    (setq-local header-line-format tiles--focus-saved-header)
    (setq tiles--focus-saved-header nil)
    (let ((win (selected-window)))
      (if tiles--focus-saved-margins
          (set-window-margins win (car tiles--focus-saved-margins)
                              (cdr tiles--focus-saved-margins))
        (set-window-margins win 0 0))
      (set-window-fringes win nil nil))
    (visual-line-mode -1)
    (kill-local-variable 'word-wrap)
    (kill-local-variable 'truncate-lines)))

(defun tiles--touch-rename (filepath)
  "Prompt to rename FILEPATH to a fresh current-time timestamp.
On confirmation, performs the rename and evicts the old path from the
cache.  Returns the new filepath, or nil if the user declined."
  (let* ((old-ts (tiles--filename-to-timestamp (file-name-nondirectory filepath)))
         (new-name (tiles--generate-filename))
         (new-filepath (expand-file-name new-name (file-name-directory filepath)))
         (new-ts (tiles--filename-to-timestamp new-name)))
    (when (yes-or-no-p (format "Update timestamp from %s to %s? " old-ts new-ts))
      (rename-file filepath new-filepath)
      (tiles--evict-file filepath)
      new-filepath)))

;;;###autoload
(defun tiles-touch ()
  "Update the current note's timestamp to now and rename the file accordingly.
Must be visiting a TILES note file.  Asks for confirmation."
  (interactive)
  (let ((filepath (buffer-file-name)))
    (unless (and filepath (tiles--tile-file-p filepath))
      (user-error "Not visiting a TILES note file"))
    (when (buffer-modified-p)
      (save-buffer))
    (let* ((old-name (file-name-nondirectory filepath))
           (new-filepath (tiles--touch-rename filepath)))
      (when new-filepath
        (set-visited-file-name new-filepath t t)
        (when (equal tiles--preview-file filepath)
          (setq tiles--preview-file new-filepath))
        (let ((notes-buf (get-buffer tiles--notes-buffer-name)))
          (when notes-buf
            (with-current-buffer notes-buf
              (tiles-show-notes))))
        (message "Renamed %s -> %s"
                 old-name (file-name-nondirectory new-filepath))))))

(defun tiles--quick-save (content tags)
  "Save a note with CONTENT and TAGS (slash-separated string)."
  (tiles--ensure-directory)
  (let* ((save-dir (tiles--note-save-directory))
         (filename (tiles--generate-filename))
         (filepath (expand-file-name filename save-dir))
         (note (concat content "\n\n" tags "\n")))
    (while (file-exists-p filepath)
      (sleep-for 0.1)
      (setq filename (tiles--generate-filename))
      (setq filepath (expand-file-name filename save-dir)))
    (write-region note nil filepath)
    (when (get-buffer tiles--notes-buffer-name)
      (tiles-show-notes))
    (message "Saved: %s" filename)))

;;;###autoload
(defun tiles-quick ()
  "Quick capture a note via the minibuffer.
Prompts for content, then tags per `tiles-tag-mode'."
  (interactive)
  (let* ((content (read-string "Note: "))
         (tags (tiles--prompt-for-tags)))
    (when (string-empty-p (string-trim content))
      (user-error "Note content cannot be empty"))
    (when (and (not (tiles--tags-inhibited-p))
               (or (null tags) (string-empty-p (string-trim tags))))
      (user-error "Tags cannot be empty"))
    (tiles--quick-save content (or tags "-"))))

;;;###autoload
(defun tiles-yank ()
  "Quick capture a note with content from clipboard.
Pre-fills content from the kill ring, then prompts for tags."
  (interactive)
  (let* ((clip (or (current-kill 0 t) ""))
         (content (read-string "Note: " clip))
         (tags (tiles--prompt-for-tags)))
    (when (string-empty-p (string-trim content))
      (user-error "Note content cannot be empty"))
    (when (and (not (tiles--tags-inhibited-p))
               (or (null tags) (string-empty-p (string-trim tags))))
      (user-error "Tags cannot be empty"))
    (tiles--quick-save content (or tags "-"))))

;;; Search Functions

;;;###autoload
(defun tiles-tag-search (query)
  "Search TILES notes by tags.
QUERY uses / for AND and space for OR.
\"b218/lx2026\" matches notes with both tags.
\"b218 misc\" matches notes with either tag.
\"b218/lx2026 misc\" matches (b218 AND lx2026) OR misc."
  (interactive (list (cond
                       ((tiles--tags-inhibited-p)
                        (user-error "Tag search is disabled (tiles-tag-mode is inhibit)"))
                       ((tiles--tags-restricted-p)
                        (completing-read "Search tag: " tiles-tag-mode nil t))
                       ((tiles--tags-required-one-of-p)
                        (completing-read
                         (format "Search tags (required: %s): "
                                 (mapconcat #'identity (tiles--tags-required-list) ", "))
                         (tiles--tags-required-list) nil nil))
                       (t (read-string "Search tags: ")))))
  (let* ((query-tags (tiles--parse-tag-query query))
         (results (tiles--sort-newest-first
                   (tiles--files-matching-tag-query query-tags))))
    (setq tiles--current-search-results results)
    (setq tiles--current-search-query query)
    (setq tiles--current-search-type 'tag)
    (if results
        (tiles--show-search-view results)
      (message "No matching notes found for tags: %s" query))))

;;;###autoload
(defun tiles-keyword-search (query)
  "Search TILES notes by bold keywords.
QUERY uses space for OR, `/' for AND, and a `!' prefix for NOT.
\"foo bar\" matches notes containing either keyword.
\"foo/bar\" matches notes containing both.
\"foo/!bar\" matches notes containing foo but not bar.
\"foo/bar baz\" = (foo AND bar) OR baz."
  (interactive "sSearch keywords (space=OR, /=AND, !=NOT): ")
  (let* ((query-and-groups (tiles--parse-keyword-query query))
         (results (tiles--sort-newest-first
                   (tiles--files-matching-keyword-query query-and-groups))))
    (setq tiles--current-search-results results)
    (setq tiles--current-search-query query)
    (setq tiles--current-search-type 'keyword)
    (if results
        (tiles--show-search-view results)
      (message "No matching notes found for keywords: %s" query))))

(defvar tiles-list-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "n") #'tiles-list-next)
    (define-key map (kbd "p") #'tiles-list-prev)
    (define-key map (kbd "RET") #'tiles-list-select)
    (define-key map (kbd "o") #'tiles-list-sort-occurrence)
    (define-key map (kbd "a") #'tiles-list-sort-alpha)
    (define-key map (kbd "d") #'tiles-list-toggle-direction)
    (define-key map (kbd "R") #'tiles-list-rename)
    (define-key map (kbd "q") #'quit-window)
    map)
  "Keymap for `tiles-list-mode'.")

(define-derived-mode tiles-list-mode special-mode "Tiles-List"
  "Major mode for browsing TILES tags or keywords."
  (setq-local cursor-type 'box)
  (setq truncate-lines t)
  (face-remap-add-relative 'hl-line 'tiles-notes-hl-line)
  (hl-line-mode 1))

(defvar-local tiles--list-type nil "Type of list: `tag' or `keyword'.")
(defvar-local tiles--list-items nil "Hash table of items to counts.")
(defvar-local tiles--list-cross nil "Hash table of cross-reference items.")
(defvar-local tiles--list-sort 'alpha "Current sort: `alpha' or `occurrence'.")
(defvar-local tiles--list-descending nil "When non-nil, sort in descending order.")

(defun tiles--list-render ()
  "Re-render the current list buffer with current sort settings."
  (let* ((items tiles--list-items)
         (cross tiles--list-cross)
         (sort-type tiles--list-sort)
         (desc tiles--list-descending)
         (type tiles--list-type)
         (type-label (if (eq type 'tag) "tags" "keywords"))
         (keys (hash-table-keys items))
         (sorted (if (eq sort-type 'occurrence)
                     (sort keys (if desc
                                    (lambda (a b)
                                      (let ((ca (gethash a items))
                                            (cb (gethash b items)))
                                        (if (= ca cb) (string< a b) (> ca cb))))
                                  (lambda (a b)
                                    (let ((ca (gethash a items))
                                          (cb (gethash b items)))
                                      (if (= ca cb) (string< a b) (< ca cb))))))
                   (sort keys (if desc #'string> #'string<))))
         (sort-label (format "%s %s"
                             (if (eq sort-type 'alpha) "alphabetical" "by occurrence")
                             (if desc "descending" "ascending")))
         (inhibit-read-only t))
    (erase-buffer)
    (insert (propertize (format "%d unique %s  [%s]  (RET:search  R:rename  o:sort occurrence  a:sort alpha  d:toggle direction  q:quit)\n\n"
                                (length keys) type-label sort-label)
                        'face 'font-lock-comment-face
                        'tiles-header t))
    (dolist (item sorted)
      (let ((count (gethash item items))
            (is-cross (gethash item cross)))
        (insert (format "[%d] " count)
                (if is-cross
                    (propertize item 'face 'bold)
                  item)
                "\n")))
    (goto-char (point-min))
    (forward-line 2)))

(defun tiles-list-next ()
  "Move to the next item in the list."
  (interactive)
  (forward-line 1)
  (when (eobp) (forward-line -1)))

(defun tiles-list-prev ()
  "Move to the previous item in the list."
  (interactive)
  (forward-line -1)
  (when (get-text-property (point) 'tiles-header)
    (forward-line 1)))

(defun tiles-list-select ()
  "Search the dashboard for the item on the current line."
  (interactive)
  (let* ((raw (string-trim (buffer-substring-no-properties
                            (line-beginning-position) (line-end-position))))
         (item (if (string-match "^\\[[0-9]+\\] \\(.*\\)" raw)
                   (match-string 1 raw)
                 raw))
         (type (buffer-local-value 'tiles--list-type (current-buffer))))
    (when (and item (not (string-empty-p item)))
      (quit-window)
      (setq tiles--notes-page 0)
      (setq tiles--notes-filter (cons type item))
      (tiles-show-notes))))

(defun tiles-list-sort-occurrence ()
  "Sort the list by occurrence count (high to low by default)."
  (interactive)
  (setq tiles--list-sort 'occurrence)
  (setq tiles--list-descending t)
  (tiles--list-render))

(defun tiles-list-sort-alpha ()
  "Sort the list alphabetically (a-z by default)."
  (interactive)
  (setq tiles--list-sort 'alpha)
  (setq tiles--list-descending nil)
  (tiles--list-render))

(defun tiles-list-toggle-direction ()
  "Toggle between ascending and descending sort."
  (interactive)
  (setq tiles--list-descending (not tiles--list-descending))
  (tiles--list-render))

(defun tiles--rename-keyword-in-file (file old-keyword new-keyword)
  "Replace bold OLD-KEYWORD with NEW-KEYWORD in FILE.
Matching is done after normalizing hyphens to spaces, so `*Falcon-9*'
matches the keyword \"Falcon 9\".  Returns non-nil if the file changed."
  (with-temp-buffer
    (insert-file-contents file)
    (let ((modified nil))
      (goto-char (point-min))
      (while (re-search-forward "\\*\\([^*\n]+\\)\\*" nil t)
        (when (string= (tiles--normalize-keyword (match-string 1))
                       old-keyword)
          (replace-match (format "*%s*" new-keyword) t t)
          (setq modified t)))
      (when modified
        (write-region (point-min) (point-max) file nil 'silent)
        t))))

(defun tiles--rename-tag-in-file (file old-tag new-tag)
  "Replace OLD-TAG with NEW-TAG on the tag line of FILE.
The tag line is the last non-empty line of the file.  Returns non-nil
if the file changed."
  (with-temp-buffer
    (insert-file-contents file)
    (let* ((lines (split-string (buffer-string) "\n"))
           (i (1- (length lines))))
      (while (and (>= i 0) (string-empty-p (string-trim (nth i lines))))
        (setq i (1- i)))
      (when (>= i 0)
        (let* ((tag-line (string-trim (nth i lines)))
               (tags (split-string tag-line "/" t "[ \t]+"))
               (renamed (mapcar (lambda (tag)
                                  (if (string= tag old-tag) new-tag tag))
                                tags)))
          (when (not (equal tags renamed))
            (setf (nth i lines) (mapconcat #'identity renamed "/"))
            (erase-buffer)
            (insert (mapconcat #'identity lines "\n"))
            (write-region (point-min) (point-max) file nil 'silent)
            t))))))

(defun tiles-list-rename ()
  "Rename the tag or keyword on the current line across all note files.
For keywords, replaces every `*old*' bold occurrence with `*new*'.
For tags, replaces the tag in the tag line of every file that has it.
Prompts for the new name and reports how many files were modified."
  (interactive)
  (let* ((type tiles--list-type)
         (label (if (eq type 'tag) "tag" "keyword"))
         (raw (string-trim (buffer-substring-no-properties
                            (line-beginning-position) (line-end-position))))
         (item (if (string-match "^\\[[0-9]+\\] \\(.*\\)" raw)
                   (match-string 1 raw)
                 raw)))
    (when (or (not item) (string-empty-p item))
      (user-error "No %s on this line" label))
    (when (and (eq type 'tag) (tiles--tags-inhibited-p))
      (user-error "Tag rename is disabled (tiles-tag-mode is inhibit)"))
    (let ((new-name (string-trim
                     (read-string (format "Rename %s \"%s\" to: " label item)
                                  item))))
      (when (string-empty-p new-name)
        (user-error "New %s name cannot be empty" label))
      (when (if (eq type 'tag)
                (string= new-name item)
              (string= (tiles--normalize-keyword new-name)
                       (tiles--normalize-keyword item)))
        (user-error "New name is the same as the old name"))
      (when (eq type 'tag)
        (cond
         ((tiles--tags-restricted-p)
          (unless (member new-name tiles-tag-mode)
            (user-error "New tag not in allowed list: %s"
                        (mapconcat #'identity tiles-tag-mode ", "))))
         ((tiles--tags-required-one-of-p)
          (when (and (member item (tiles--tags-required-list))
                     (not (member new-name (tiles--tags-required-list))))
            (unless (yes-or-no-p
                     (format "\"%s\" is in the required list and \"%s\" is not.  Continue? "
                             item new-name))
              (user-error "Aborted"))))))
      (tiles--ensure-index)
      (let* ((index (if (eq type 'tag) tiles--tag-index tiles--keyword-index))
             (files (copy-sequence (gethash item index)))
             (rename-fn (if (eq type 'tag)
                            #'tiles--rename-tag-in-file
                          #'tiles--rename-keyword-in-file))
             (count 0))
        (dolist (file files)
          (when (funcall rename-fn file item new-name)
            (remhash file tiles--cache)
            (tiles--parse-note-file file)
            (setq count (1+ count))))
        (message "Renamed %s \"%s\" to \"%s\" in %d file%s"
                 label item new-name count (if (= count 1) "" "s"))
        (let ((data (tiles--list-collect-data)))
          (setq-local tiles--list-items
                      (if (eq type 'tag) (car data) (cdr data)))
          (setq-local tiles--list-cross
                      (if (eq type 'tag) (cdr data) (car data)))
          (tiles--list-render))))))

(defun tiles--list-collect-data ()
  "Collect tag and keyword counts from all notes.
Returns (tags-hash . keywords-hash) where values are counts."
  (tiles--ensure-index)
  (let ((all-tags (make-hash-table :test 'equal))
        (all-keywords (make-hash-table :test 'equal)))
    (maphash (lambda (tag files)
               (puthash tag (length files) all-tags))
             tiles--tag-index)
    (maphash (lambda (kw files)
               (puthash kw (length files) all-keywords))
             tiles--keyword-index)
    (cons all-tags all-keywords)))

;;;###autoload
(defun tiles-list-tags ()
  "Display all unique tags across all notes in a navigable buffer.
Tags that also appear as keywords are shown in bold.
Press RET to filter the dashboard by the selected tag."
  (interactive)
  (when (tiles--tags-inhibited-p)
    (user-error "Tag listing is disabled (tiles-tag-mode is inhibit)"))
  (let* ((data (tiles--list-collect-data))
         (buf (get-buffer-create "*Tiles Tags*")))
    (with-current-buffer buf
      (tiles-list-mode)
      (setq-local tiles--list-type 'tag)
      (setq-local tiles--list-items (car data))
      (setq-local tiles--list-cross (cdr data))
      (setq-local tiles--list-sort 'alpha)
      (setq-local tiles--list-descending nil)
      (tiles--list-render))
    (switch-to-buffer buf)))

;;;###autoload
(defun tiles-list-keywords ()
  "Display all unique keywords across all notes in a navigable buffer.
Keywords that also appear as tags are shown in bold.
Press RET to filter the dashboard by the selected keyword."
  (interactive)
  (let* ((data (tiles--list-collect-data))
         (buf (get-buffer-create "*Tiles Keywords*")))
    (with-current-buffer buf
      (tiles-list-mode)
      (setq-local tiles--list-type 'keyword)
      (setq-local tiles--list-items (cdr data))
      (setq-local tiles--list-cross (car data))
      (setq-local tiles--list-sort 'alpha)
      (setq-local tiles--list-descending nil)
      (tiles--list-render))
    (switch-to-buffer buf)))

;;; Notes Viewer

(defvar tiles-notes-view-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "n") #'tiles-notes-next)
    (define-key map (kbd "p") #'tiles-notes-prev)
    (define-key map (kbd "<down>") #'tiles-notes-next)
    (define-key map (kbd "<up>") #'tiles-notes-prev)
    (define-key map (kbd "RET") #'tiles-notes-open)
    (define-key map (kbd "<M-return>") #'tiles-notes-open-read-only)
    (define-key map (kbd "SPC") #'tiles-notes-preview)
    (define-key map (kbd "t") #'tiles-notes-filter-tag)
    (define-key map (kbd "k") #'tiles-notes-filter-keyword)
    (define-key map (kbd "c") #'tiles-notes-clear-filter)
    (define-key map (kbd "C") #'tiles-notes-clear-exclude)
    (define-key map (kbd "d") #'tiles-notes-change-date)
    (define-key map (kbd "D") #'tiles-notes-delete)
    (define-key map (kbd "TAB") #'tiles-notes-toggle-expand)
    (define-key map (kbd "<M-up>") #'tiles-notes-move-up)
    (define-key map (kbd "<M-down>") #'tiles-notes-move-down)
    (define-key map (kbd "f") #'tiles-notes-toggle-raw)
    (define-key map (kbd "F") #'tiles-notes-exclude-tags)
    (define-key map (kbd "T") #'tiles-list-tags)
    (define-key map (kbd "K") #'tiles-list-keywords)
    (define-key map (kbd "u") #'tiles-notes-touch)
    (define-key map (kbd "w") #'tiles-notes-copy)
    (define-key map (kbd "W") #'tiles-notes-copy-stripped)
    (define-key map (kbd "+") #'tiles-notes-load-more)
    (define-key map (kbd "0") #'tiles-notes-stitch)
    (define-key map (kbd "l") #'tiles-new)
    (define-key map (kbd "g") #'tiles-show-notes)
    (define-key map (kbd "q") #'tiles-notes-quit)
    map)
  "Keymap for `tiles-notes-view-mode'.")

(defface tiles-timestamp-today
  '((t :foreground "#228b22"))
  "Face for today's note timestamps."
  :group 'tiles)

(defface tiles-timestamp-recent
  '((t :foreground "#3a5a2a"))
  "Face for note timestamps less than 2 weeks old."
  :group 'tiles)

(defface tiles-timestamp-old
  '((t :foreground "#999999"))
  "Face for note timestamps older than 2 weeks."
  :group 'tiles)

(defface tiles-tags
  '((t :foreground "#a00000"))
  "Face for tags in the notes viewer."
  :group 'tiles)

(defface tiles-notes-hl-line
  '((t :background "#FFC700"))
  "Face for the selected line in the notes viewer."
  :group 'tiles)

(defface tiles-notes-expanded
  '((t :background "#FFF8DC"))
  "Face for expanded note lines in the notes viewer."
  :group 'tiles)

(defun tiles--hl-line-range ()
  "Return the range for hl-line: from line start to at most target-width chars."
  (let* ((bol (line-beginning-position))
         (eol (line-end-position))
         (end (min eol (+ bol tiles--line-target-width))))
    (cons bol end)))

(define-derived-mode tiles-notes-view-mode special-mode "Tiles-Notes"
  "Major mode for TILES chronological notes viewer."
  (setq-local line-move-visual nil)
  (setq-local cursor-type 'box)
  (setq truncate-lines t)
  (setq-local hl-line-range-function #'tiles--hl-line-range)
  (face-remap-add-relative 'hl-line 'tiles-notes-hl-line)
  (hl-line-mode 1)
  (add-hook 'after-save-hook #'tiles--after-save-hook))

(defun tiles--filename-to-timestamp (filename)
  "Extract a formatted timestamp from FILENAME.
FILENAME should match TYYYYMMDDHHMMSS.org."
  (when (string-match "T\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)" filename)
    (format "%s-%s-%s %s:%s"
            (match-string 1 filename)
            (match-string 2 filename)
            (match-string 3 filename)
            (match-string 4 filename)
            (match-string 5 filename))))

(defface tiles-keywords
  '((t :foreground "#006600"))
  "Face for keywords (bold words) in the notes viewer."
  :group 'tiles)

(defun tiles--strip-org-markup (text)
  "Strip all `org-mode' markup from TEXT, returning plain text.
Handles inline footnotes, links (with or without description), and the
emphasis markups: bold (*x*), italic (/x/), underline (_x_),
strikethrough (+x+), code (~x~), and verbatim (=x=)."
  (let ((result text))
    (setq result (replace-regexp-in-string "\\[fn:[^]]*\\]" "" result))
    (setq result (replace-regexp-in-string "\\[\\[\\(?:[^][]+\\)\\]\\[\\([^][]+\\)\\]\\]" "\\1" result))
    (setq result (replace-regexp-in-string "\\[\\[[^][]+\\]\\]" "" result))
    (setq result (replace-regexp-in-string "\\*\\([^*\n]+\\)\\*" "\\1" result))
    (setq result (replace-regexp-in-string "/\\([^/\n]+\\)/" "\\1" result))
    (setq result (replace-regexp-in-string "_\\([^_\n]+\\)_" "\\1" result))
    (setq result (replace-regexp-in-string "\\+\\([^+\n]+\\)\\+" "\\1" result))
    (setq result (replace-regexp-in-string "~\\([^~\n]+\\)~" "\\1" result))
    (setq result (replace-regexp-in-string "=\\([^=\n]+\\)=" "\\1" result))
    result))

(defun tiles--render-org-preview (text)
  "Render TEXT for dashboard preview.
When `tiles-preview-raw' is non-nil, strips all org markup to plain text.
Otherwise renders with bold/italic faces, stripping footnotes and links."
  (if tiles-preview-raw
      (tiles--strip-org-markup text)
    ;; Strip inline footnotes [fn:...]
    (let ((result text))
      (setq result (replace-regexp-in-string "\\[fn:[^]]*\\]" "" result))
      ;; Replace org links [[url][desc]] with just desc, and [[url]] with empty
      (setq result (replace-regexp-in-string "\\[\\[\\(?:[^][]+\\)\\]\\[\\([^][]+\\)\\]\\]" "\\1" result))
      (setq result (replace-regexp-in-string "\\[\\[[^][]+\\]\\]" "" result))
      ;; Apply bold
      (let ((pos 0)
            (out ""))
        (while (string-match "\\*\\([^*\n]+\\)\\*" result pos)
          (setq out (concat out (substring result pos (match-beginning 0))
                            (propertize (match-string 1 result) 'face 'bold)))
          (setq pos (match-end 0)))
        (setq result (concat out (substring result pos))))
      ;; Apply italic
      (let ((pos 0)
            (out ""))
        (while (string-match "/\\([^/\n]+\\)/" result pos)
          (setq out (concat out (substring result pos (match-beginning 0))
                            (propertize (match-string 1 result) 'face 'italic)))
          (setq pos (match-end 0)))
        (setq result (concat out (substring result pos))))
      result)))

(defun tiles--note-oneline-preview (note-data)
  "Return a propertized single-line preview of NOTE-DATA's content, max 80 chars."
  (let* ((content (tiles--strip-private-paragraphs
                   (or (plist-get note-data :content) "")))
         (line (string-trim (or (car (split-string content "\n" t)) "")))
         (rendered (tiles--render-org-preview line))
         (truncated (if (> (length rendered) tiles-preview-length)
                        (concat (substring rendered 0 (1- tiles-preview-length)) "…")
                      rendered)))
    truncated))

(defun tiles--timestamp-face (filename)
  "Return the appropriate face for FILENAME based on its age."
  (when (string-match "T\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)" filename)
    (let* ((date-str (format "%s-%s-%s"
                             (match-string 1 filename)
                             (match-string 2 filename)
                             (match-string 3 filename)))
           (note-time (date-to-time (concat date-str " 00:00:00")))
           (age-days (/ (float-time (time-subtract (current-time) note-time)) 86400)))
      (cond
       ((< age-days 1) 'tiles-timestamp-today)
       ((< age-days 14) 'tiles-timestamp-recent)
       (t 'tiles-timestamp-old)))))

(defun tiles-notes-filter-tag (query)
  "Filter the dashboard to show only notes matching QUERY tags."
  (interactive (list (cond
                       ((tiles--tags-inhibited-p)
                        (user-error "Tag filtering is disabled (tiles-tag-mode is inhibit)"))
                       ((tiles--tags-restricted-p)
                        (completing-read "Filter by tag: " tiles-tag-mode nil t))
                       ((tiles--tags-required-one-of-p)
                        (completing-read
                         (format "Filter by tag (required: %s): "
                                 (mapconcat #'identity (tiles--tags-required-list) ", "))
                         (tiles--tags-required-list) nil nil))
                       (t (read-string "Filter by tag: ")))))
  (if (string-empty-p (string-trim query))
      (tiles-notes-clear-filter)
    (setq tiles--notes-page 0)
    (setq tiles--notes-filter (cons 'tag query))
    (tiles-show-notes)))

(defun tiles-notes-filter-keyword (query)
  "Filter the dashboard to show only notes matching QUERY keywords."
  (interactive "sFilter by keyword: ")
  (if (string-empty-p (string-trim query))
      (tiles-notes-clear-filter)
    (setq tiles--notes-page 0)
    (setq tiles--notes-filter (cons 'keyword query))
    (tiles-show-notes)))

(defun tiles-notes-exclude-tags (query)
  "Exclude notes with any of the specified tags from the dashboard.
QUERY is a space-separated list of tags to exclude."
  (interactive (list (cond
                       ((tiles--tags-inhibited-p)
                        (user-error "Tag exclusion is disabled (tiles-tag-mode is inhibit)"))
                       ((tiles--tags-restricted-p)
                        (completing-read "Exclude tag: " tiles-tag-mode nil t))
                       ((tiles--tags-required-one-of-p)
                        (completing-read
                         (format "Exclude tag (required: %s): "
                                 (mapconcat #'identity (tiles--tags-required-list) ", "))
                         (tiles--tags-required-list) nil nil))
                       (t (read-string "Exclude tags (space-separated): ")))))
  (if (string-empty-p (string-trim query))
      (message "No tags specified")
    (setq tiles--notes-page 0)
    (setq tiles--notes-exclude (split-string query " " t))
    (tiles-show-notes)))

(defun tiles-notes-clear-filter ()
  "Clear the dashboard search filter (keep exclusions)."
  (interactive)
  (setq tiles--notes-page 0)
  (setq tiles--notes-filter nil)
  (tiles-show-notes))

(defun tiles-notes-clear-exclude ()
  "Clear the tag exclusion filter."
  (interactive)
  (setq tiles--notes-page 0)
  (setq tiles--notes-exclude nil)
  (tiles-show-notes))

(defun tiles-notes-touch ()
  "Update the selected note's timestamp to now and rename the file."
  (interactive)
  (let ((filepath (tiles--notes-current-file)))
    (unless filepath
      (user-error "No note on this line"))
    (let* ((old-name (file-name-nondirectory filepath))
           (new-filepath (tiles--touch-rename filepath)))
      (when new-filepath
        ;; Close preview if it was showing the renamed file
        (when (equal tiles--preview-file filepath)
          (let* ((buf (get-file-buffer filepath))
                 (win (when buf (get-buffer-window buf))))
            (when win (delete-window win))
            (when buf (kill-buffer buf))
            (setq tiles--preview-file nil)))
        (tiles-show-notes)
        (message "Renamed %s -> %s"
                 old-name (file-name-nondirectory new-filepath))))))

(defun tiles--notes-copy-to-kill (strip-markup)
  "Copy the note at point's content to the kill ring.
Private (`&&') paragraphs are always excluded.  When STRIP-MARKUP is
non-nil, also strip all org formatting (bold, italic, underline,
strikethrough, code, verbatim, links, inline footnotes)."
  (let ((filepath (tiles--notes-current-file)))
    (unless filepath
      (user-error "No note on this line"))
    (let* ((note-data (tiles--parse-note-file filepath))
           (raw (when note-data
                  (tiles--strip-private-paragraphs
                   (or (plist-get note-data :content) ""))))
           (content (when raw
                      (string-trim
                       (if strip-markup
                           (tiles--strip-org-markup raw)
                         raw)))))
      (if (and content (not (string-empty-p content)))
          (progn
            (kill-new content)
            (message "Copied %d character%s (%s) to the kill ring"
                     (length content)
                     (if (= (length content) 1) "" "s")
                     (if strip-markup "plain text" "verbatim")))
        (message "Note is empty")))))

(defun tiles-notes-copy ()
  "Copy the note on the current line to the kill ring verbatim.
Private (`&&') paragraphs are stripped, matching what is shown in
previews and stitched views, but `org-mode' markup (bold, italic,
links, etc.) is preserved.  Use `tiles-notes-copy-stripped' (bound to
`W' in the dashboard) for a plain-text copy with markup removed."
  (interactive)
  (tiles--notes-copy-to-kill nil))

(defun tiles-notes-copy-stripped ()
  "Copy the note on the current line to the kill ring as plain text.
Strips both private (`&&') paragraphs and all `org-mode' formatting
\(bold, italic, underline, strikethrough, code, verbatim, links,
inline footnotes).  Bound to `W' in the dashboard; use `w' to copy
verbatim with markup preserved."
  (interactive)
  (tiles--notes-copy-to-kill t))

(defun tiles-notes-load-more ()
  "Load the next batch of notes in the dashboard."
  (interactive)
  (if (not tiles-dashboard-limit)
      (message "All notes already shown")
    (let ((current-file (tiles--notes-current-file)))
      (setq tiles--notes-page (1+ tiles--notes-page))
      (tiles-show-notes)
      (when current-file
        (goto-char (point-min))
        (while (and (not (eobp))
                    (not (equal (get-text-property (point) 'tiles-filepath) current-file)))
          (forward-line 1))))))

(defun tiles-notes-toggle-raw ()
  "Toggle raw (plain text) preview in the dashboard."
  (interactive)
  (setq tiles-preview-raw (not tiles-preview-raw))
  (tiles-show-notes)
  (message "Preview formatting %s" (if tiles-preview-raw "off" "on")))

(defun tiles--apply-exclude (files)
  "Remove from FILES any notes that have excluded tags.
Uses `tiles--notes-exclude' and the reverse index."
  (if (not tiles--notes-exclude)
      files
    (tiles--ensure-index)
    (let ((excluded (make-hash-table :test 'equal)))
      (dolist (etag tiles--notes-exclude)
        (dolist (f (gethash etag tiles--tag-index))
          (puthash f t excluded)))
      (seq-remove (lambda (f) (gethash f excluded)) files))))

(defun tiles--apply-filter (files)
  "Filter FILES according to `tiles--notes-filter' and `tiles--notes-exclude'."
  (let ((result (tiles--apply-exclude files)))
    (if (not tiles--notes-filter)
        result
      (let* ((type (car tiles--notes-filter))
             (query (cdr tiles--notes-filter))
             (matching (if (eq type 'tag)
                           (tiles--files-matching-tag-query
                            (tiles--parse-tag-query query))
                         (tiles--files-matching-keyword-query
                          (tiles--parse-keyword-query query))))
             (match-set (make-hash-table :test 'equal)))
        (dolist (f matching) (puthash f t match-set))
        (seq-filter (lambda (f) (gethash f match-set)) result)))))

;;;###autoload
(defun tiles-show-notes ()
  "Display all notes chronologically, newest first.
Shows a dashboard with statistics and note listing."
  (interactive)
  (when tiles-debug
    (setq tiles--debug-cache-hits 0
          tiles--debug-cache-misses 0))
  (let* ((tiles--debug-t0 (when tiles-debug (float-time)))
         ;; Remember which note is selected when refreshing in place,
         ;; so we can restore the cursor on that line after re-render.
         (prev-selection (when (string= (buffer-name) tiles--notes-buffer-name)
                           (get-text-property (line-beginning-position)
                                              'tiles-filepath)))
         (all-files (tiles--get-all-tile-files))
         (tiles--debug-t1 (when tiles-debug (float-time)))
         (filtered (tiles--apply-filter all-files))
         (tiles--debug-t2 (when tiles-debug (float-time))))
    (if (and (not all-files)
             (not (string= (buffer-name) tiles--notes-buffer-name)))
        (message "No tiles notes found in %s" tiles-directory)
      (let* ((buf (get-buffer-create tiles--notes-buffer-name))
             (start-time (float-time))
             (num-all (length all-files))
             (num-filtered (length filtered))
             (page-end (if tiles-dashboard-limit
                           (* (1+ tiles--notes-page) tiles-dashboard-limit)
                         (length filtered)))
             (files (seq-take filtered page-end))
             (has-more (and tiles-dashboard-limit (> num-filtered page-end)))
             (max-tag-len 0))
        ;; Compute longest tag string length
        (dolist (file files)
          (let* ((note-data (tiles--parse-note-file file))
                 (tags (when note-data (plist-get note-data :tags)))
                 (tag-str (if tags (concat "  " (mapconcat #'identity tags "/")) "")))
            (setq max-tag-len (max max-tag-len (length tag-str)))))
        (delete-other-windows)
        (switch-to-buffer buf)
        (setq tiles--line-target-width (+ tiles-preview-length tiles-line-padding max-tag-len))
        (let ((inhibit-read-only t))
          (erase-buffer)
          (dolist (file files)
            (insert (tiles--render-note-line file) "\n"))
          (when has-more
            (insert (propertize (format "  ... %d more notes (+:load more)\n"
                                        (- num-filtered page-end))
                                'face 'font-lock-comment-face
                                'tiles-header t)))
          (let* ((load-time (- (float-time) start-time))
                 (title (format "  *T*agged *I*nstant *L*ightweight *E*macs *S*nippets (TILES), v%s | %d notes | loaded in %.3fs\n"
                                tiles-version num-all load-time))
                 (keys (concat "  [SPC] view, [RET] open, [M-RET] read-only, [TAB] expand, [f] format, [d] chg date, [u] touch, [w] copy, [0] stitch, [D] delete, [g] refresh\n"
                               (if (tiles--tags-inhibited-p)
                                   "  [k] filter keyword, [K] list keywords, [c] clr search, [l] new tile, [q] quit\n"
                                 "  [t] filter tag, [k] filter keyword, [F] exclude tags, [T] list tags, [K] list keywords, [c] clr search, [C] clr excl, [l] new tile, [q] quit\n")))
                 (lunar-str (or (condition-case nil
                                    (tiles--next-lunar-event)
                                  (error nil))
                                ""))
                 (filter-info (if tiles--notes-filter
                                  (format "filter %s: %s (%d/%d)"
                                          (car tiles--notes-filter)
                                          (cdr tiles--notes-filter)
                                          num-filtered num-all)
                                ""))
                 (exclude-info (if tiles--notes-exclude
                                   (format "excluding: %s"
                                           (mapconcat #'identity tiles--notes-exclude " "))
                                 ""))
                 (page-info (if tiles-dashboard-limit
                                (format "showing %d/%d" (length files) num-filtered)
                              ""))
                 (status-parts (seq-filter (lambda (s) (not (string-empty-p s)))
                                           (list lunar-str filter-info exclude-info page-info)))
                 (status-line (format "  %s\n" (mapconcat #'identity status-parts " > ")))
                 (eq-char (if tiles-fancy-separators ?═ ?=))
                 (dash-char (if tiles-fancy-separators ?─ ?-))
                 (eq-line (concat "  " (make-string (- tiles--line-target-width 2) eq-char) "\n"))
                 (dash-line (concat "  " (make-string (- tiles--line-target-width 2) dash-char) "\n\n")))
            (goto-char (point-min))
            (insert (propertize title 'tiles-header t)
                    (propertize eq-line 'face 'font-lock-comment-face 'tiles-header t)
                    (propertize keys 'face 'font-lock-comment-face 'tiles-header t)
                    (propertize status-line 'face 'font-lock-comment-face 'tiles-header t)
                    (propertize dash-line 'face 'font-lock-comment-face 'tiles-header t))
            (when tiles-debug
              (message "[TILES debug] %d files total, %d displayed | scan %.1fms | filter %.1fms | render %.1fms | total %.1fms | cache: %d hits, %d misses"
                       num-all (length files)
                       (* 1000.0 (- tiles--debug-t1 tiles--debug-t0))
                       (* 1000.0 (- tiles--debug-t2 tiles--debug-t1))
                       (* 1000.0 load-time)
                       (* 1000.0 (- (float-time) tiles--debug-t0))
                       tiles--debug-cache-hits
                       tiles--debug-cache-misses))))
        (tiles-notes-view-mode)
        ;; Restore cursor to the previously selected note if it is still
        ;; displayed; otherwise jump to the first note line.
        (goto-char (point-min))
        (unless (and prev-selection
                     (let (found)
                       (while (and (not found) (not (eobp)))
                         (if (and (equal (get-text-property (point) 'tiles-filepath)
                                         prev-selection)
                                  (not (get-text-property (point) 'tiles-header)))
                             (setq found t)
                           (forward-line 1)))
                       found))
          (goto-char (point-min))
          (text-property-search-forward 'tiles-filepath))))))

(defun tiles--notes-on-note-line-p ()
  "Return t if point is on a note line (not an expanded detail line)."
  (and (get-text-property (line-beginning-position) 'tiles-filepath)
       (not (get-text-property (line-beginning-position) 'tiles-expanded))))

(defun tiles--notes-update-preview-if-open ()
  "Update the preview split if it is currently showing a note."
  (when tiles--preview-file
    (let ((filepath (tiles--notes-current-file)))
      (when (and filepath (not (equal filepath tiles--preview-file)))
        (let* ((old-buf (get-file-buffer tiles--preview-file))
               (existing-win (when old-buf (get-buffer-window old-buf))))
          (when existing-win
            (with-selected-window existing-win
              (find-file filepath))
            (setq tiles--preview-file filepath)))))))

(defun tiles--notes-has-expansion-p ()
  "Return t if any note is currently expanded."
  (save-excursion
    (goto-char (point-min))
    (and (text-property-search-forward 'tiles-expanded) t)))

(defun tiles-notes-next ()
  "Move to next note."
  (interactive)
  (if (tiles--notes-has-expansion-p)
      (message "Collapse expanded note first (TAB)")
    (let ((start (point)))
      (forward-line 1)
      (while (and (not (eobp)) (not (tiles--notes-on-note-line-p)))
        (forward-line 1))
      (if (tiles--notes-on-note-line-p)
          (tiles--notes-update-preview-if-open)
        (goto-char start)))))

(defun tiles-notes-prev ()
  "Move to previous note."
  (interactive)
  (if (tiles--notes-has-expansion-p)
      (message "Collapse expanded note first (TAB)")
    (let ((start (point)))
      (forward-line -1)
      (while (and (not (bobp)) (not (tiles--notes-on-note-line-p)))
        (forward-line -1))
      (if (tiles--notes-on-note-line-p)
          (tiles--notes-update-preview-if-open)
        (goto-char start)))))

(defun tiles--notes-current-file ()
  "Get filepath of note on current line."
  (get-text-property (line-beginning-position) 'tiles-filepath))

(defun tiles-notes-open ()
  "Open the note on the current line."
  (interactive)
  (let ((filepath (tiles--notes-current-file)))
    (when filepath
      (find-file filepath)
      (goto-char (point-min))
      (tiles--enable-tag-line-fontification)
      (when tiles-focus-default
        (tiles-focus-mode 1)))))

(defun tiles-notes-open-read-only ()
  "Open the note on the current line in a read-only buffer.
Activates `tiles-readonly-mode' so `m' shows similar notes."
  (interactive)
  (let ((filepath (tiles--notes-current-file)))
    (when filepath
      (find-file-read-only filepath)
      (goto-char (point-min))
      (tiles--enable-tag-line-fontification)
      (tiles-readonly-mode 1)
      (when tiles-focus-default
        (tiles-focus-mode 1)))))

(defun tiles-notes-preview ()
  "Open the note on the current line in a split below for editing."
  (interactive)
  (let ((filepath (tiles--notes-current-file)))
    (when filepath
      (let ((existing-win (when tiles--preview-file
                            (let ((buf (get-file-buffer tiles--preview-file)))
                              (when buf (get-buffer-window buf))))))
        (if existing-win
            ;; Reuse the existing preview window
            (progn
              (with-selected-window existing-win
                (find-file filepath))
              (setq tiles--preview-file filepath))
          ;; Open a new split
          (split-window-below)
          (other-window 1)
          (find-file filepath)
          (setq tiles--preview-file filepath)
          (other-window 1))))))

(defun tiles--note-expanded-extra (note-data file)
  "Return up to three extra lines for NOTE-DATA.
The lines are: private (`&&') paragraphs when present, keywords, and stats
\(char/word counts and on-disk size).  FILE is the path to the note file,
used to compute filesize."
  (let* ((content (or (plist-get note-data :content) ""))
         (filepath (plist-get note-data :filepath))
         (indent (make-string 20 ?\s))
         (keywords (plist-get note-data :keywords))
         (char-count (length content))
         (word-count (length (split-string content "[ \t\n]+" t)))
         (filesize (if (and filepath (file-exists-p filepath))
                       (let* ((size (file-attribute-size (file-attributes filepath)))
                              (human (file-size-human-readable size)))
                         (if (string-match-p "[A-Za-z]" human)
                             human
                           (concat human "B")))
                     "?"))
         (result ""))
    ;; Private paragraphs (&&) line
    (let ((private (tiles--extract-private-paragraphs content)))
      (when private
        (let* ((priv-str (mapconcat #'identity private " | "))
               (max-priv-len (- tiles--line-target-width 20))
               (priv-str (if (> (length priv-str) max-priv-len)
                             (concat (substring priv-str 0 (1- max-priv-len)) "…")
                           priv-str))
               (priv-line (concat indent priv-str))
               (padded-priv (if (< (length priv-line) tiles--line-target-width)
                                (concat priv-line (make-string (- tiles--line-target-width (length priv-line)) ?\s))
                              priv-line)))
          (setq result (concat result
                               (propertize padded-priv
                                           'face (list 'font-lock-doc-face 'tiles-notes-expanded)
                                           'tiles-filepath file
                                           'tiles-expanded t)
                               "\n")))))
    ;; Keywords line (truncated to align with preview above)
    (let* ((kw-str (if keywords
                       (concat "[" (mapconcat #'identity keywords ", ") "]")
                     "[no keywords]"))
           (max-kw-len (- tiles--line-target-width 20))
           (kw-str (if (> (length kw-str) max-kw-len)
                       (concat (substring kw-str 0 (1- max-kw-len)) "…")
                     kw-str))
           (kw-line (concat indent kw-str))
           (padded-kw (if (< (length kw-line) tiles--line-target-width)
                          (concat kw-line (make-string (- tiles--line-target-width (length kw-line)) ?\s))
                        kw-line)))
      (setq result (concat result
                           (propertize padded-kw
                                       'face (list 'tiles-tags 'tiles-notes-expanded)
                                       'tiles-filepath file
                                       'tiles-expanded t)
                           "\n")))
    ;; Stats line
    (let* ((stats (format "%d chars, %d words, %s" char-count word-count filesize))
           (stats-line (concat indent stats))
           (padded-stats (if (< (length stats-line) tiles--line-target-width)
                             (concat stats-line (make-string (- tiles--line-target-width (length stats-line)) ?\s))
                           stats-line)))
      (setq result (concat result
                           (propertize padded-stats
                                       'face (list 'font-lock-comment-face 'tiles-notes-expanded)
                                       'tiles-filepath file
                                       'tiles-expanded t)
                           "\n")))
    result))

(defun tiles-notes-toggle-expand ()
  "Toggle expanded view for the note on the current line."
  (interactive)
  (let ((filepath (tiles--notes-current-file)))
    (unless filepath
      (user-error "No note on this line"))
    (let ((inhibit-read-only t))
      (save-excursion
        (forward-line 1)
        (if (get-text-property (point) 'tiles-expanded)
            ;; Collapse: remove expanded lines
            (let ((start (point)))
              (while (and (not (eobp))
                          (get-text-property (point) 'tiles-expanded))
                (forward-line 1))
              (delete-region start (point)))
          ;; Expand: insert continuation + keywords below current line
          (let* ((note-data (tiles--parse-note-file filepath))
                 (extra (when note-data
                          (tiles--note-expanded-extra note-data filepath))))
            (when (and extra (not (string-empty-p extra)))
              (insert extra))))))))

(defun tiles--notes-displayed-files ()
  "Return the list of filepaths in current display order from the dashboard buffer."
  (let ((files nil))
    (save-excursion
      (goto-char (point-min))
      (while (not (eobp))
        (let ((fp (get-text-property (point) 'tiles-filepath)))
          (when (and fp
                     (not (get-text-property (point) 'tiles-expanded))
                     (not (get-text-property (point) 'tiles-header)))
            (push fp files)))
        (forward-line 1)))
    (nreverse files)))

(defun tiles-notes-move-up ()
  "Move the selected note up one position in the dashboard."
  (interactive)
  (when (tiles--notes-has-expansion-p)
    (user-error "Collapse expanded note first (TAB)"))
  (let ((filepath (tiles--notes-current-file)))
    (unless filepath
      (user-error "No note on this line"))
    (let ((inhibit-read-only t)
          (cur-start (line-beginning-position))
          (cur-end (1+ (line-end-position))))
      ;; Find previous note line
      (save-excursion
        (forward-line -1)
        (while (and (not (bobp)) (not (tiles--notes-on-note-line-p)))
          (forward-line -1))
        (when (tiles--notes-on-note-line-p)
          (let* ((prev-start (line-beginning-position))
                 (cur-line (buffer-substring cur-start cur-end)))
            (delete-region cur-start cur-end)
            (goto-char prev-start)
            (insert cur-line))))
      ;; Re-position cursor on the moved line
      (goto-char (point-min))
      (while (and (not (eobp))
                  (not (equal (get-text-property (point) 'tiles-filepath) filepath)))
        (forward-line 1)))))

(defun tiles-notes-move-down ()
  "Move the selected note down one position in the dashboard."
  (interactive)
  (when (tiles--notes-has-expansion-p)
    (user-error "Collapse expanded note first (TAB)"))
  (let ((filepath (tiles--notes-current-file)))
    (unless filepath
      (user-error "No note on this line"))
    (let ((inhibit-read-only t)
          (cur-start (line-beginning-position))
          (cur-end (1+ (line-end-position))))
      ;; Find next note line
      (save-excursion
        (goto-char cur-end)
        (while (and (not (eobp)) (not (tiles--notes-on-note-line-p)))
          (forward-line 1))
        (when (tiles--notes-on-note-line-p)
          (let* ((next-end (1+ (line-end-position)))
                 (cur-line (buffer-substring cur-start cur-end)))
            (goto-char next-end)
            (insert cur-line)
            (delete-region cur-start cur-end))))
      ;; Re-position cursor on the moved line
      (goto-char (point-min))
      (while (and (not (eobp))
                  (not (equal (get-text-property (point) 'tiles-filepath) filepath)))
        (forward-line 1)))))

(defun tiles-notes-stitch ()
  "Stitch the currently displayed notes into a flowing view.
Respects manual display order set by M-up/M-down."
  (interactive)
  (let ((files (tiles--notes-displayed-files)))
    (if (not files)
        (message "No notes to stitch")
      (if (or tiles--notes-filter
              (yes-or-no-p "You should perform a search before stitching notes.  Are you sure you want to stitch ALL the notes? "))
          (progn
            (tiles-notes-quit)
            (setq tiles--current-search-results files)
            (tiles--show-stitched-view files))
        (message nil)))))

(defun tiles-notes-change-date ()
  "Change the date/time of the note on the current line.
Prompts for a new date in YYYY-MM-DD HH:MM or YYYY-MM-DD HH:MM:SS format."
  (interactive)
  (let ((filepath (tiles--notes-current-file)))
    (unless filepath
      (user-error "No note on this line"))
    (let* ((fname (file-name-nondirectory filepath))
           (current-ts (tiles--filename-to-timestamp fname))
           (new-ts (read-string "New date (YYYY-MM-DD HH:MM[:SS]): " current-ts))
           (parts (split-string new-ts "[-: ]" t)))
      (when (= (length parts) 5)
        (setq parts (append parts '("00"))))
      (unless (= (length parts) 6)
        (user-error "Invalid date format, use YYYY-MM-DD HH:MM or YYYY-MM-DD HH:MM:SS"))
      (let* ((new-fname (format "T%s%s%s%s%s%s.org"
                                (nth 0 parts) (nth 1 parts) (nth 2 parts)
                                (nth 3 parts) (nth 4 parts) (nth 5 parts)))
             (new-filepath (expand-file-name new-fname (file-name-directory filepath))))
        (when (and (file-exists-p new-filepath)
                   (not (equal filepath new-filepath)))
          (user-error "A note with that timestamp already exists"))
        (unless (equal filepath new-filepath)
          ;; If this file is open in the preview, close it first
          (when (equal tiles--preview-file filepath)
            (let* ((buf (get-file-buffer filepath))
                   (win (when buf (get-buffer-window buf))))
              (when win (delete-window win))
              (when buf (kill-buffer buf))
              (setq tiles--preview-file nil)))
          (rename-file filepath new-filepath)
          (tiles--evict-file filepath)
          ;; Refresh the dashboard
          (tiles-show-notes))))))

(defun tiles-notes-delete ()
  "Delete the note on the current line after confirmation."
  (interactive)
  (let ((filepath (tiles--notes-current-file)))
    (unless filepath
      (user-error "No note on this line"))
    (let* ((fname (file-name-nondirectory filepath))
           (note-data (tiles--parse-note-file filepath))
           (preview (if note-data
                        (truncate-string-to-width
                         (string-trim
                          (or (car (split-string
                                    (tiles--strip-private-paragraphs
                                     (plist-get note-data :content)) "\n" t))
                              ""))
                         60)
                      "")))
      (when (yes-or-no-p (format "Delete note %s (%s)? " fname preview))
        ;; Close preview if showing this file
        (when (equal tiles--preview-file filepath)
          (let* ((buf (get-file-buffer filepath))
                 (win (when buf (get-buffer-window buf))))
            (when win (delete-window win))
            (when buf (kill-buffer buf))
            (setq tiles--preview-file nil)))
        (delete-file filepath)
        (tiles--evict-file filepath)
        (tiles-show-notes)))))

(defun tiles--render-note-line (file)
  "Return a propertized string for FILE's line in the notes viewer."
  (let* ((fname (file-name-nondirectory file))
         (timestamp (tiles--filename-to-timestamp fname))
         (ts-face (tiles--timestamp-face fname))
         (note-data (tiles--parse-note-file file))
         (preview (if note-data (tiles--note-oneline-preview note-data) ""))
         (tags (when note-data (plist-get note-data :tags)))
         (tag-str (if (and tags (not (tiles--tags-inhibited-p)))
                      (concat "  " (mapconcat #'identity tags "/"))
                    ""))
         (preview-padded (if (< (length preview) tiles-preview-length)
                             (concat preview (make-string (- tiles-preview-length (length preview)) ?\s))
                           preview))
         (has-private (and (not tiles-preview-raw)
                           note-data
                           (let ((content (plist-get note-data :content)))
                             (seq-some #'tiles--private-paragraph-p
                                       (split-string (or content "") "\n\n+" t)))))
         (separator (if has-private
                        (concat " " (propertize "&" 'face 'tiles-tags))
                      "  ")))
    (let* ((line (concat (propertize "  " 'tiles-filepath file)
                         (propertize timestamp 'face ts-face 'tiles-filepath file)
                         (propertize separator 'tiles-filepath file)
                         (propertize preview-padded 'tiles-filepath file)
                         (propertize tag-str 'face 'tiles-tags 'tiles-filepath file)))
           (cur-len (length line)))
      (if (< cur-len tiles--line-target-width)
          (concat line (propertize (make-string (- tiles--line-target-width cur-len) ?\s)
                                   'tiles-filepath file))
        line))))

(defun tiles--refresh-note-line (filepath)
  "Refresh the line for FILEPATH in the notes viewer buffer."
  ;; Invalidate cache so we re-read from disk
  (remhash filepath tiles--cache)
  (let ((notes-buf (get-buffer tiles--notes-buffer-name)))
    (when notes-buf
      (with-current-buffer notes-buf
        (let ((inhibit-read-only t))
          (save-excursion
            (goto-char (point-min))
            (while (not (eobp))
              (when (equal (get-text-property (point) 'tiles-filepath) filepath)
                (let ((line-start (line-beginning-position))
                      (line-end (line-end-position)))
                  (delete-region line-start line-end)
                  (insert (tiles--render-note-line filepath)))
                (end-of-line))
              (forward-line 1))))))))

(defun tiles--after-save-hook ()
  "Hook to refresh the notes viewer after saving a tile file."
  (let ((filepath (buffer-file-name)))
    (when (and filepath
               (string-prefix-p (expand-file-name tiles-directory)
                                (expand-file-name filepath)))
      (tiles--refresh-note-line filepath))))

(defun tiles-notes-quit ()
  "Quit the notes viewer."
  (interactive)
  (remove-hook 'after-save-hook #'tiles--after-save-hook)
  (setq tiles--notes-filter nil)
  (setq tiles--notes-page 0)
  (let ((notes-buf (get-buffer tiles--notes-buffer-name)))
    (when notes-buf (kill-buffer notes-buf)))
  ;; Close the preview window but keep the file buffer (in case of unsaved edits)
  (when tiles--preview-file
    (let* ((buf (get-file-buffer tiles--preview-file))
           (win (when buf (get-buffer-window buf))))
      (when win (delete-window win)))
    (setq tiles--preview-file nil))
  (delete-other-windows))

;;; Similar Notes

(defcustom tiles-similar-count 5
  "Number of similar notes shown by `tiles-show-similar'."
  :type 'integer
  :group 'tiles)

(defcustom tiles-similar-keyword-weight 3.0
  "Score multiplier for shared tokens that are also `*bold*' in the target."
  :type 'number
  :group 'tiles)

(defcustom tiles-similar-min-token-length 3
  "Minimum length of a content token considered for similarity scoring."
  :type 'integer
  :group 'tiles)

(defvar-local tiles--similar-region-start nil
  "Start marker of the inserted similar-notes block, or nil if hidden.")

(defvar-local tiles--similar-region-end nil
  "End marker of the inserted similar-notes block, or nil if hidden.")

(defun tiles--tokenize-content (content)
  "Return a deduplicated list of lowercase tokens from CONTENT.
Strips org markup, splits on non-alphanumerics, and drops tokens
shorter than `tiles-similar-min-token-length'."
  (let ((text (downcase (tiles--strip-org-markup (or content ""))))
        tokens)
    (dolist (w (split-string text "[^[:alnum:]]+" t))
      (when (>= (length w) tiles-similar-min-token-length)
        (push w tokens)))
    (delete-dups tokens)))

(defun tiles--file-content-tokens (filepath)
  "Return content tokens for FILEPATH, cached by mtime."
  (let* ((mtime (file-attribute-modification-time
                 (file-attributes filepath)))
         (cached (gethash filepath tiles--content-token-cache)))
    (if (and cached (equal (car cached) mtime))
        (cdr cached)
      (let* ((data (tiles--parse-note-file filepath))
             (content (tiles--strip-private-paragraphs
                       (or (plist-get data :content) "")))
             (tokens (tiles--tokenize-content content)))
        (puthash filepath (cons mtime tokens) tiles--content-token-cache)
        tokens))))

(defun tiles--similar-notes (filepath)
  "Return up to `tiles-similar-count' files most similar to FILEPATH.
Scoring is TF-IDF over content tokens; tokens that are bold keywords
in the target get a `tiles-similar-keyword-weight' boost.
Each element is (SCORE . OTHER-FILEPATH), highest score first."
  (tiles--ensure-index)
  (let* ((target-tokens (tiles--file-content-tokens filepath))
         (target-kw-set (make-hash-table :test 'equal))
         (df (make-hash-table :test 'equal))
         (n (max 1 (hash-table-count tiles--file-index-record)))
         scored)
    (dolist (kw (plist-get (tiles--parse-note-file filepath) :keywords))
      (dolist (w (split-string (downcase kw) "[^[:alnum:]]+" t))
        (when (>= (length w) tiles-similar-min-token-length)
          (puthash w t target-kw-set))))
    (maphash
     (lambda (f _)
       (dolist (tok (tiles--file-content-tokens f))
         (when (member tok target-tokens)
           (puthash tok (1+ (gethash tok df 0)) df))))
     tiles--file-index-record)
    (maphash
     (lambda (f _)
       (unless (equal f filepath)
         (let ((score 0.0))
           (dolist (tok (tiles--file-content-tokens f))
             (when (member tok target-tokens)
               (let* ((d (gethash tok df 0))
                      (idf (log (/ (+ n 1.0) (+ d 1.0))))
                      (boost (if (gethash tok target-kw-set)
                                 tiles-similar-keyword-weight
                               1.0)))
                 (setq score (+ score (* boost idf))))))
           (when (> score 0)
             (push (cons score f) scored)))))
     tiles--file-index-record)
    (setq scored (sort scored (lambda (a b) (> (car a) (car b)))))
    (seq-take scored tiles-similar-count)))

(defun tiles--similar-clear ()
  "Remove the inserted similar-notes block from the current buffer."
  (when (and tiles--similar-region-start tiles--similar-region-end)
    (let ((inhibit-read-only t)
          (was-modified (buffer-modified-p)))
      (delete-region tiles--similar-region-start tiles--similar-region-end)
      (set-marker tiles--similar-region-start nil)
      (set-marker tiles--similar-region-end nil)
      (setq tiles--similar-region-start nil
            tiles--similar-region-end nil)
      (set-buffer-modified-p was-modified)
      (when font-lock-mode (font-lock-flush)))))

(defun tiles--similar-insert (filepath)
  "Append the top similar notes for FILEPATH to the current buffer.
Returns the number of notes inserted, or nil when none were found."
  (let ((matches (tiles--similar-notes filepath)))
    (when matches
      (let ((was-modified (buffer-modified-p))
            (inhibit-read-only t))
        (save-excursion
          (goto-char (point-max))
          (unless (bolp) (insert "\n"))
          (insert "\n")
          (setq tiles--similar-region-start (point-marker))
          (set-marker-insertion-type tiles--similar-region-start nil)
          (dolist (m matches)
            (let* ((f (cdr m))
                   (data (tiles--parse-note-file f))
                   (content (string-trim
                             (or (tiles--strip-private-paragraphs
                                  (plist-get data :content))
                                 ""))))
              (insert content "\n\n")))
          (setq tiles--similar-region-end (point-marker))
          (set-marker-insertion-type tiles--similar-region-end t))
        (set-buffer-modified-p was-modified)
        (when font-lock-mode (font-lock-flush))
        (length matches)))))

(defun tiles-show-similar ()
  "Toggle a block of notes similar to the current tile at end of buffer.
Scores notes by TF-IDF over shared content tokens; tokens that are
`*bold*' in the current note get a `tiles-similar-keyword-weight'
boost.  Press \\[tiles-show-similar] again to remove the block."
  (interactive)
  (let ((filepath (buffer-file-name)))
    (unless (and filepath (tiles--tile-file-p filepath))
      (user-error "Not visiting a tile file"))
    (if (and tiles--similar-region-start tiles--similar-region-end)
        (progn (tiles--similar-clear)
               (message "Similar notes hidden."))
      (let ((n (tiles--similar-insert filepath)))
        (if n
            (message "Showing %d similar %s."
                     n (if (= 1 n) "note" "notes"))
          (message "No similar notes found."))))))

(defun tiles-readonly-quit ()
  "Bury the current read-only tile buffer and delete its window."
  (interactive)
  (quit-window t))

(defvar tiles-readonly-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "m") #'tiles-show-similar)
    (define-key map (kbd "q") #'tiles-readonly-quit)
    map)
  "Keymap for `tiles-readonly-mode'.")

(define-minor-mode tiles-readonly-mode
  "Minor mode for read-only TILES note buffers.
Press \\<tiles-readonly-mode-map>\\[tiles-show-similar] to list notes similar to the current tile."
  :lighter " Tile-RO"
  :keymap tiles-readonly-mode-map)

;;; Search View Mode (Two-Panel)

(defvar tiles-search-view-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "n") #'tiles-search-next)
    (define-key map (kbd "p") #'tiles-search-prev)
    (define-key map (kbd "<down>") #'tiles-search-next)
    (define-key map (kbd "<up>") #'tiles-search-prev)
    (define-key map (kbd "RET") #'tiles-search-open)
    (define-key map (kbd "SPC") #'tiles-toggle-view)
    (define-key map (kbd "r") #'tiles-refine-search)
    (define-key map (kbd "t") #'tiles-switch-to-tag-search)
    (define-key map (kbd "k") #'tiles-switch-to-keyword-search)
    (define-key map (kbd "q") #'tiles-quit)
    map)
  "Keymap for `tiles-search-view-mode'.")

(define-derived-mode tiles-search-view-mode special-mode "Tiles-Search"
  "Major mode for TILES search results."
  (setq-local line-move-visual nil)
  (setq-local cursor-type 'box)
  (hl-line-mode 1))

(defun tiles--show-search-view (results)
  "Display RESULTS in two-panel search view."
  (delete-other-windows)
  ;; Create search results buffer (upper panel)
  (let ((search-buf (get-buffer-create tiles--search-buffer-name)))
    (switch-to-buffer search-buf)
    (let ((inhibit-read-only t))
      (erase-buffer)
      (dolist (file results)
        (insert (propertize (file-name-nondirectory file)
                            'tiles-filepath file)
                "\n"))
      (goto-char (point-min)))
    (tiles-search-view-mode)
    ;; Split for preview (lower panel)
    (split-window-below)
    (other-window 1)
    (switch-to-buffer (get-buffer-create tiles--preview-buffer-name))
    (org-mode)
    (setq buffer-read-only t)
    (other-window 1)
    ;; Update preview for first result
    (tiles--update-preview)))

(defun tiles--current-result-file ()
  "Get the filepath of the currently selected result."
  (get-text-property (line-beginning-position) 'tiles-filepath))

(defun tiles--update-preview ()
  "Update the preview buffer with the current selection."
  (let ((filepath (tiles--current-result-file)))
    (when filepath
      (let ((note-data (tiles--parse-note-file filepath))
            (preview-buf (get-buffer tiles--preview-buffer-name)))
        (when (and note-data preview-buf)
          (with-current-buffer preview-buf
            (let ((inhibit-read-only t))
              (erase-buffer)
              (insert (tiles--strip-private-paragraphs
                       (plist-get note-data :content)))
              (goto-char (point-min)))))))))

(defun tiles-search-next ()
  "Move to next search result."
  (interactive)
  (forward-line 1)
  (when (eobp)
    (forward-line -1))
  (tiles--update-preview))

(defun tiles-search-prev ()
  "Move to previous search result."
  (interactive)
  (forward-line -1)
  (tiles--update-preview))

(defun tiles-search-open ()
  "Open the selected note for editing."
  (interactive)
  (let ((filepath (tiles--current-result-file)))
    (when filepath
      (tiles-quit)
      (find-file filepath)
      (tiles--enable-tag-line-fontification))))

(defun tiles-toggle-view ()
  "Toggle between search view and stitched view."
  (interactive)
  (if tiles--current-search-results
      (tiles--show-stitched-view tiles--current-search-results)
    (message "No search results to display")))

(defun tiles-refine-search ()
  "Start a new search."
  (interactive)
  (let ((type tiles--current-search-type))
    (tiles-quit)
    (if (eq type 'keyword)
        (call-interactively #'tiles-keyword-search)
      (call-interactively #'tiles-tag-search))))

(defun tiles-switch-to-tag-search ()
  "Switch to tag search."
  (interactive)
  (tiles-quit)
  (call-interactively #'tiles-tag-search))

(defun tiles-switch-to-keyword-search ()
  "Switch to keyword search."
  (interactive)
  (tiles-quit)
  (call-interactively #'tiles-keyword-search))

(defun tiles-quit ()
  "Quit TILES view and clean up buffers."
  (interactive)
  (let ((search-buf (get-buffer tiles--search-buffer-name))
        (preview-buf (get-buffer tiles--preview-buffer-name))
        (stitched-buf (get-buffer tiles--stitched-buffer-name)))
    (when search-buf (kill-buffer search-buf))
    (when preview-buf (kill-buffer preview-buf))
    (when stitched-buf (kill-buffer stitched-buf)))
  (delete-other-windows))

;;; Stitched View Mode

(defvar tiles-stitched-view-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "n") #'tiles-stitched-next)
    (define-key map (kbd "p") #'tiles-stitched-prev)
    (define-key map (kbd "RET") #'tiles-stitched-open)
    (define-key map (kbd "e") #'tiles-stitched-open)
    (define-key map (kbd "SPC") #'tiles-stitched-toggle-view)
    (define-key map (kbd "r") #'tiles-refine-search)
    (define-key map (kbd "q") #'tiles-quit)
    map)
  "Keymap for `tiles-stitched-view-mode'.")

(define-derived-mode tiles-stitched-view-mode org-mode "Tiles-Stitched"
  "Major mode for TILES stitched view."
  (setq buffer-read-only t))

(defvar-local tiles--stitched-boundaries nil
  "List of (start-pos . filepath) for each note in stitched view.")

(defun tiles--show-stitched-view (results)
  "Display RESULTS in stitched view."
  (delete-other-windows)
  (let ((buf (get-buffer-create tiles--stitched-buffer-name))
        (boundaries nil))
    (switch-to-buffer buf)
    (let ((inhibit-read-only t))
      (erase-buffer)
      (dolist (file results)
        (let ((note-data (tiles--parse-note-file file)))
          (when note-data
            (push (cons (point) file) boundaries)
            (insert (tiles--strip-private-paragraphs
                     (plist-get note-data :content)))
            (insert "\n\n"))))
      (goto-char (point-min)))
    (tiles-stitched-view-mode)
    (setq tiles--stitched-boundaries (nreverse boundaries))
    ;; Kill other tiles buffers
    (let ((search-buf (get-buffer tiles--search-buffer-name))
          (preview-buf (get-buffer tiles--preview-buffer-name)))
      (when search-buf (kill-buffer search-buf))
      (when preview-buf (kill-buffer preview-buf)))))

(defun tiles--stitched-current-file ()
  "Get the filepath of the note at point in stitched view."
  (let ((pos (point))
        (result nil))
    (dolist (boundary tiles--stitched-boundaries)
      (when (<= (car boundary) pos)
        (setq result (cdr boundary))))
    result))

(defun tiles-stitched-next ()
  "Jump to next note boundary in stitched view."
  (interactive)
  (let ((pos (point))
        (next-pos nil))
    (dolist (boundary tiles--stitched-boundaries)
      (when (and (> (car boundary) pos) (not next-pos))
        (setq next-pos (car boundary))))
    (if next-pos
        (goto-char next-pos)
      (message "No more notes"))))

(defun tiles-stitched-prev ()
  "Jump to previous note boundary in stitched view."
  (interactive)
  (let ((pos (point))
        (prev-pos nil))
    ;; Boundaries are sorted ascending; stop at the first one >= pos.
    (catch 'done
      (dolist (boundary tiles--stitched-boundaries)
        (if (< (car boundary) pos)
            (setq prev-pos (car boundary))
          (throw 'done nil))))
    (if prev-pos
        (goto-char prev-pos)
      (message "At first note"))))

(defun tiles-stitched-open ()
  "Open the source file of the note at point."
  (interactive)
  (let ((filepath (tiles--stitched-current-file)))
    (if filepath
        (progn
          (tiles-quit)
          (find-file filepath)
          (goto-char (point-min))
          (tiles--enable-tag-line-fontification)
          (when tiles-focus-default
            (tiles-focus-mode 1)))
      (message "No note at point"))))

(defun tiles-stitched-toggle-view ()
  "Toggle back to search view from stitched view."
  (interactive)
  (if tiles--current-search-results
      (tiles--show-search-view tiles--current-search-results)
    (message "No search results to display")))

;;; Dynamic Blocks

(defun tiles--dblock-get-files (params)
  "Get filtered and sorted tile files based on PARAMS plist.
Supports :tags, :keywords, :sort (\"newest\" or \"oldest\"), and :limit."
  (let* ((tags (plist-get params :tags))
         (keywords (plist-get params :keywords))
         (sort-order (or (plist-get params :sort) "newest"))
         (limit (plist-get params :limit))
         (files (tiles--get-all-tile-files))
         (filtered
          (seq-filter
           (lambda (file)
             (let ((note-data (tiles--parse-note-file file)))
               (and note-data
                    (or (not tags)
                        (tiles--note-matches-tag-p
                         note-data (tiles--parse-tag-query tags)))
                    (or (not keywords)
                        (tiles--note-matches-keyword-p
                         note-data (tiles--parse-keyword-query keywords))))))
           files)))
    (when (equal sort-order "oldest")
      (setq filtered (nreverse filtered)))
    (if (and limit (> limit 0))
        (seq-take filtered limit)
      filtered)))

(defun org-dblock-write:tiles-notes (params)
  "Dynamic block: insert a list of matching TILES notes as org links.
PARAMS supports :tags, :keywords, :sort (\"newest\"/\"oldest\"), :limit."
  (let ((files (tiles--dblock-get-files params)))
    (if (not files)
        (insert "  N/A\n")
      (dolist (file files)
        (let* ((fname (file-name-nondirectory file))
               (timestamp (tiles--filename-to-timestamp fname))
               (note-data (tiles--parse-note-file file))
               (preview (if note-data
                            (let* ((content (tiles--strip-private-paragraphs
                                            (or (plist-get note-data :content) "")))
                                   (line (string-trim
                                          (or (car (split-string content "\n" t)) ""))))
                              (if (> (length line) 80)
                                  (concat (substring line 0 77) "...")
                                line))
                          ""))
               (tags (when note-data (plist-get note-data :tags)))
               (tag-str (if tags
                            (concat "  " (mapconcat #'identity tags "/"))
                          "")))
          (insert (format "- [[file:%s][%s]] %s%s\n" file timestamp preview tag-str)))))))

(defun org-dblock-write:tiles-files (params)
  "Dynamic block: embed the contents of matching TILES notes.
PARAMS supports :tags, :keywords, :sort (\"newest\"/\"oldest\"), :limit,
and :separator (string between notes, default blank line).
When `tiles-dblock-html-anchor' is non-nil, each note is preceded by an
`@@html:<a id=\"...\">@@' Org export snippet, unless the note has a tag
listed in `tiles-dblock-no-anchor-tags'."
  (let ((files (tiles--dblock-get-files params))
        (separator (or (plist-get params :separator) "\n")))
    (if (not files)
        (insert "N/A\n")
      (let ((first t))
        (dolist (file files)
          (let ((note-data (tiles--parse-note-file file)))
            (when note-data
              (if first
                  (setq first nil)
                (insert separator))
              (let ((anchor (file-name-sans-extension
                             (file-name-nondirectory file)))
                    (tags (plist-get note-data :tags)))
                (when (and tiles-dblock-html-anchor
                           (not (seq-some (lambda (tag)
                                            (member tag tiles-dblock-no-anchor-tags))
                                          tags)))
                  (insert (format "@@html:<a id=\"%s\" href=\"#%s\" class=\"paragraph-link\">&rarr;</a>@@\n"
                                  anchor anchor)))
                (insert (tiles--strip-private-paragraphs
                         (plist-get note-data :content)))
                (insert "\n")))))))))

;; Register dblocks for C-c C-x x insertion menu
(when (boundp 'org-dynamic-block-alist)
  (add-to-list 'org-dynamic-block-alist
               '("tiles-notes" . org-dblock-write:tiles-notes))
  (add-to-list 'org-dynamic-block-alist
               '("tiles-files" . org-dblock-write:tiles-files)))

;;; Keybindings

;;;###autoload
(defvar tiles-command-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "m") #'tiles-show-notes)
    (define-key map (kbd "n") #'tiles-new)
    (define-key map (kbd "q") #'tiles-quick)
    (define-key map (kbd "y") #'tiles-yank)
    (define-key map (kbd "t") #'tiles-tag-search)
    (define-key map (kbd "k") #'tiles-keyword-search)
    map)
  "Keymap for TILES commands, bound under `C-c m'.")

;; To bind the command map, add to your init.el:
;; (global-set-key (kbd "C-c m") tiles-command-map)

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