;;; fzfa-media-thumbnail.el --- Video thumbnails for `fzfa' -*- lexical-binding: t; -*-

;; Copyright (C) 2026 James Nguyen

;; Author: James Nguyen <james@jojojames.com>
;; Package-Requires: ((emacs "29.1"))
;; Homepage: https://github.com/jojojames/fzfa
;; Assisted-by: Claude:claude-opus-4-7
;; SPDX-License-Identifier: GPL-3.0-or-later

;;; Commentary:

;; Route file-preview for video files through `media-thumbnail' — a
;; cached JPEG generated by `ffmpeg' — instead of the raw byte-buffer
;; the default handler would render.
;;
;; Cache hit: the JPEG is inserted synchronously at the pane's target
;; pixel size.  Cache miss: `media-thumbnail-generate-async' spawns
;; ffmpeg and we show a "generating…" placeholder; the upstream
;; callback fires when the JPEG lands, and — if the user is still
;; hovering the same file — we swap in the real image.
;;
;; `media-thumbnail' is not `require'd at load time.  The dispatch
;; function loads it lazily on the first video hover and shows a
;; "please install media-thumbnail" placeholder when it is unavailable,
;; so the extension can ship without adding a hard dependency.

;;; Code:

(require 'fzfa)

(declare-function media-thumbnail-generate-async "media-thumbnail"
                  (file &key size ignore-aspect-ratio callback))
(declare-function media-thumbnail-get-cache-path "media-thumbnail" (file))
;; `cl-defun' with `&key' — `declare-function' arity check counts
;; keyword args wrong (sees each `:kw VAL' as two separate args).  Pass
;; `t' for the arglist to skip arity validation while still letting
;; `check-declare' confirm the function lives in `media-thumbnail'.
(declare-function media-thumbnail-preview-buffer "media-thumbnail" t)
(defvar media-thumbnail-video-exts)

(defcustom fzfa-media-thumbnail-video-exts
  '("mkv" "mp4" "webm" "mov" "avi" "m4v" "wmv" "flv" "ogv"
    "mpg" "mpeg" "3gp" "3gpp" "m2v" "m1v" "ts" "mts" "m2ts" "qt")
  "Fallback video extensions used before `media-thumbnail' is loaded.

Once `media-thumbnail' is available at dispatch time, its own
`media-thumbnail-video-exts' takes precedence so the two sources agree
on \"what counts as video\"."
  :type '(repeat string)
  :group 'fzfa)

(defcustom fzfa-media-thumbnail-fallback-size 800
  "Longest-edge pixel size passed to ffmpeg when the pane size cannot
be measured.

`fzfa-media-thumbnail--target-size' prefers the live posframe geometry
when `fzfa-posframe-mode' is active — this value is only consulted when
that measurement is unavailable (e.g. no graphical display, or the
posframe extension isn't loaded)."
  :type 'integer
  :group 'fzfa)

(defcustom fzfa-media-thumbnail-show-metadata t
  "Non-nil to prepend a one-line video-metadata header above the thumbnail.

The header shows basename, resolution, duration, codec, and file
size when the underlying `ffprobe' returns them.  Requires
`media-thumbnail-ffprobe-executable' on PATH — when ffprobe is
unavailable the header degrades to just the basename, and setting
this to nil skips even that (image-only preview).

Reads the same cached metadata as `media-thumbnail's own seek
chain, so enabling this on a session that already probed
percentages costs no extra subprocess per hover."
  :type 'boolean
  :group 'fzfa)

(defvar fzfa-media-thumbnail--preview-buffer " *fzfa-media-thumbnail*"
  "Shared buffer used to render the video thumbnail preview.")

(defvar fzfa-media-thumbnail--missing-buffer " *fzfa-media-thumbnail-missing*"
  "Shared buffer used for the \"install media-thumbnail\" placeholder.")

(declare-function fzfa-posframe--top-frame "fzfa-posframe" ())
(declare-function fzfa-posframe--pane-geometry "fzfa-posframe"
                  (parent &optional purpose))

(defun fzfa-media-thumbnail--pane-pixels ()
  "Return the preview pane's (WIDTH . HEIGHT) in pixels, or nil.

Reads `fzfa-posframe--pane-geometry' when `fzfa-posframe-mode' is
active on a graphical display; nil otherwise so callers can fall back
to their own defaults."
  (when (and (bound-and-true-p fzfa-posframe-mode)
             (display-graphic-p)
             (fboundp 'fzfa-posframe--top-frame)
             (fboundp 'fzfa-posframe--pane-geometry))
    (let* ((parent (fzfa-posframe--top-frame))
           (geom (fzfa-posframe--pane-geometry parent))
           (char-w (with-selected-frame parent (frame-char-width)))
           (char-h (with-selected-frame parent (default-line-height))))
      (cons (* (car geom) char-w) (* (cdr geom) char-h)))))

(defun fzfa-media-thumbnail--target-size ()
  "Return the target pixel size for the preview pane's thumbnail.

`media-thumbnail's ffmpeg pipeline scales with
`force_original_aspect_ratio=decrease', fitting the frame inside a
bounding box whose longest edge is this value; the caller's
`create-image' then applies `:max-width' + `:max-height' to downscale
further if needed.  Sizing against the pane's LONGEST edge means
landscape videos fill the pane's width and portrait videos fill the
pane's height, with letterboxing along the mismatched axis.

Falls back to `fzfa-media-thumbnail-fallback-size' when no posframe
pane can be measured."
  (if-let* ((pane (fzfa-media-thumbnail--pane-pixels)))
      (max (car pane) (cdr pane))
    fzfa-media-thumbnail-fallback-size))

(defun fzfa-media-thumbnail--video-p (path)
  "Return non-nil when PATH's extension names a video format."
  (when-let* ((ext (file-name-extension path)))
    (member (downcase ext)
            (or (and (featurep 'media-thumbnail)
                     (bound-and-true-p media-thumbnail-video-exts))
                fzfa-media-thumbnail-video-exts))))

(defun fzfa-media-thumbnail--missing-placeholder (path)
  "Return a placeholder buffer instructing the user to install `media-thumbnail'."
  (let ((buf (get-buffer-create fzfa-media-thumbnail--missing-buffer)))
    (with-current-buffer buf
      (setq-local buffer-read-only nil)
      (erase-buffer)
      (insert
       (propertize "media-thumbnail not installed\n\n" 'face 'warning)
       "Video preview for:\n  " path "\n\n"
       "Install the `media-thumbnail' package from MELPA and ensure\n"
       "`ffmpeg' is on your PATH.  Then restart Emacs and hover this\n"
       "file again.")
      (goto-char (point-min))
      (setq-local buffer-read-only t))
    buf))

(defun fzfa-media-thumbnail--generating-placeholder (path)
  "Return a \"thumbnail generating\" placeholder buffer for PATH."
  (let ((buf (get-buffer-create fzfa-media-thumbnail--preview-buffer))
        (inhibit-read-only t))
    (with-current-buffer buf
      (erase-buffer)
      (insert
       (propertize "Generating thumbnail…\n\n" 'face 'shadow)
       "  " path)
      (goto-char (point-min))
      (setq-local buffer-read-only t))
    buf))

(defun fzfa-media-thumbnail--image-buffer (path cache-path)
  "Return the shared preview buffer populated with PATH's thumbnail.

Thin wrapper over `media-thumbnail-preview-buffer' that supplies
the posframe pane's pixel dimensions as scaling constraints, pins
the shared buffer name, and forwards
`fzfa-media-thumbnail-show-metadata' as the header policy.

All formatting / image-insertion machinery lives upstream — this
extension owns only the fzfa-specific dispatch and geometry
adaptation."
  (let ((pane (fzfa-media-thumbnail--pane-pixels)))
    (media-thumbnail-preview-buffer
     path cache-path
     :buffer fzfa-media-thumbnail--preview-buffer
     :max-width (and pane (car pane))
     :max-height (and pane (cdr pane))
     :header fzfa-media-thumbnail-show-metadata)))

(defun fzfa-media-thumbnail--still-hovering-p (path session)
  "Return non-nil when SESSION's candidate resolves to PATH."
  (when-let* ((cand (fzfa--frontend-candidate)))
    (equal path (fzfa-resolve-candidate cand session))))

(defun fzfa-media-thumbnail--make-callback (path origin-win session)
  "Return a `media-thumbnail-generate-async' callback for PATH.

ORIGIN-WIN is used only as a liveness gate — SESSION is what determines
whether the user is still hovering PATH."
  (lambda (_file cache-path success-p)
    (when (and success-p
               (fzfa-media-thumbnail--still-hovering-p path session)
               (window-live-p origin-win))
      (fzfa-preview-show
       (fzfa-media-thumbnail--image-buffer path cache-path)))))

(defun fzfa-media-thumbnail--dispatch (path session)
  "Return a preview buffer for PATH when it names a video file.

Non-video paths return nil so `fzfa--file-preview' falls through to
its default text-buffer handler.  When `media-thumbnail' is not
installed, return a placeholder telling the user how to enable this.

Cache miss shows a \"generating…\" placeholder immediately and asks
`media-thumbnail-generate-async' to run ffmpeg at the current pane's
pixel size; the upstream callback swaps in the real image if the user
is still on PATH when it lands."
  (when (fzfa-media-thumbnail--video-p path)
    (cond
     ((not (require 'media-thumbnail nil t))
      (fzfa-media-thumbnail--missing-placeholder path))
     (t
      (let ((cache-path (media-thumbnail-get-cache-path path))
            (size (fzfa-media-thumbnail--target-size)))
        (cond
         ((file-exists-p cache-path)
          (fzfa-media-thumbnail--image-buffer path cache-path))
         (t
          (media-thumbnail-generate-async
           path
           :size size
           :callback (fzfa-media-thumbnail--make-callback
                      path
                      (fzfa-preview-get :origin-window)
                      session))
          (fzfa-media-thumbnail--generating-placeholder path))))))))

;;;###autoload
(defun fzfa-media-thumbnail-setup ()
  "Wire the video-thumbnail dispatcher into `fzfa--file-preview'.

Idempotent.  Invoked by `fzfa-setup' when `media-thumbnail' is listed
in `fzfa-extensions'."
  (add-hook 'fzfa-file-preview-dispatch-functions
            #'fzfa-media-thumbnail--dispatch))

(provide 'fzfa-media-thumbnail)

;; Local Variables:
;; package-lint-main-file: "fzfa.el"
;; End:

;;; fzfa-media-thumbnail.el ends here
