;;; git-overleaf-core.el --- Core helpers for git-overleaf -*- lexical-binding: t; -*-

;; Copyright (C) 2020-2026 Jamie Cui
;; Author: Jamie Cui <jamie.cui@outlook.com>
;; URL: https://github.com/Jamie-Cui/git-overleaf
;; Assisted-by: Codex:GPT-5.5
;; SPDX-License-Identifier: GPL-3.0-or-later
;; This file is not part of GNU Emacs.

;;; Commentary:

;; Core customization, logging, cookie, Git, and filesystem helpers.

;;; Code:

(require 'auth-source)
(require 'cl-lib)
(require 'git-overleaf-log)
(require 'subr-x)
(require 'url-parse)

(declare-function git-overleaf-authenticate "git-overleaf-auth")

;;;; Variables

(defcustom git-overleaf-cookie-storage 'authinfo
  "Where Overleaf cookies should be persisted across Emacs sessions.

If the value is the symbol `authinfo', cookies are stored in the first
file entry from `auth-sources', including encrypted `.gpg' files.  If
there is no file entry, `~/.authinfo.gpg' is used.

If the value is a string, it is treated as a plain file path where the
serialized cookie alist is stored.

If the value is nil, authenticated cookies stay only in memory for the
current Emacs session."
  :type '(choice
          (const :tag "Emacs auth-source file" authinfo)
          (file :tag "Plain file")
          (const :tag "Session only" nil))
  :group 'git-overleaf)

(defcustom git-overleaf-cookies #'git-overleaf--load-configured-cookies
  "The Overleaf session cookies.

Can either be:

- an alist mapping domain keys to `(COOKIE-STRING EXPIRY)'
- a string containing either that serialized alist or a raw Cookie header
- a function returning one of those values

The default value follows `git-overleaf-cookie-storage'.  Override this
variable directly only when you want to supply cookies manually or use a
custom loader.

The cookies are usually obtained and refreshed via
`git-overleaf-authenticate'."
  :type '(choice sexp string function)
  :group 'git-overleaf)


(defcustom git-overleaf-url "https://www.overleaf.com"
  "The URL of the Overleaf server."
  :type 'string
  :group 'git-overleaf)

(defcustom git-overleaf-auth-backend 'webdriver
  "Backend used by `git-overleaf-authenticate' to obtain cookies."
  :type '(choice
          (const :tag "Selenium webdriver" webdriver)
          (const :tag "Import from Firefox profile" firefox-cookies))
  :group 'git-overleaf)

(defcustom git-overleaf-git-executable "git"
  "Git executable used for project operations."
  :type 'string
  :group 'git-overleaf)

(defcustom git-overleaf-curl-executable "curl"
  "Curl executable used for project download and upload."
  :type 'string
  :group 'git-overleaf)

(defconst git-overleaf--curl-connect-timeout 10
  "Seconds to wait while establishing Overleaf curl connections.")

(defconst git-overleaf--curl-max-time 45
  "Maximum seconds to allow one non-download Overleaf curl request to run.")

(defconst git-overleaf--curl-download-max-time nil
  "Maximum seconds to allow one Overleaf project zip download to run.")

(defconst git-overleaf--curl-download-speed-limit 1024
  "Minimum bytes per second expected during project zip downloads.")

(defconst git-overleaf--curl-download-speed-time 30
  "Seconds a project zip download may remain below the low-speed limit.")

(defcustom git-overleaf-unzip-executable "unzip"
  "Unzip executable used to unpack downloaded projects."
  :type 'string
  :group 'git-overleaf)

(defcustom git-overleaf-base-ref "refs/git-overleaf/base"
  "Git ref that stores the last successfully synchronized snapshot."
  :type 'string
  :group 'git-overleaf)

(defcustom git-overleaf-remote-ref "refs/git-overleaf/remote"
  "Git ref that stores the latest fetched Overleaf snapshot."
  :type 'string
  :group 'git-overleaf)

(defcustom git-overleaf-remote-name "overleaf"
  "Default name of the branchless logical Overleaf remote.

The remote has no URL and is intended for git-overleaf clients and
Magit integration, not for direct Git transport commands."
  :type 'string
  :group 'git-overleaf)

(defcustom git-overleaf-sync-metadata-enabled t
  "Whether to maintain a remote Overleaf sync metadata file.

When enabled, push commands upload `git-overleaf-sync-metadata-file'
to the Overleaf project root.  Downloaded snapshots read and remove that
file before Git comparisons, so it acts as remote bookkeeping rather
than project content."
  :type 'boolean
  :group 'git-overleaf)

(defcustom git-overleaf-sync-metadata-file ".git-overleaf-sync.json"
  "Root-level Overleaf file used to remember the last uploaded Git commit.

This file name is reserved by `git-overleaf' when
`git-overleaf-sync-metadata-enabled' is non-nil.  It is stored on the
Overleaf remote, but should not be tracked by the local Git repository."
  :type 'string
  :group 'git-overleaf)

(defcustom git-overleaf-local-backups-enabled t
  "Whether to create local backup refs before mutating Overleaf sync steps.

When enabled, operations that may move the current branch or complete a
pending sync first create refs under
`git-overleaf-local-backup-ref-prefix'.  These refs keep the previous
local commits reachable even if a later merge, fast-forward, or branch
update does not produce the expected result."
  :type 'boolean
  :group 'git-overleaf)

(defcustom git-overleaf-local-backup-ref-prefix "refs/git-overleaf/backups"
  "Git ref namespace used for local Overleaf safety backups."
  :type 'string
  :group 'git-overleaf)

(defcustom git-overleaf-socket-timeout 15
  "Seconds to wait for the Overleaf project tree websocket response."
  :type 'integer
  :group 'git-overleaf)

(defcustom git-overleaf-enable-async nil
  "Whether Overleaf commands may run long operations in the background.

When nil, commands run synchronously by default.

When non-nil, interactive commands such as `git-overleaf-clone',
`git-overleaf-push', and `git-overleaf-pull' collect necessary
user input in the foreground, then run network, unzip, and Git work on a
background Emacs thread.  Hook-driven pushes may also run in the
background.  Lisp callers that invoke these commands noninteractively
keep the synchronous behavior unless documented otherwise."
  :type 'boolean
  :group 'git-overleaf)

(defcustom git-overleaf-auth-session-cookie-regexp
  "\\`\\(?:overleaf_session[[:alnum:]_]*\\|sharelatex_session[[:alnum:]_]*\\|sharelatex\\.sid\\|connect\\.sid\\|sessionid\\|session\\)\\'"
  "Regexp matching cookie names that represent the authenticated session.

This is used only for local expiry checks after `git-overleaf-authenticate'.
Short-lived analytics or load-balancer cookies are intentionally ignored,
because their expiry is often much earlier than the actual login session."
  :type 'regexp
  :group 'git-overleaf)

(defvar git-overleaf-save-cookies #'git-overleaf--save-configured-cookies
  "Function storing a freshly authenticated cookie string.

The function receives a string containing the session cookies and stores
them in a way that `git-overleaf-cookies' can later access.

The default implementation follows `git-overleaf-cookie-storage'.  Override
this variable directly only when you want custom persistence logic.")

(defvar git-overleaf--current-cookies nil
  "Cached cookie alist returned from `git-overleaf-cookies'.")

(defvar git-overleaf--csrf-cache (make-hash-table :test #'equal)
  "Cache of csrf tokens keyed by \"URL|PROJECT-ID\".")

(defvar git-overleaf--remote-sync-metadata nil
  "Sync metadata extracted from the currently downloaded remote snapshot.")

(defvar git-overleaf--remote-sync-metadata-text nil
  "Raw sync metadata text extracted from the current remote snapshot.")

(cl-defstruct git-overleaf--command-result
  "Result of an external command."
  status
  output)

(cl-defstruct git-overleaf--entity
  "A remote Overleaf project entity."
  path
  name
  id
  type
  parent-id)

(cl-defstruct git-overleaf--snapshot
  "A downloaded Overleaf project snapshot."
  temp-dir
  root)

(cl-defstruct git-overleaf--repo-status
  "Parsed `git status --porcelain' state."
  lines
  staged
  unstaged
  unmerged)

(cl-defstruct git-overleaf--async-completion
  "Completed background Overleaf operation."
  task-id
  name
  key
  status
  value
  error
  on-success
  on-error
  default-directory
  git-overleaf-url
  log-context)

;;;; Async helpers

(cl-defstruct git-overleaf--async-task
  "Running background Overleaf operation."
  id
  name
  key
  thread
  processes
  canceled)

(defvar git-overleaf--async-locks (make-hash-table :test #'equal)
  "Background operation locks keyed by repository or task identity.")

(defvar git-overleaf--async-tasks (make-hash-table :test #'eql)
  "Running background operations keyed by internal task id.")

(defvar git-overleaf--async-canceled-task-ids (make-hash-table :test #'eql)
  "Ids of canceled background operations that may still unwind later.")

(defvar git-overleaf--async-next-task-id 0
  "Last internal id assigned to a background operation.")

(defvar git-overleaf--async-completions nil
  "Completed background operations waiting for foreground callbacks.")

(defvar git-overleaf--async-current-task-id nil
  "Internal id of the background operation running in this thread.")

(defvar git-overleaf--async-mutex
  (and (fboundp 'make-mutex)
       (make-mutex "git-overleaf-async"))
  "Mutex protecting `git-overleaf--async-completions'.")

(defvar git-overleaf--async-timer nil
  "Timer that drains completed background operations.")

(defvar git-overleaf--after-operation-functions nil
  "Functions called after successful repository operations.

Each function receives the repository root and an operation symbol such
as `fetch', `pull', `push', `reset', or `pull-abort'.")

(defun git-overleaf--repo-async-key (repo)
  "Return the async operation lock key for REPO."
  (format "repo:%s" (directory-file-name (expand-file-name repo))))

(defun git-overleaf--async-key-active-p (key)
  "Return non-nil when an asynchronous operation holds KEY."
  (and key (gethash key git-overleaf--async-locks)))

(defmacro git-overleaf--with-async-mutex (&rest body)
  "Run BODY while holding the async completion mutex."
  (declare (indent 0) (debug t))
  `(if git-overleaf--async-mutex
       (progn
         (mutex-lock git-overleaf--async-mutex)
         (unwind-protect
             (progn ,@body)
           (mutex-unlock git-overleaf--async-mutex)))
     ,@body))

(defun git-overleaf--async-supported-p ()
  "Return non-nil if background Emacs threads are available."
  (fboundp 'make-thread))

(defun git-overleaf--async-enabled-p ()
  "Return non-nil when Overleaf operations may start background work."
  (and git-overleaf-enable-async
       (not noninteractive)
       (git-overleaf--async-supported-p)))

(defun git-overleaf--async-next-task-id ()
  "Return the next internal async task id."
  (setq git-overleaf--async-next-task-id
        (1+ git-overleaf--async-next-task-id)))

(defun git-overleaf--async-lock-empty-p ()
  "Return non-nil if there are no active async locks."
  (let ((empty t))
    (maphash (lambda (_key _value)
               (setq empty nil))
             git-overleaf--async-locks)
    empty))

(defun git-overleaf--async-task-empty-p ()
  "Return non-nil if there are no tracked async tasks."
  (let ((empty t))
    (maphash (lambda (_key _value)
               (setq empty nil))
             git-overleaf--async-tasks)
    empty))

(defun git-overleaf--async-live-task-p (task)
  "Return non-nil if TASK still has live work."
  (or (and (git-overleaf--async-task-thread task)
           (thread-live-p (git-overleaf--async-task-thread task)))
      (cl-some (lambda (process)
                 (and (processp process)
                      (process-live-p process)))
               (git-overleaf--async-task-processes task))))

(defun git-overleaf--async-register-task (task)
  "Register TASK as running background work."
  (puthash (git-overleaf--async-task-id task)
           task
           git-overleaf--async-tasks))

(defun git-overleaf--async-remove-task (task-id)
  "Remove TASK-ID from running background work."
  (remhash task-id git-overleaf--async-tasks))

(defun git-overleaf--async-current-task ()
  "Return the task currently running in this thread, or nil."
  (and git-overleaf--async-current-task-id
       (gethash git-overleaf--async-current-task-id
                git-overleaf--async-tasks)))

(defun git-overleaf--async-current-task-canceled-p ()
  "Return non-nil if the current background task has been canceled."
  (or (and git-overleaf--async-current-task-id
           (gethash git-overleaf--async-current-task-id
                    git-overleaf--async-canceled-task-ids))
      (when-let* ((task (git-overleaf--async-current-task)))
        (git-overleaf--async-task-canceled task))))

(defun git-overleaf--async-register-process (process)
  "Register PROCESS under the current background task."
  (when-let* (((processp process))
              (task (git-overleaf--async-current-task)))
    (push process (git-overleaf--async-task-processes task)))
  process)

(defun git-overleaf--async-unregister-process (process)
  "Unregister PROCESS from the current background task."
  (when-let* (((processp process))
              (task (git-overleaf--async-current-task)))
    (setf (git-overleaf--async-task-processes task)
          (delq process (git-overleaf--async-task-processes task)))))

(defun git-overleaf--async-cancel-task (task)
  "Cancel TASK and any external processes it started."
  (setf (git-overleaf--async-task-canceled task) t)
  (dolist (process (git-overleaf--async-task-processes task))
    (when (and (processp process)
               (process-live-p process))
      (ignore-errors (interrupt-process process))
      (when (process-live-p process)
        (ignore-errors (kill-process process)))
      (when (process-live-p process)
        (ignore-errors (delete-process process)))))
  (when-let* ((thread (git-overleaf--async-task-thread task)))
    (when (and (thread-live-p thread)
               (not (eq thread (current-thread))))
      (ignore-errors
        (thread-signal thread
                       'quit
                       (list (format "Canceled %s"
                                     (git-overleaf--async-task-name task))))))))

(defun git-overleaf--async-cancel-completion-p (completion)
  "Return non-nil if COMPLETION belongs to a canceled task."
  (when-let* ((task-id (git-overleaf--async-completion-task-id completion)))
    (let ((task (gethash task-id git-overleaf--async-tasks))
          (canceled (gethash task-id git-overleaf--async-canceled-task-ids)))
      (prog1 (or canceled
                 (and task (git-overleaf--async-task-canceled task)))
        (git-overleaf--async-remove-task task-id)
        (remhash task-id git-overleaf--async-canceled-task-ids)))))

(defun git-overleaf--async-ensure-timer ()
  "Ensure the async completion drain timer is running."
  (unless (timerp git-overleaf--async-timer)
    (setq git-overleaf--async-timer
          (run-at-time 0.1 0.1 #'git-overleaf--async-drain-completions))))

(defun git-overleaf--async-stop-timer-if-idle ()
  "Stop the async completion timer when no background work remains."
  (when (and (timerp git-overleaf--async-timer)
             (null git-overleaf--async-completions)
             (git-overleaf--async-lock-empty-p)
             (git-overleaf--async-task-empty-p))
    (cancel-timer git-overleaf--async-timer)
    (setq git-overleaf--async-timer nil)))

(defun git-overleaf--async-push-completion (completion)
  "Queue COMPLETION for foreground handling."
  (git-overleaf--with-async-mutex
    (push completion git-overleaf--async-completions)))

(defun git-overleaf--async-pop-completions ()
  "Return queued async completions in completion order."
  (git-overleaf--with-async-mutex
    (prog1 (nreverse git-overleaf--async-completions)
      (setq git-overleaf--async-completions nil))))

(defun git-overleaf--async-drain-completions ()
  "Run foreground callbacks for completed async operations."
  (dolist (completion (git-overleaf--async-pop-completions))
    (unless (git-overleaf--async-cancel-completion-p completion)
      (let ((key (git-overleaf--async-completion-key completion)))
        (when key
          (remhash key git-overleaf--async-locks)))
      (let ((default-directory
             (or (git-overleaf--async-completion-default-directory completion)
                 default-directory))
            (git-overleaf-url
             (or (git-overleaf--async-completion-git-overleaf-url completion)
                 git-overleaf-url))
            (git-overleaf-log-context
             (or (git-overleaf--async-completion-log-context completion)
                 git-overleaf-log-context)))
        (condition-case err
            (pcase (git-overleaf--async-completion-status completion)
              ('success
               (if-let* ((callback
                          (git-overleaf--async-completion-on-success completion)))
                   (funcall callback
                            (git-overleaf--async-completion-value completion))
                 (git-overleaf--message "Finished %s"
					                        (git-overleaf--async-completion-name
					                         completion))))
              ('error
               (let ((message
                      (git-overleaf--async-completion-error completion)))
                 (if-let* ((callback
                            (git-overleaf--async-completion-on-error completion)))
                     (funcall callback message)
                   (git-overleaf--warn "%s failed: %s"
					                       (git-overleaf--async-completion-name completion)
					                       message)))))
          (error
           (git-overleaf--warn "%s callback failed: %s"
				                   (git-overleaf--async-completion-name completion)
				                   (error-message-string err)))))))
  (git-overleaf--async-stop-timer-if-idle))

(defun git-overleaf--force-stop ()
  "Stop all running background Overleaf operations."
  (let ((tasks nil))
    (maphash (lambda (_id task)
               (push task tasks))
             git-overleaf--async-tasks)
    (unless tasks
      (user-error "No Overleaf background operation is running"))
    (dolist (task tasks)
      (let ((task-id (git-overleaf--async-task-id task)))
        (puthash task-id t git-overleaf--async-canceled-task-ids)
        (git-overleaf--async-cancel-task task)
        (unless (git-overleaf--async-live-task-p task)
          (remhash task-id git-overleaf--async-canceled-task-ids))))
    (clrhash git-overleaf--async-locks)
    (clrhash git-overleaf--async-tasks)
    (setq git-overleaf--async-completions nil)
    (when (timerp git-overleaf--async-timer)
      (cancel-timer git-overleaf--async-timer)
      (setq git-overleaf--async-timer nil))
    (git-overleaf--message
     "Stopped %d Overleaf background operation%s"
     (length tasks)
     (if (= (length tasks) 1) "" "s"))))

(cl-defun git-overleaf--async-start
    (name function &key key on-success on-error quiet)
  "Run FUNCTION as background operation NAME.

When KEY is non-nil, only one operation with that key may run at a
time.  ON-SUCCESS receives FUNCTION's return value in the foreground.
ON-ERROR receives an error message string in the foreground."
  (if (not (git-overleaf--async-enabled-p))
      (condition-case err
          (let ((value (funcall function)))
            (when on-success
              (funcall on-success value))
            value)
        (error
         (let ((message (error-message-string err)))
           (if on-error
               (funcall on-error message)
             (signal (car err) (cdr err))))))
    (when (git-overleaf--async-key-active-p key)
      (user-error "Overleaf operation already running: %s"
                  (gethash key git-overleaf--async-locks)))
    (when key
      (puthash key name git-overleaf--async-locks))
    (let* ((task-id (git-overleaf--async-next-task-id))
           (task (make-git-overleaf--async-task
                  :id task-id
                  :name name
                  :key key))
           (captured-default-directory default-directory)
           (captured-git-overleaf-url git-overleaf-url)
           (captured-current-cookies git-overleaf--current-cookies)
           (captured-log-context (git-overleaf-log-current-context))
           (captured-process-environment process-environment))
      (git-overleaf--async-ensure-timer)
      (unless quiet
        (git-overleaf--message "Started %s in background" name))
      (git-overleaf--async-register-task task)
      (setf (git-overleaf--async-task-thread task)
            (make-thread
             (lambda ()
               (let ((default-directory captured-default-directory)
                     (git-overleaf-url captured-git-overleaf-url)
                     (git-overleaf--current-cookies captured-current-cookies)
                     (git-overleaf-log-context captured-log-context)
                     (process-environment captured-process-environment)
                     (git-overleaf--async-current-task-id task-id))
                 (unwind-protect
                     (condition-case err
                         (let ((value (funcall function)))
                           (unless (git-overleaf--async-current-task-canceled-p)
                             (git-overleaf--async-push-completion
                              (make-git-overleaf--async-completion
                               :task-id task-id
                               :name name
                               :key key
                               :status 'success
                               :value value
                               :on-success on-success
                               :on-error on-error
                               :default-directory captured-default-directory
                               :git-overleaf-url captured-git-overleaf-url
                               :log-context captured-log-context))))
                       (quit nil)
                       (error
                        (unless (git-overleaf--async-current-task-canceled-p)
                          (git-overleaf--async-push-completion
                           (make-git-overleaf--async-completion
                            :task-id task-id
                            :name name
                            :key key
                            :status 'error
                            :error (error-message-string err)
                            :on-success on-success
                            :on-error on-error
                            :default-directory captured-default-directory
                            :git-overleaf-url captured-git-overleaf-url
                            :log-context captured-log-context)))))
                   (when (git-overleaf--async-current-task-canceled-p)
                     (remhash task-id git-overleaf--async-canceled-task-ids)
                     (git-overleaf--async-remove-task task-id)))))
             name))
      task)))

;;;; Cookie helpers

(defconst git-overleaf--authinfo-default-source "~/.authinfo.gpg"
  "Fallback authinfo file used by Overleaf cookie helpers.")

(defconst git-overleaf--authinfo-default-user "git-overleaf"
  "Default authinfo login used for Overleaf cookie helpers.")

(defconst git-overleaf--authinfo-default-port "git-overleaf-cookie"
  "Default authinfo port used for Overleaf cookie helpers.")

(defconst git-overleaf--authinfo-record-marker "git-overleaf-cookie-record"
  "Marker key used for authinfo entries managed by this package.")

(defun git-overleaf--authinfo-source-file (&optional source)
  "Return the expanded authinfo SOURCE file path."
  (expand-file-name
   (or source
       (cl-find-if #'stringp auth-sources)
       git-overleaf--authinfo-default-source)))

(defun git-overleaf--authinfo-resolve-host (&optional host)
  "Return the authinfo host key for HOST or the current Overleaf host."
  (downcase (or host (git-overleaf--url-host))))

(defun git-overleaf--authinfo-resolve-user (&optional user)
  "Return the authinfo login key for USER."
  (or user git-overleaf--authinfo-default-user))

(defun git-overleaf--authinfo-resolve-port (&optional port)
  "Return the authinfo port key for PORT."
  (or port git-overleaf--authinfo-default-port))

(defun git-overleaf--authinfo-format-value (value)
  "Return VALUE formatted as one authinfo token."
  (if (string-match-p "[[:space:]\"#]" value)
      (format "%S" value)
    value))

(defun git-overleaf--authinfo-format-field (name value)
  "Return one authinfo field string for NAME and VALUE."
  (format "%s %s" name (git-overleaf--authinfo-format-value value)))

(defun git-overleaf--authinfo-encode-secret (secret)
  "Encode serialized cookie SECRET for authinfo storage."
  (base64-encode-string secret t))

(defun git-overleaf--authinfo-decode-secret (secret)
  "Decode serialized cookie SECRET loaded from authinfo."
  (condition-case nil
      (base64-decode-string secret)
    (error secret)))

(defun git-overleaf--authinfo-entry-line (host user port secret)
  "Return one authinfo line storing SECRET for HOST, USER, and PORT."
  (string-join
   (list (git-overleaf--authinfo-format-field "machine" host)
         (git-overleaf--authinfo-format-field "login" user)
         (git-overleaf--authinfo-format-field "port" port)
         (git-overleaf--authinfo-format-field
          "password"
          (git-overleaf--authinfo-encode-secret secret))
         (git-overleaf--authinfo-format-field git-overleaf--authinfo-record-marker "t"))
   " "))

(defun git-overleaf--authinfo-entry-regexp (host user port)
  "Return a regexp matching a managed authinfo entry.
HOST, USER, and PORT are the authinfo machine, login, and port values
to match."
  (format "^machine %s login %s port %s password .* %s t$"
          (regexp-quote (git-overleaf--authinfo-format-value host))
          (regexp-quote (git-overleaf--authinfo-format-value user))
          (regexp-quote (git-overleaf--authinfo-format-value port))
          (regexp-quote git-overleaf--authinfo-record-marker)))

(defun git-overleaf--authinfo-read-secret (source host user port)
  "Read the Overleaf cookie secret from authinfo SOURCE.
HOST, USER, and PORT identify the machine, login, and port entry to
read."
  (let ((file (git-overleaf--authinfo-source-file source)))
    (when (file-readable-p file)
      (let* ((auth-sources (list file))
             (entry (car (auth-source-search
                          :max 1
                          :host (git-overleaf--authinfo-resolve-host host)
                          :user (git-overleaf--authinfo-resolve-user user)
                          :port (git-overleaf--authinfo-resolve-port port)
                          :require '(:secret)))))
        (when entry
          (git-overleaf--authinfo-decode-secret
           (auth-info-password entry)))))))

(defun git-overleaf--authinfo-write-secret (source host user port secret)
  "Write SECRET to authinfo SOURCE for HOST, USER, and PORT."
  (let* ((file (git-overleaf--authinfo-source-file source))
         (resolved-host (git-overleaf--authinfo-resolve-host host))
         (resolved-user (git-overleaf--authinfo-resolve-user user))
         (resolved-port (git-overleaf--authinfo-resolve-port port))
         (regexp (git-overleaf--authinfo-entry-regexp
                  resolved-host
                  resolved-user
                  resolved-port))
         (line (git-overleaf--authinfo-entry-line
                resolved-host
                resolved-user
                resolved-port
                secret)))
    (with-temp-buffer
      (let ((existing
             (progn
               (when (file-readable-p file)
                 (insert-file-contents file)
                 (goto-char (point-min))
                 (flush-lines regexp))
               (replace-regexp-in-string "\\`\n+" "" (buffer-string)))))
        ;; Keep this buffer's `epa-file-encrypt-to' value.  Reading an
        ;; existing .gpg file records its recipients buffer-locally, so the
        ;; following write re-encrypts to the same keys without prompting.
        (erase-buffer)
        (insert line)
        (unless (string-empty-p existing)
          (insert "\n" existing))
        (write-region nil nil file nil 'silent)))
    (set-file-modes file #o600)
    (auth-source-forget+ :host resolved-host :user resolved-user :port resolved-port)))

(defun git-overleaf--load-configured-cookies ()
  "Load cookies according to `git-overleaf-cookie-storage'."
  (pcase git-overleaf-cookie-storage
    ('authinfo
     (git-overleaf--authinfo-read-secret nil nil nil nil))
    ((pred stringp)
     (with-temp-buffer
       (insert-file-contents (expand-file-name git-overleaf-cookie-storage))
       (read (string-trim (buffer-string)))))
    (_ nil)))

(defun git-overleaf--save-configured-cookies (cookies)
  "Persist COOKIES according to `git-overleaf-cookie-storage'."
  (pcase git-overleaf-cookie-storage
    ('authinfo
     (git-overleaf--authinfo-write-secret nil nil nil nil cookies))
    ((pred stringp)
     (with-temp-file (expand-file-name git-overleaf-cookie-storage)
       (insert cookies)))
    (_ nil)))

(defconst git-overleaf--cookie-expiry-millisecond-threshold 100000000000
  "Cookie expiry values at or above this are Unix milliseconds.")

(defun git-overleaf--cookie-expiry-seconds (expiry)
  "Return cookie EXPIRY normalized to Unix seconds.
Older Firefox profiles and webdriver use seconds, while newer Firefox
profiles store cookie expiry timestamps in milliseconds."
  (if (and (integerp expiry)
           (>= expiry git-overleaf--cookie-expiry-millisecond-threshold))
      (/ expiry 1000)
    expiry))

(defun git-overleaf--normalize-cookie-entry (entry)
  "Normalize one cookie ENTRY into `(DOMAIN COOKIE-STRING EXPIRY)'."
  (pcase entry
    (`(,domain ,cookie-string ,expiry)
     (unless (and (stringp domain)
                  (stringp cookie-string)
                  (or (null expiry) (integerp expiry)))
       (error "Invalid Overleaf cookie entry: %S" entry))
     (list (downcase domain)
           cookie-string
           (git-overleaf--cookie-expiry-seconds expiry)))
    (`(,domain ,cookie-string)
     (unless (and (stringp domain) (stringp cookie-string))
       (error "Invalid Overleaf cookie entry: %S" entry))
     (list (downcase domain) cookie-string nil))
    (_
     (error "Invalid Overleaf cookie entry: %S" entry))))

(defun git-overleaf--normalize-full-cookies (cookies)
  "Return a normalized cookie alist from COOKIES."
  (cond
   ((null cookies) nil)
   ((stringp cookies)
    (let ((trimmed (string-trim cookies)))
      (cond
       ((string-empty-p trimmed) nil)
       ((string-prefix-p "(" trimmed)
        (condition-case err
            (git-overleaf--normalize-full-cookies
             (car (read-from-string trimmed)))
          (error
           (error "Could not parse serialized Overleaf cookies: %s"
                  (error-message-string err)))))
       (t
        (list (list (git-overleaf--cookie-domain) trimmed nil))))))
   ((listp cookies)
    (mapcar #'git-overleaf--normalize-cookie-entry cookies))
   (t
    (error "Unsupported value for `git-overleaf-cookies': %S" cookies))))

(defun git-overleaf--get-full-cookies ()
  "Load the association list mapping domains to cookies."
  (if git-overleaf--current-cookies
      git-overleaf--current-cookies
    (condition-case err
        (setq git-overleaf--current-cookies
              (git-overleaf--normalize-full-cookies
               (if (functionp git-overleaf-cookies)
                   (funcall git-overleaf-cookies)
                 git-overleaf-cookies)))
      (error
       (git-overleaf--warn "Error while loading cookies: %s"
			                   (error-message-string err))
       nil))))

(defun git-overleaf--get-cookies ()
  "Load cookies from `git-overleaf-cookies'."
  (let ((state (git-overleaf--cookie-state)))
    (pcase (plist-get state :status)
      ('valid
       (plist-get state :value))
      ('expired
       (user-error
        "Cookies for %s are expired.  Refresh them with `git-overleaf-authenticate' or manually"
        (git-overleaf--url-host)))
      (_
       (user-error
        "Cookies for %s are not set.  Configure them with `git-overleaf-authenticate' or manually"
        (git-overleaf--url-host))))))

(defun git-overleaf--cookie-state ()
  "Return the local cookie state for the current `git-overleaf-url'.
The result is a plist with `:status' set to one of `valid',
`missing', or `expired'.  For `valid', `:value' contains the cookie
header string.  This only inspects locally available cookie data and
does not contact the Overleaf server."
  (let* ((entry
          (cl-some
           (lambda (domain)
             (alist-get
              domain
              (git-overleaf--get-full-cookies)
              nil
              nil
              #'string=))
           (git-overleaf--cookie-key-candidates)))
         (now (time-convert nil 'integer)))
    (if entry
        (pcase-let ((`(,value ,raw-validity) entry))
          (let ((validity
                 (git-overleaf--cookie-expiry-seconds raw-validity)))
            (if (or (not validity) (< now validity))
                `(:status valid :value ,value :validity ,validity)
              (setq git-overleaf--current-cookies nil)
              `(:status expired :validity ,validity))))
      (setq git-overleaf--current-cookies nil)
      '(:status missing))))

(defun git-overleaf--authentication-needed-reason (&optional state)
  "Return a user-facing reason when cookie STATE is not valid."
  (let* ((host (git-overleaf--url-host))
         (status (plist-get (or state (git-overleaf--cookie-state)) :status)))
    (pcase status
      ('expired
       (format
        "Cookies for %s are expired according to the locally saved expiry time."
        host))
      ('missing
       (format "Cookies for %s are not set locally." host))
      (_ nil))))

(defun git-overleaf--ensure-authenticated (&optional op-desc)
  "Ensure the current `git-overleaf-url' has usable cookies before OP-DESC.
OP-DESC is a user-facing operation description used in authentication
error messages.
If cookies are missing or expired, ask in the minibuffer whether to run
`git-overleaf-authenticate' immediately."
  (let* ((state (git-overleaf--cookie-state))
         (status (plist-get state :status))
         (reason (git-overleaf--authentication-needed-reason state)))
    (if (eq status 'valid)
        t
      (if (or noninteractive
              (not
               (let ((use-dialog-box nil))
                 (y-or-n-p
                  (format "%s Re-run `git-overleaf-authenticate` now? "
                          reason)))))
          (user-error
           "%s Run `git-overleaf-authenticate` before %s"
           reason
           (or op-desc "continuing"))
        (git-overleaf-authenticate git-overleaf-url)
        (git-overleaf--get-cookies)
        t))))

;;;; Generic helpers

(defun git-overleaf--pget (plist &rest keys)
  "Recursively follow KEYS inside PLIST."
  (while keys
    (let ((key (pop keys)))
      (setq plist
            (if (integerp key)
                (nth key plist)
              (plist-get plist key)))))
  plist)

(defun git-overleaf--completing-read (prompt collection &optional padding)
  "Perform a completing read with PROMPT over COLLECTION.

COLLECTION is a list of plists with the shape
`(:fields (DISPLAY-FIELD...) :data DATA)'.
PADDING is the minimum number of spaces between display fields."
  (let* ((num-fields (1- (length (plist-get (car collection) :fields))))
         (padding (or padding 2))
         (field-widths
          (mapcar
           (lambda (field)
             (apply #'max
                    (mapcar
                     (lambda (row)
                       (length (nth field (plist-get row :fields))))
                     collection)))
           (number-sequence 0 num-fields)))
         (format-string
          (apply #'concat
                 (mapcar
                  (lambda (width)
                    (format "%%-%is" (+ padding width)))
                  field-widths)))
         (final-collection
          (cl-loop
           for row in collection
           collect
           `(,(apply #'format format-string (plist-get row :fields))
             ,(plist-get row :data)))))
    (cadr
     (assoc
      (completing-read prompt final-collection nil t)
      final-collection))))

(defun git-overleaf--url ()
  "Return a sanitized Overleaf URL without a trailing slash."
  (string-trim (string-trim git-overleaf-url) "" "/"))

(defun git-overleaf--url-host ()
  "Return the normalized host part of `git-overleaf-url'."
  (let ((host (url-host (url-generic-parse-url (git-overleaf--url)))))
    (unless host
      (user-error "Invalid Overleaf URL: %s" (git-overleaf--url)))
    (downcase host)))

(defun git-overleaf--cookie-domain ()
  "Return the cookie domain for the current `git-overleaf-url'."
  (git-overleaf--url-host))

(defun git-overleaf--cookie-key-candidates ()
  "Return candidate cookie keys for the current `git-overleaf-url'."
  (let* ((host (git-overleaf--url-host))
         (labels (string-split host "\\."))
         (candidates (list host (concat "." host))))
    ;; Keep parent-domain lookups for older saved cookie formats.
    (while (> (length labels) 2)
      (setq labels (cdr labels))
      (let ((suffix (string-join labels ".")))
        (setq candidates
              (append candidates (list suffix (concat "." suffix))))))
    (delete-dups candidates)))

(defun git-overleaf--project-page-url (project-id)
  "Return the project page URL for PROJECT-ID."
  (format "%s/project/%s" (git-overleaf--url) project-id))

(defun git-overleaf--sanitize-name (name)
  "Turn NAME into a filesystem-friendly directory name."
  (let ((sanitized
         (replace-regexp-in-string
          "-+"
          "-"
          (replace-regexp-in-string
           "[^[:alnum:]]+"
           "-"
           (downcase (string-trim name))))))
    (string-trim sanitized "-" "-")))

(defun git-overleaf--ensure-executable (program)
  "Return PROGRAM if it is executable, otherwise signal an error."
  (or (executable-find program)
      (user-error "Required executable `%s' was not found" program)))

(defconst git-overleaf--sensitive-header-regexp
  "\\`\\(?:Cookie\\|Authorization\\|X-Csrf-Token\\):[[:space:]]*"
  "Regexp matching command header arguments that should not be logged.")

(defun git-overleaf--redact-sensitive-argument (arg)
  "Return ARG with sensitive command data redacted."
  (if (and (stringp arg)
           (string-match git-overleaf--sensitive-header-regexp arg))
      (concat (match-string 0 arg) "<redacted>")
    arg))

(defun git-overleaf--redact-command-args (args)
  "Return ARGS with sensitive values redacted for display."
  (let ((remaining args)
        (redacted nil))
    (while remaining
      (let ((arg (pop remaining)))
        (push arg redacted)
        (when (and (member arg '("-H" "--header" "-b" "--cookie"))
                   remaining)
          (let ((value (pop remaining)))
            (push (if (member arg '("-b" "--cookie"))
                      "<redacted>"
                    (git-overleaf--redact-sensitive-argument value))
                  redacted)))))
    (nreverse (mapcar #'git-overleaf--redact-sensitive-argument redacted))))

(defun git-overleaf--redact-sensitive-text (text)
  "Return TEXT with sensitive HTTP header values redacted."
  (when text
    (replace-regexp-in-string
     "\\(\\(?:Cookie\\|Authorization\\|X-Csrf-Token\\):[[:space:]]*\\)[^\r\n]*"
     "\\1<redacted>"
     text
     nil
     nil)))

(defun git-overleaf--command-error-message (program args output)
  "Return a safe error message for PROGRAM ARGS and command OUTPUT."
  (format "%s %s failed: %s"
          program
          (string-join (git-overleaf--redact-command-args args) " ")
          (let ((safe-output
                 (git-overleaf--redact-sensitive-text output)))
            (if (or (null safe-output) (string-empty-p safe-output))
                "unknown error"
              safe-output))))

(defun git-overleaf--command-result-or-error
    (program args status output noerror)
  "Return a command result or signal a safe command error.
PROGRAM and ARGS describe the command.  STATUS and OUTPUT are its exit
status and captured output.  When NOERROR is non-nil, return a result
even for non-zero exits."
  (unless (or noerror (and (integerp status) (zerop status)))
    (error "%s"
           (git-overleaf--command-error-message
            program
            args
            output)))
  (make-git-overleaf--command-result
   :status status
   :output output))

(defun git-overleaf--background-thread-p ()
  "Return non-nil when running outside Emacs' main thread."
  (and (fboundp 'current-thread)
       (boundp 'main-thread)
       (not (eq (current-thread) main-thread))))

(defun git-overleaf--run-async-wait
    (program args directory env noerror)
  "Run PROGRAM with ARGS asynchronously and wait from a worker thread.
DIRECTORY, ENV, and NOERROR have the same meaning as in
`git-overleaf--run'."
  (let* ((default-directory (or directory default-directory))
         (process-environment (append env process-environment))
         (mutex (make-mutex "git-overleaf-command"))
         (process nil)
         (done nil)
         (status nil)
         (output-chunks nil)
         (result nil))
    (unwind-protect
        (setq
         result
         (progn
           (setq process
                 (make-process
                  :name "git-overleaf-command"
                  :buffer nil
                  :command (cons program args)
                  :connection-type 'pipe
                  :noquery t
                  :filter
                  (lambda (_proc string)
                    (mutex-lock mutex)
                    (unwind-protect
                        (push string output-chunks)
                      (mutex-unlock mutex)))
                  :sentinel
                  (lambda (proc _event)
                    (when (memq (process-status proc) '(exit signal))
                      (mutex-lock mutex)
                      (unwind-protect
                          (unless done
                            (setq status (process-exit-status proc))
                            (setq done t))
                        (mutex-unlock mutex))))))
           (git-overleaf--async-register-process process)
           (while (not done)
             (accept-process-output process 0.05)
             (thread-yield))
           (let ((output nil))
             (mutex-lock mutex)
             (unwind-protect
                 (setq output
                       (string-trim-right
                        (apply #'concat (nreverse output-chunks))))
               (mutex-unlock mutex))
             (git-overleaf--command-result-or-error
              program
              args
              status
              output
              noerror))))
      (when (and process (process-live-p process))
        (ignore-errors (delete-process process)))
      (when process
        (git-overleaf--async-unregister-process process)))
    result))

(defun git-overleaf--run (program args &optional directory env noerror)
  "Run PROGRAM with ARGS in DIRECTORY and optional ENV.
Return an `git-overleaf--command-result'.  Signal an error unless
NOERROR is non-nil."
  (when (git-overleaf--async-current-task-canceled-p)
    (signal 'quit (list "Overleaf background operation was canceled")))
  (let ((program (git-overleaf--ensure-executable program)))
    (if (and (git-overleaf--background-thread-p)
             (fboundp 'make-mutex))
        (git-overleaf--run-async-wait
         program
         args
         directory
         env
         noerror)
      (let ((default-directory (or directory default-directory))
            (process-environment (append env process-environment)))
        (with-temp-buffer
          (let ((status (apply #'process-file program nil (current-buffer) nil args))
                (output nil))
            (setq output (string-trim-right (buffer-string)))
            (git-overleaf--command-result-or-error
             program
             args
             status
             output
             noerror)))))))

(defun git-overleaf--git-run (repo args &optional env noerror)
  "Run Git with ARGS in REPO and return a command result.
ENV is prepended to `process-environment'.  If NOERROR is non-nil, do
not signal on non-zero exit status."
  (git-overleaf--run git-overleaf-git-executable args repo env noerror))

(defun git-overleaf--git-output (repo &rest args)
  "Run Git with ARGS in REPO and return trimmed stdout."
  (git-overleaf--command-result-output
   (git-overleaf--git-run repo args)))

(defun git-overleaf--git-output-noerror (repo &rest args)
  "Run Git with ARGS in REPO and return stdout, or nil on failure."
  (let ((result (git-overleaf--git-run repo args nil t)))
    (when (and (integerp (git-overleaf--command-result-status result))
               (zerop (git-overleaf--command-result-status result)))
      (git-overleaf--command-result-output result))))

(defun git-overleaf-root (&optional directory)
  "Return the Git toplevel for DIRECTORY, or nil if none exists."
  (git-overleaf--git-output-noerror
   (or directory default-directory)
   "rev-parse" "--show-toplevel"))

(defun git-overleaf--require-repo (&optional directory)
  "Return the Git toplevel for DIRECTORY, or signal a user error."
  (or (and directory (git-overleaf-root directory))
      (git-overleaf-root default-directory)
      (user-error "Not inside a Git repository")))

(defun git-overleaf--require-managed-repo (&optional directory)
  "Return the managed Overleaf Git repo for DIRECTORY, or signal a user error."
  (let ((repo (git-overleaf--require-repo directory)))
    (unless (git-overleaf--managed-repo-p repo)
      (user-error "Repository %s is not configured as an Overleaf project" repo))
    repo))

(defun git-overleaf--set-repo-url (repo &optional url)
  "Set `git-overleaf-url' from REPO metadata or explicit URL, and return it."
  (setq git-overleaf-url
        (or url
            (and repo (git-overleaf--git-config-get repo "git-overleaf.url"))
            (git-overleaf--url))))

(defun git-overleaf--read-repo-status (repo)
  "Return parsed `git status --porcelain' information for REPO."
  (let* ((output (git-overleaf--git-output repo "status" "--porcelain"))
         (lines (unless (string-empty-p output)
                  (split-string output "\n" t)))
         (staged nil)
         (unstaged nil)
         (unmerged nil))
    (dolist (line lines)
      (let* ((code (substring line 0 2))
             (index-status (aref code 0))
             (worktree-status (aref code 1)))
        (when (member code '("DD" "AU" "UD" "UA" "DU" "AA" "UU"))
          (setq unmerged t))
        (when (and (not (eq index-status ?\s))
                   (not (eq index-status ?\?)))
          (setq staged t))
        (when (or (string= code "??")
                  (and (not (eq worktree-status ?\s))
                       (not (eq worktree-status ?\?))))
          (setq unstaged t))))
    (make-git-overleaf--repo-status
     :lines lines
     :staged staged
     :unstaged unstaged
     :unmerged unmerged)))

(defun git-overleaf--current-branch (repo)
  "Return the current branch name for REPO.
Signal an error on detached HEAD."
  (let ((branch
         (git-overleaf--git-output repo "branch" "--show-current")))
    (if (string-empty-p branch)
        (user-error "Detached HEAD is not supported for Overleaf push/pull")
      branch)))

(defun git-overleaf--rev-parse (repo revision)
  "Resolve REVISION inside REPO."
  (git-overleaf--git-output repo "rev-parse" revision))

(defun git-overleaf--rev-parse-noerror (repo revision)
  "Resolve REVISION inside REPO, returning nil if it does not exist."
  (git-overleaf--git-output-noerror repo "rev-parse" "--verify" revision))

(defun git-overleaf--tree-id (repo revision)
  "Return the tree object id for REVISION inside REPO."
  (git-overleaf--git-output repo "rev-parse" (format "%s^{tree}" revision)))

(defun git-overleaf--merge-in-progress-p (repo)
  "Return non-nil if REPO currently has a merge in progress."
  (git-overleaf--rev-parse-noerror repo "MERGE_HEAD"))

(defun git-overleaf--is-ancestor-p (repo ancestor descendant)
  "Return non-nil if ANCESTOR is an ancestor of DESCENDANT in REPO."
  (let ((result
         (git-overleaf--git-run
          repo
          (list "merge-base" "--is-ancestor" ancestor descendant)
          nil
          t)))
    (and (integerp (git-overleaf--command-result-status result))
         (zerop (git-overleaf--command-result-status result)))))

(defun git-overleaf--path-depth (path)
  "Return the slash depth of PATH."
  (if (string-empty-p path)
      0
    (length (split-string path "/" t))))

(defun git-overleaf--parent-path (path)
  "Return the parent directory path for PATH relative to the repo root."
  (let ((dir (file-name-directory path)))
    (if dir
        (directory-file-name dir)
      "")))

(defun git-overleaf--directory-empty-p (dir)
  "Return non-nil if DIR is empty or does not exist."
  (or (not (file-exists-p dir))
      (null
       (cl-remove-if
        (lambda (name)
          (member name '("." "..")))
        (directory-files dir nil nil t)))))

(defun git-overleaf--copy-directory-contents (source destination)
  "Copy SOURCE directory contents into DESTINATION."
  (make-directory destination t)
  (dolist (entry (directory-files source t nil t))
    (unless (member (file-name-nondirectory entry) '("." ".."))
      (let ((target
             (expand-file-name (file-name-nondirectory entry) destination)))
        (if (file-directory-p entry)
            (copy-directory entry target t t t)
          (copy-file entry target t t t t))))))

(defun git-overleaf--normalize-extracted-root (directory)
  "Return the effective project root inside DIRECTORY."
  (let ((entries
         (cl-remove-if
          (lambda (path)
            (member (file-name-nondirectory path) '("." "..")))
          (directory-files directory t nil t))))
    (if (and (= (length entries) 1)
             (file-directory-p (car entries)))
        (car entries)
      directory)))

(defun git-overleaf--files-equal-p (left right)
  "Return non-nil if LEFT and RIGHT have byte-identical contents."
  (and (file-exists-p left)
       (file-exists-p right)
       (= (file-attribute-size (file-attributes left))
          (file-attribute-size (file-attributes right)))
       (let ((result
              (git-overleaf--run
               "cmp"
               (list "--silent" left right)
               nil
               nil
               t)))
         (and (integerp (git-overleaf--command-result-status result))
              (zerop (git-overleaf--command-result-status result))))))

;;;; Git metadata

(defun git-overleaf--git-config-get (repo key)
  "Read Git config KEY from REPO."
  (git-overleaf--git-output-noerror repo "config" "--local" "--get" key))

(defun git-overleaf--git-config-set (repo key value)
  "Set Git config KEY to VALUE in REPO."
  (git-overleaf--git-output repo "config" "--local" key value))

(defun git-overleaf--git-config-unset (repo key)
  "Unset Git config KEY in REPO."
  (git-overleaf--git-run
   repo
   (list "config" "--local" "--unset-all" key)
   nil
   t))

(defun git-overleaf--git-remotes (repo)
  "Return the names of Git remotes configured in REPO."
  (when-let* ((output (git-overleaf--git-output-noerror repo "remote")))
    (split-string output "\n" t)))

(defun git-overleaf--marked-remotes (repo &optional project-id)
  "Return logical Overleaf remotes configured in REPO.
When PROJECT-ID is non-nil, only return remotes marked for that project."
  (when-let* ((output
               (git-overleaf--git-output-noerror
                repo
                "config"
                "--local"
                "--get-regexp"
                "^remote\\..*\\.gitoverleafprojectid$")))
    (let (remotes)
      (dolist (line (split-string output "\n" t))
        (when (string-match
               "\\`remote\\.\\(.+\\)\\.gitoverleafprojectid[[:space:]]+\\(.+\\)\\'"
               line)
          (let ((remote (match-string 1 line))
                (marker-project-id (match-string 2 line)))
            (when (or (null project-id)
                      (equal marker-project-id project-id))
              (push remote remotes)))))
      (nreverse remotes))))

(defun git-overleaf--remote-name (repo)
  "Return REPO's registered logical Overleaf remote, or nil."
  (let ((remotes
         (git-overleaf--marked-remotes
          repo
          (git-overleaf--project-id repo))))
    (pcase remotes
      ('nil nil)
      (`(,remote) remote)
      (_
       (user-error
        "Repository %s has multiple logical remotes for this Overleaf project: %s"
        repo
        (string-join remotes ", "))))))

(defun git-overleaf--validate-remote-name (repo name)
  "Return NAME when it is a valid remote name for REPO, or signal."
  (unless (and (stringp name) (not (string-empty-p name)))
    (user-error "Overleaf remote name cannot be empty"))
  (unless (git-overleaf--git-output-noerror
           repo
           "check-ref-format"
           (format "refs/remotes/%s/project" name))
    (user-error "Invalid Overleaf remote name: %s" name))
  name)

(defun git-overleaf--configure-remote (repo name project-id)
  "Configure NAME in REPO as the logical remote for PROJECT-ID."
  (git-overleaf--git-config-set
   repo (format "remote.%s.gitOverleafProjectId" name) project-id)
  (git-overleaf--git-config-set
   repo (format "remote.%s.skipFetchAll" name) "true")
  (git-overleaf--git-config-set
   repo (format "remote.%s.skipDefaultUpdate" name) "true")
  name)

(defun git-overleaf--register-remote (repo name &optional project-id)
  "Register logical Overleaf remote NAME in REPO.
Use PROJECT-ID when non-nil, otherwise read it from repository metadata.
Return the registered remote name.  Existing marked remotes are reused."
  (setq name (git-overleaf--validate-remote-name repo name))
  (let* ((project-id (or project-id (git-overleaf--project-id repo)))
         (marked (git-overleaf--marked-remotes repo)))
    (pcase marked
      (`(,remote)
       (git-overleaf--configure-remote repo remote project-id))
      ('nil
       (when (member name (git-overleaf--git-remotes repo))
         (user-error
          "Git remote `%s' already exists and is not an Overleaf remote"
          name))
       (git-overleaf--configure-remote repo name project-id))
      (_
       (user-error
        "Repository %s has multiple logical Overleaf remotes: %s"
        repo
        (string-join marked ", "))))))

(defun git-overleaf--maybe-register-default-remote (repo project-id)
  "Register REPO's default logical remote for PROJECT-ID when safe.
Return its name, or nil when the default name belongs to a real remote."
  (condition-case err
      (git-overleaf--register-remote repo git-overleaf-remote-name project-id)
    (user-error
     (git-overleaf--warn
      "%s; run `git-overleaf-register-remote' with another name"
      (error-message-string err))
     nil)))

(defun git-overleaf--set-base-ref (repo revision)
  "Move the Overleaf base ref in REPO to REVISION."
  (git-overleaf--git-output
   repo
   "update-ref"
   (or (git-overleaf--git-config-get repo "git-overleaf.baseRef")
       git-overleaf-base-ref)
   revision))

(defun git-overleaf--set-remote-ref (repo revision)
  "Move the latest Overleaf snapshot ref in REPO to REVISION."
  (git-overleaf--git-output
   repo
   "update-ref"
   (git-overleaf--remote-ref repo)
   revision))

(defun git-overleaf--base-ref (repo)
  "Return the configured base ref for REPO."
  (or (git-overleaf--git-config-get repo "git-overleaf.baseRef")
      git-overleaf-base-ref))

(defun git-overleaf--remote-ref (repo)
  "Return the configured latest Overleaf snapshot ref for REPO."
  (or (git-overleaf--git-config-get repo "git-overleaf.remoteRef")
      git-overleaf-remote-ref))

(defun git-overleaf--project-id (repo)
  "Return the configured Overleaf project id for REPO."
  (or (git-overleaf--git-config-get repo "git-overleaf.projectId")
      (user-error "Repository %s is not configured as an Overleaf project" repo)))

(defun git-overleaf--project-name (repo)
  "Return the configured Overleaf project name for REPO."
  (or (git-overleaf--git-config-get repo "git-overleaf.projectName")
      (git-overleaf--project-id repo)))

(defun git-overleaf--managed-repo-p (repo)
  "Return non-nil if REPO stores Overleaf project metadata."
  (git-overleaf--git-config-get repo "git-overleaf.projectId"))

(defun git-overleaf--write-repo-metadata (repo project)
  "Persist PROJECT metadata inside REPO."
  (git-overleaf--git-config-set
   repo "git-overleaf.projectId" (plist-get project :id))
  (git-overleaf--git-config-set
   repo "git-overleaf.projectName" (plist-get project :name))
  (git-overleaf--git-config-set
   repo "git-overleaf.url" (git-overleaf--url))
  (git-overleaf--git-config-set
   repo "git-overleaf.baseRef" git-overleaf-base-ref)
  (git-overleaf--git-config-set
   repo "git-overleaf.remoteRef" git-overleaf-remote-ref)
  (git-overleaf--maybe-register-default-remote
   repo (plist-get project :id)))

(defun git-overleaf--notify-operation-succeeded (repo operation)
  "Notify integrations that OPERATION succeeded for REPO."
  (run-hook-with-args
   'git-overleaf--after-operation-functions repo operation))

;;;; Log context

(defun git-overleaf--log-context-for-repo (&optional repo)
  "Return a log context plist for managed REPO."
  (when repo
    (git-overleaf-log-make-context
     :project-id (git-overleaf--git-config-get repo "git-overleaf.projectId")
     :project-name (git-overleaf--git-config-get repo "git-overleaf.projectName")
     :repo repo
     :url (or (git-overleaf--git-config-get repo "git-overleaf.url")
              (git-overleaf--url)))))

(defun git-overleaf--log-context-for-project
    (project &optional repo)
  "Return a log context plist for PROJECT and optional REPO."
  (git-overleaf-log-make-context
   :project-id (plist-get project :id)
   :project-name (plist-get project :name)
   :repo repo
   :url (git-overleaf--url)))

(defun git-overleaf--log-default-context ()
  "Return the default Overleaf log context for `default-directory'."
  (or (when-let* ((repo (ignore-errors (git-overleaf-root default-directory))))
        (git-overleaf--log-context-for-repo repo))
      (git-overleaf-log-make-context :url (git-overleaf--url))))

(defmacro git-overleaf--with-repo-log-context (repo &rest body)
  "Run BODY with REPO metadata as the Overleaf log context."
  (declare (indent 1) (debug (form body)))
  `(git-overleaf-log-with-context
       (git-overleaf--log-context-for-repo ,repo)
     ,@body))

(defun git-overleaf--clear-pending-state (repo)
  "Remove pending synchronization metadata from REPO."
  (dolist (key '("git-overleaf.pendingRemoteCommit"
                 "git-overleaf.pendingTargetCommit"
                 "git-overleaf.pendingAction"))
    (git-overleaf--git-config-unset repo key)))

(defun git-overleaf--set-pending-pull-state (repo remote-commit)
  "Persist a pending pull state inside REPO recording REMOTE-COMMIT."
  (git-overleaf--clear-pending-state repo)
  (git-overleaf--git-config-set
   repo "git-overleaf.pendingRemoteCommit" remote-commit)
  (git-overleaf--git-config-set
   repo "git-overleaf.pendingAction" "pull"))

(defun git-overleaf--set-pending-push-state
    (repo remote-commit target-commit)
  "Persist a resumable push from REMOTE-COMMIT to TARGET-COMMIT in REPO."
  (git-overleaf--clear-pending-state repo)
  (git-overleaf--git-config-set
   repo "git-overleaf.pendingRemoteCommit" remote-commit)
  (git-overleaf--git-config-set
   repo "git-overleaf.pendingTargetCommit" target-commit)
  (git-overleaf--git-config-set
   repo "git-overleaf.pendingAction" "push"))

(defun git-overleaf--pending-state (repo)
  "Return pending synchronization metadata for REPO, or nil."
  (when-let* ((action-str
               (git-overleaf--git-config-get repo "git-overleaf.pendingAction")))
    `(:action ,(intern action-str)
		      :remote-commit
		      ,(git-overleaf--git-config-get repo "git-overleaf.pendingRemoteCommit")
		      :target-commit
		      ,(git-overleaf--git-config-get repo "git-overleaf.pendingTargetCommit"))))

(defun git-overleaf--record-remote-fetch-time (repo)
  "Record the current time as REPO's latest observed remote snapshot time."
  (git-overleaf--git-config-set
   repo
   "git-overleaf.remoteFetchedAt"
   (format-time-string "%Y-%m-%dT%H:%M:%SZ" (current-time) t)))

(defun git-overleaf--remote-fetched-at (repo)
  "Return the timestamp of REPO's latest observed remote snapshot."
  (git-overleaf--git-config-get repo "git-overleaf.remoteFetchedAt"))

;;;; Local safety backups

(defun git-overleaf--local-backup-ref-prefix ()
  "Return the configured local backup ref prefix."
  (let ((prefix
         (string-trim-right
          (string-trim git-overleaf-local-backup-ref-prefix)
          "/")))
    (cond
     ((string-empty-p prefix)
      (user-error "`git-overleaf-local-backup-ref-prefix' cannot be empty"))
     ((not (string-prefix-p "refs/" prefix))
      (user-error "`git-overleaf-local-backup-ref-prefix' must start with `refs/'"))
     (t prefix))))

(defun git-overleaf--sanitize-ref-component (value)
  "Return VALUE sanitized for use as one Git ref path component."
  (let ((component
         (replace-regexp-in-string
          "\\.\\.+"
          "-"
          (replace-regexp-in-string
           "[^[:alnum:]._-]+"
           "-"
           (downcase (string-trim (format "%s" value)))))))
    (setq component (string-trim component "[-.]+" "[-.]+"))
    (if (string-empty-p component)
        "unknown"
      component)))

(defun git-overleaf--local-backup-ref-name (repo target reason)
  "Return an unused backup ref name in REPO for TARGET and REASON."
  (let* ((prefix (git-overleaf--local-backup-ref-prefix))
         (branch
          (or (git-overleaf--git-output-noerror repo "branch" "--show-current")
              "detached"))
         (base
          (format
           "%s/%s-%s-%s-%s"
           prefix
           (format-time-string "%Y%m%d-%H%M%S")
           (git-overleaf--sanitize-ref-component branch)
           (git-overleaf--sanitize-ref-component reason)
           (substring target 0 (min 12 (length target))))))
    (cl-loop
     for index from 0
     for ref = (if (zerop index) base (format "%s-%d" base index))
     unless (git-overleaf--rev-parse-noerror repo ref)
     return ref)))

(defun git-overleaf--create-local-backup-ref
    (repo reason &optional revision)
  "Create a local backup ref in REPO for REVISION before REASON.
REVISION defaults to HEAD.  Return the created ref, or nil if backups are
disabled or the revision does not exist."
  (when git-overleaf-local-backups-enabled
    (when-let* ((target
                 (git-overleaf--rev-parse-noerror
                  repo
                  (or revision "HEAD")))
                (ref (git-overleaf--local-backup-ref-name
                      repo
                      target
                      reason)))
      (git-overleaf--git-output
       repo
       "update-ref"
       "-m"
       (format "overleaf: backup before %s" reason)
       ref
       target)
      (git-overleaf--debug "Created local backup ref %s at %s" ref target)
      ref)))


(provide 'git-overleaf-core)

;;; git-overleaf-core.el ends here
