;;; ai-code-eca.el --- ECA backend bridge for ai-code  -*- lexical-binding: t; -*-

;; Copyright (C) 2026
;; Author: davidwuchn
;; Keywords: tools, convenience
;; SPDX-License-Identifier: Apache-2.0

;;; Commentary:
;; ECA backend bridge for ai-code with:
;;   - Session management (list, switch, which)
;;   - Workspace management (add, list, remove, sync)
;;   - Context commands (file, cursor, repo-map, clipboard)
;;   - Shared context with auto-apply on session switch
;;   - Integrated into ai-code-menu (C-c a) when ECA selected
;;
;; When ECA is selected as the ai-code backend, ECA items appear
;; directly in ai-code-menu under the "ECA" group.

;;; Code:

(require 'cl-lib)
(require 'seq)
(require 'package)
(require 'subr-x)
(require 'tabulated-list)
(require 'ai-code-backends)
(require 'ai-code-input nil t)
(require 'transient nil t)
(require 'eca nil t)
(require 'eca-util nil t)
(require 'eca-chat nil t)
(require 'eca-process nil t)

;;; Binary Upgrade Configuration:

(defcustom ai-code-eca-upgrade-auto-enabled nil
  "When non-nil, check for ECA binary updates once per day at idle."
  :type 'boolean
  :group 'eca)

(defcustom ai-code-eca-upgrade-auto-idle-seconds 30
  "Seconds of idle time before the daily ECA binary update check runs."
  :type 'integer
  :group 'eca)

(declare-function eca "eca" (&optional arg))
(declare-function eca-session "eca-util" ())
(declare-function eca-vals "eca-util" (map))
(declare-function eca-chat-open "eca-chat" (session))
(declare-function eca-chat-send-prompt "eca-chat" (message))
(declare-function eca-chat--get-last-buffer "eca-chat" (session))
(declare-function eca-info "eca-util" (format-string &rest args))
(declare-function eca--session-id "eca-util" (session))
(declare-function eca--session-status "eca-util" (session))
(declare-function eca--session-workspace-folders "eca-util" (session))
(declare-function eca-chat-add-workspace-root "eca-chat" ())
(declare-function eca-get "eca-util" (map key))
(declare-function eca-process--download-url "eca-process" (version))

(declare-function transient-append-suffix "transient" (prefix loc suffix &optional face))
(declare-function transient-remove-suffix "transient" (prefix suffix))

(eval-when-compile
  (defvar eca--sessions)
  (defvar eca--session-id-cache)
  (defvar eca-process--latest-server-version)
  (defvar eca-server-install-path)
  (defvar eca-server-version-file-path)
  (defvar package-vc-selected-packages))

(declare-function ai-code-eca-upgrade--curl-download-string "ai-code-eca" (url))
(declare-function ai-code-eca-upgrade--curl-download-file "ai-code-eca"
                  (&key url path on-done))
(declare-function ai-code-eca-upgrade--auto-maybe "ai-code-eca" ())

;;; Core Commands

;;;###autoload
(defun ai-code-eca-start (&optional arg)
  "Start or resume an ECA session.
With prefix ARG, force new session."
  (interactive "P")
  (ai-code-eca--ensure-available)
  (let ((current-prefix-arg arg))
    (call-interactively #'eca)))

;;;###autoload
(defun ai-code-eca-switch (&optional force-prompt)
  "Switch to ECA chat buffer.
With FORCE-PROMPT (prefix arg), force new session."
  (interactive "P")
  (ai-code-eca--ensure-available)
  (if force-prompt
      (ai-code-eca-start '(16))
    (let ((session (eca-session)))
      (if session
          (progn
            (eca-chat-open session)
            (pop-to-buffer (eca-chat--get-last-buffer session)))
        (ai-code-eca-start nil)))))

(defun ai-code-eca--ensure-available ()
  "Ensure ECA package and functions are available."
  (unless (require 'eca nil t)
    (user-error "ECA not available; install with: M-x package-install RET eca RET"))
  (dolist (fn '(eca eca-session eca-chat-open eca-chat--get-last-buffer))
    (unless (fboundp fn)
      (user-error "ECA missing function: %s; reinstall eca package" fn)))
  (ai-code-eca--ensure-cleanup-hooks))

;;; Menu Integration - ECA group in ai-code-menu

(defvar ai-code-eca--menu-group-added nil
  "Track whether ECA group has been added to ai-code-menu.")

(declare-function eca-workspaces "eca" ())

;;;###autoload
(transient-define-prefix ai-code-eca-menu ()
  "ECA session management menu."
  ["ECA"
   ("E" "Start ECA for project..." ai-code-eca-create-session-for-workspace)
   ("W" "Switch session" ai-code-eca-switch-session)
   ("D" "ECA Workspace Dashboard" eca-workspaces)
   ("A" "Add project to session" ai-code-eca-add-workspace-folder)
   ("X" "Remove project from session" ai-code-eca-remove-workspace-folder)
   ("F" "Add file to shared context" ai-code-eca-share-file-context)
   ("M" "Add repo map to shared context" ai-code-eca-share-repo-map-context)
   ("B" "Add clipboard as file context" ai-code-eca-chat-add-clipboard-context-now)
   ("Y" "Clear shared" ai-code-eca-clear-shared-context)])

(defun ai-code-eca--add-menu-group ()
  "Add ECA commands directly to ai-code-menu layouts.
Only adds when ECA is the current backend."
  (when (and (featurep 'transient)
             (not ai-code-eca--menu-group-added)
             (eq (bound-and-true-p ai-code-selected-backend) 'eca))
    (condition-case err
        (progn
          (dolist (prefix '(ai-code-menu-default ai-code-menu-2-columns))
            (when (commandp prefix)
              (transient-append-suffix prefix '(0 -1)
                ["ECA"
                 ("E" "Start ECA for project..." ai-code-eca-create-session-for-workspace)
                 ("W" "Switch session" ai-code-eca-switch-session)
                  ("D" "ECA Workspace Dashboard" eca-workspaces)
                  ("A" "Add project to session" ai-code-eca-add-workspace-folder)
                  ("X" "Remove project from session" ai-code-eca-remove-workspace-folder)
                  ("F" "Add file to shared context" ai-code-eca-share-file-context)
                  ("M" "Add repo map to shared context" ai-code-eca-share-repo-map-context)
                  ("B" "Add clipboard as file context" ai-code-eca-chat-add-clipboard-context-now)
                  ("Y" "Clear shared" ai-code-eca-clear-shared-context)])))
          (setq ai-code-eca--menu-group-added t))
      (error
       (message "Failed to add ECA menu: %s" (error-message-string err))))))

(defun ai-code-eca--remove-menu-group ()
  "Remove ECA commands from ai-code-menu layouts."
  (when ai-code-eca--menu-group-added
    (condition-case err
        (progn
          (dolist (prefix '(ai-code-menu-default ai-code-menu-2-columns))
            (when (commandp prefix)
              (dolist (key '("B" "Y" "M" "F" "X" "A" "D" "W" "E"))
                (condition-case nil
                    (transient-remove-suffix prefix key)
                  (error nil)))))
          (setq ai-code-eca--menu-group-added nil))
      (error
       (message "Failed to remove ECA menu: %s" (error-message-string err))))))

(defun ai-code-eca--after-set-backend (backend)
  "Update the ECA menu entries after selecting BACKEND."
  (if (eq backend 'eca)
      (ai-code-eca--add-menu-group)
    (ai-code-eca--remove-menu-group)))

(defun ai-code-eca--setup-ai-code-integration (&rest _args)
  "Install ECA integration once `ai-code' is loaded."
  (when (featurep 'ai-code)
    (unless (advice-member-p #'ai-code-eca--after-set-backend
                             'ai-code-set-backend)
      (advice-add 'ai-code-set-backend :after
                  #'ai-code-eca--after-set-backend))
    (remove-hook 'after-load-functions
                 #'ai-code-eca--setup-ai-code-integration)))

(if (featurep 'ai-code)
    (ai-code-eca--setup-ai-code-integration)
  (add-hook 'after-load-functions #'ai-code-eca--setup-ai-code-integration))


;;; Session Multiplexing and Workspace Management:

(declare-function eca-create-session "eca-util" (workspace-folders))
(declare-function eca-delete-session "eca-util" (session))
(declare-function eca-assert-session-running "eca-util" (session))
(declare-function (setf eca--session-workspace-folders) "eca-util" (value session))
(declare-function eca--session-add-workspace-folder "eca-util" (session folder))
(declare-function eca--session-chats "eca-util" (session))
(declare-function eca-process-start "eca-process" (session on-ready on-message))
(declare-function eca--initialize "eca" (session))
(declare-function eca--handle-message "eca" (session message))
(declare-function eca-chat--add-context "eca-chat" (context-plist))
(declare-function eca-chat--with-current-buffer "eca-chat" (&rest body))
(declare-function eca-api-notify "eca-api" (session &rest args))
(declare-function projectile-project-root "projectile" (&optional dir))
(declare-function project-current "project" (&optional maybe-prompt dir))
(declare-function project-root "project" (project))

(defun ai-code-eca--normalize-folder-path (path)
  "Return PATH as an expanded directory path without a trailing slash."
  (directory-file-name (expand-file-name path)))

;;; Session Multiplexing

(defun ai-code-eca-list-sessions ()
  "Return a list of all active ECA sessions.
Each element is a plist with :id, :status, :workspace-folders, and :chat-count.
Return nil if ECA has no active sessions."
  (and (boundp 'eca--sessions)
       eca--sessions
       (mapcar (lambda (session)
                 (list :id (eca--session-id session)
                       :status (eca--session-status session)
                       :workspace-folders (eca--session-workspace-folders session)
                       :chat-count (length (eca--session-chats session))))
               (eca-vals eca--sessions))))

(defun ai-code-eca-select-session (&optional session-id)
  "Select an ECA session by SESSION-ID or interactively.
Return the selected session or nil if canceled."
  (interactive)
  (let* ((sessions (ai-code-eca-list-sessions))
         (choices (and sessions
                       (> (length sessions) 1)
                       (mapcar (lambda (s)
                                 (cons (format "Session %d: %s (%s) - %d chats"
                                               (plist-get s :id)
                                               (mapconcat #'identity
                                                          (plist-get s :workspace-folders)
                                                          ", ")
                                               (plist-get s :status)
                                               (plist-get s :chat-count))
                                       (plist-get s :id)))
                               sessions)))
         (session-id
          (or session-id
              (if (null sessions)
                  (progn
                    (message "No active ECA sessions")
                    nil)
                (if (= (length sessions) 1)
                    (plist-get (car sessions) :id)
                  (cdr (assoc (completing-read "Select ECA session: " choices nil t)
                              choices)))))))
    (when session-id
      (let ((session (condition-case nil
                         (eca-get eca--sessions session-id)
                       (error nil))))
        (if session
            (progn
              (setq eca--session-id-cache session-id)
              (when (called-interactively-p 'interactive)
                (eca-info "Switched to session %d" session-id))
              session)
          (user-error "Session %s not found (may have been deleted)" session-id))))))

(defun ai-code-eca-switch-to-session (&optional session-id)
  "Switch to ECA session SESSION-ID and open its last chat buffer.
When called interactively, prompt for session selection.
Auto-apply shared context if any."
  (interactive)
  (let ((session (ai-code-eca-select-session session-id)))
    (when session
      (ai-code-eca--apply-shared-context-internal session)
      (eca-chat-open session)
      (pop-to-buffer (eca-chat--get-last-buffer session))
      session)))

(defun ai-code-eca--session-workspace-folders (session)
  "Return workspace folders for ECA SESSION."
  (if (and (listp session) (plist-member session :workspace-folders))
      (plist-get session :workspace-folders)
    (eca--session-workspace-folders session)))

(defun ai-code-eca--find-session-by-workspace (workspace-root)
  "Find an existing session that has WORKSPACE-ROOT in its workspace folders.
Returns the session if found, nil otherwise."
  (when (boundp 'eca--sessions)
    (let ((target (expand-file-name workspace-root)))
      (cl-find-if
       (lambda (session)
         (member target (mapcar #'expand-file-name
                                (ai-code-eca--session-workspace-folders
                                 session))))
       (eca-vals eca--sessions)))))

(defun ai-code-eca-create-session-for-workspace (&optional _arg)
  "Start ECA session for a selected workspace.
Prompt for workspace root, then create or reuse session for that workspace.
Reuse an existing session for that workspace when possible."
  (interactive "P")
  (let* ((proj (project-current nil default-directory))
         (default-dir (if proj (project-root proj) default-directory))
         (workspace-root (read-directory-name "Workspace root: " default-dir))
         (existing-session (ai-code-eca--find-session-by-workspace workspace-root)))
    (if existing-session
        (progn
          (message "Reusing existing ECA session for %s" workspace-root)
          (pcase (eca--session-status existing-session)
            ('stopped
             (eca-process-start existing-session
                                (lambda ()
                                  (eca--initialize existing-session))
                                (apply-partially #'eca--handle-message
                                                 existing-session)))
            ('started
             (eca-chat-open existing-session))
            ('starting
             (eca-info "ECA server is already starting")))
          (pop-to-buffer (eca-chat--get-last-buffer existing-session))
          existing-session)
      (let ((session (eca-create-session (list workspace-root))))
        (when session
          (pcase (eca--session-status session)
            ('stopped
             (eca-process-start session
                                (lambda ()
                                  (eca--initialize session))
                                (apply-partially #'eca--handle-message session)))
            ('started
             (eca-chat-open session))
            ('starting
             (eca-info "ECA server is already starting")))
          (pop-to-buffer (eca-chat--get-last-buffer session))
          session)))))

;;; Workspace Management

(defun ai-code-eca-list-workspace-folders (&optional session)
  "Return workspace folders for SESSION or the current session."
  (let ((sess (or session (eca-session))))
    (when sess
      (eca--session-workspace-folders sess))))

(defun ai-code-eca-add-workspace-folder (folder &optional session)
  "Add FOLDER to SESSION's workspace.
SESSION defaults to the current session.  Return the expanded folder path."
  (interactive
   (let ((session (eca-session)))
     (unless session
       (user-error "No ECA session active"))
     (list (read-directory-name "Add workspace folder: ") session)))
  (let ((sess (or session (eca-session))))
    (unless sess
      (user-error "No ECA session active"))
    (let* ((folder (expand-file-name folder))
           (existing (eca--session-workspace-folders sess))
           (session-id (eca--session-id sess)))
      (unless (file-directory-p folder)
        (user-error "Directory does not exist: %s" folder))
      (when (member folder existing)
        (user-error "Folder already in workspace: %s" folder))
      (eca--session-add-workspace-folder sess folder)
      (eca-info "Added workspace folder to session %d: %s" session-id folder)
      folder)))

(defalias 'ai-code-eca-chat-add-workspace-folder #'ai-code-eca-add-workspace-folder
  "Alias for `eca-add-workspace-folder' for discoverability.")

(defun ai-code-eca-remove-workspace-folder (folder &optional session)
  "Remove FOLDER from SESSION's workspace.
SESSION defaults to the current session.  Return the removed folder."
  (interactive
   (let* ((session (eca-session))
          (folders (when session (eca--session-workspace-folders session))))
     (unless session
       (user-error "No ECA session active"))
     (unless folders
       (user-error "No workspace folders in session"))
     (list (completing-read "Remove workspace folder: " folders nil t) session)))
  (let ((sess (or session (eca-session))))
    (unless sess
      (user-error "No ECA session active"))
    (let* ((folder (directory-file-name (expand-file-name folder)))
           (existing-raw (eca--session-workspace-folders sess))
           (existing (mapcar (lambda (f) (directory-file-name (expand-file-name f))) existing-raw))
           (session-id (eca--session-id sess)))
      (unless (member folder existing)
        (user-error "Folder not in workspace: %s" folder))
      (let ((raw-folder (nth (seq-position existing folder) existing-raw)))
        (funcall #'(setf eca--session-workspace-folders)
                 (remove raw-folder existing-raw)
                 sess)
        (when (fboundp 'eca-api-notify)
          (eca-api-notify
           sess
           :method "workspace/didChangeWorkspaceFolders"
           :params (list :event
                         (list :added []
                               :removed (vector
                                         (list :uri (concat "file://" raw-folder)
                                               :name (file-name-nondirectory
                                                      (directory-file-name raw-folder))))))))
        (eca-info "Removed workspace folder from session %d: %s" session-id raw-folder)
        raw-folder))))

(defun ai-code-eca-workspace-folder-for-file (file-path &optional session)
  "Return the workspace folder containing FILE-PATH in SESSION.
Return nil if FILE-PATH does not belong to any workspace folder."
  (let* ((sess (or session (eca-session)))
         (folders (when sess (eca--session-workspace-folders sess)))
         (file-path (expand-file-name file-path)))
    (when folders
      (seq-find (lambda (folder)
                  (string-prefix-p (file-name-as-directory folder)
                                   (file-name-as-directory file-path)))
                folders))))

(defun ai-code-eca-workspace-provenance (file-path &optional session)
  "Return workspace provenance plist for FILE-PATH in SESSION."
  (let ((workspace (ai-code-eca-workspace-folder-for-file file-path session)))
    (when workspace
      (list :workspace workspace
            :relative-path (file-relative-name file-path workspace)
            :folder-name (file-name-nondirectory
                          (directory-file-name workspace))))))

;;; Context Management

(defun ai-code-eca-chat-add-file-context (session file-path)
  "Add FILE-PATH as file context to SESSION."
  (eca-assert-session-running session)
  (let* ((file-path (expand-file-name file-path))
         (prov (ai-code-eca-workspace-provenance file-path session))
         (context (list :type "file" :path file-path)))
    (when prov
      (setq context (append context (list :workspace prov))))
    (eca-chat--with-current-buffer (eca-chat--get-last-buffer session)
      (eca-chat--add-context context)
      (eca-chat-open session))))

(defun ai-code-eca-chat-add-repo-map-context (session)
  "Add repository map context to SESSION."
  (eca-assert-session-running session)
  (eca-chat--with-current-buffer (eca-chat--get-last-buffer session)
    (eca-chat--add-context (list :type "repoMap"))
    (eca-chat-open session)))

(defun ai-code-eca-chat-add-clipboard-context (session content)
  "Add CONTENT as a temporary file context to SESSION."
  (eca-assert-session-running session)
  (let* ((temp-dir (file-name-as-directory
                    (or (bound-and-true-p eca-config-directory)
                        (expand-file-name "~/.eca"))))
         (tmp-subdir (expand-file-name "tmp" temp-dir))
         (temp-file (expand-file-name
                     (format "clipboard-%d-%d-%d.txt"
                             (emacs-pid)
                             (floor (float-time))
                             (random 1000000))
                     tmp-subdir)))
    (unless (file-directory-p tmp-subdir)
      (make-directory tmp-subdir t))
    (with-temp-file temp-file
      (insert content))
    (ai-code-eca--register-temp-file temp-file session)
    (eca-chat--with-current-buffer (eca-chat--get-last-buffer session)
      (eca-chat--add-context (list :type "file" :path temp-file))
      (eca-chat-open session))
    (eca-info "Added clipboard context (%d chars)" (length content))))

(defun ai-code-eca-chat-add-clipboard-context-now ()
  "Add current clipboard contents as context to the current ECA session."
  (interactive)
  (let ((session (eca-session)))
    (if session
        (let ((clip-content (current-kill 0 t)))
          (if (and clip-content (not (string-empty-p clip-content)))
              (ai-code-eca-chat-add-clipboard-context session clip-content)
            (message "Clipboard is empty")))
      (user-error "No ECA session active"))))

;;; Temp File Management

(defvar ai-code-eca--context-temp-files nil
  "List of temporary context files created by ai-code-eca.

Format: ((session-id . (file-path1 file-path2 ...)) ...).")

(defvar ai-code-eca--temp-file-max-age (* 24 3600)
  "Max age in seconds before temp files are considered stale.
Set to nil to disable stale-file cleanup.")

(defun ai-code-eca--cleanup-temp-context-files ()
  "Clean up all temporary context files created by ai-code-eca."
  (let ((count 0))
    (dolist (entry ai-code-eca--context-temp-files)
      (dolist (file (cdr entry))
        (condition-case nil
            (when (and file (file-exists-p file))
              (delete-file file)
              (cl-incf count))
          (error nil))))
    (setq ai-code-eca--context-temp-files nil)
    (when (and (fboundp 'eca-info) (> count 0))
      (eca-info "Cleaned up %d temporary context files" count))))

(defun ai-code-eca--cleanup-stale-temp-files ()
  "Clean up temp files older than `ai-code-eca--temp-file-max-age'."
  (when ai-code-eca--temp-file-max-age
    (let ((now (float-time))
          (count 0))
      (dolist (entry ai-code-eca--context-temp-files)
        (setcdr entry
                (cl-remove-if
                 (lambda (file)
                   (when (and file (file-exists-p file))
                     (let ((age (- now (float-time (nth 5 (file-attributes file))))))
                       (when (> age ai-code-eca--temp-file-max-age)
                         (condition-case nil
                             (delete-file file)
                           (error nil))
                         (cl-incf count)
                         t))))
                 (cdr entry))))
      (when (and (fboundp 'eca-info) (> count 0))
        (eca-info "Cleaned up %d stale temp files (older than %d hours)"
                  count (/ ai-code-eca--temp-file-max-age 3600))))))

(defun ai-code-eca--register-temp-file (file-path &optional session)
  "Register FILE-PATH for cleanup on exit or SESSION end."
  (ai-code-eca--ensure-cleanup-hooks)
  (when (and file-path (file-exists-p file-path))
    (let* ((sid (if session
                    (if (numberp session) session (eca--session-id session))
                  (when (boundp 'eca--session-id-cache)
                    eca--session-id-cache)))
           (entry (assoc sid ai-code-eca--context-temp-files)))
      (if entry
          (push file-path (cdr entry))
        (push (cons sid (list file-path)) ai-code-eca--context-temp-files)))
    file-path))

(defvar ai-code-eca--cleanup-timer nil
  "Timer used to clean stale ECA context temp files.")

(defun ai-code-eca--ensure-cleanup-hooks ()
  "Install ECA temp-file cleanup hooks."
  (add-hook 'kill-emacs-hook #'ai-code-eca--cleanup-temp-context-files)
  (unless (timerp ai-code-eca--cleanup-timer)
    (setq ai-code-eca--cleanup-timer
          (run-with-timer 3600 3600 #'ai-code-eca--cleanup-stale-temp-files))))

;;; Automatic Workspace / Session Management

(defcustom ai-code-eca-auto-add-workspace-folder t
  "If non-nil, automatically add a file's project to the current workspace.
If the value is `prompt', ask before adding."
  :type '(choice (const :tag "Auto add" t)
                 (const :tag "Prompt before adding" prompt)
                 (const :tag "Disabled" nil))
  :group 'eca)

(defcustom ai-code-eca-auto-switch-session 'prompt
  "If non-nil, automatically switch to the session matching the current project.
If the value is `prompt', ask before switching."
  :type '(choice (const :tag "Auto switch" t)
                 (const :tag "Prompt before switching" prompt)
                 (const :tag "Disabled" nil))
  :group 'eca)

(defcustom ai-code-eca-auto-create-session nil
  "If non-nil, automatically create or extend sessions for new projects.
If the value is `prompt', ask before creating."
  :type '(choice (const :tag "Auto create" t)
                 (const :tag "Prompt before creating" prompt)
                 (const :tag "Disabled" nil))
  :group 'eca)

(defvar ai-code-eca--last-project-root nil
  "Track the last project root seen by ECA auto-switch logic.")

(defvar ai-code-eca--shared-context nil
  "Plist of shared context items available to all sessions.
Keys currently used are :files and :repo-maps.

- :files contains a list of absolute file paths.
- :repo-maps contains a list of absolute repository root directories.")

(defun ai-code-eca--file-project-root (file-path)
  "Return a project root for FILE-PATH using projectile, project.el, or fallback."
  (when file-path
    (or (when (fboundp 'projectile-project-root)
          (ignore-errors
            (projectile-project-root (file-name-directory file-path))))
        (when (fboundp 'project-current)
          (ignore-errors
            (let ((proj (project-current nil (file-name-directory file-path))))
              (when proj
                (project-root proj)))))
        (file-name-directory file-path))))

(defun ai-code-eca--session-for-project-root (project-root)
  "Find the ECA session whose workspace includes PROJECT-ROOT."
  (let* ((root (ai-code-eca--normalize-folder-path project-root))
         (sessions (ai-code-eca-list-sessions)))
    (cl-dolist (info sessions)
      (let* ((session-id (plist-get info :id))
             (folders (plist-get info :workspace-folders))
             (match (cl-find root folders
                             :test (lambda (lhs rhs)
                                     (string= lhs (ai-code-eca--normalize-folder-path rhs))))))
        (when match
          (cl-return session-id))))))

(defun ai-code-eca--auto-add-workspace-hook ()
  "Auto-add the current file's project root to the current ECA workspace.
If the project is already present in the workspace, do nothing."
  (when (and ai-code-eca-auto-add-workspace-folder
             buffer-file-name
             (featurep 'eca)
             (eca-session))
    (let* ((project-root (ai-code-eca--file-project-root buffer-file-name))
           (session (eca-session))
           (workspace-folders (eca--session-workspace-folders session))
           (in-workspace (and project-root
                              (member (ai-code-eca--normalize-folder-path project-root)
                                      (mapcar (lambda (folder)
                                                (ai-code-eca--normalize-folder-path folder))
                                              workspace-folders)))))
      (when (and project-root (not in-workspace))
        (let ((root (ai-code-eca--normalize-folder-path project-root)))
          (pcase ai-code-eca-auto-add-workspace-folder
            ('t
             (eca--session-add-workspace-folder session root)
             (message "Auto-added project to ECA session %d: %s"
                      (eca--session-id session) root))
            ('prompt
             (when (y-or-n-p (format "Add project to ECA workspace (%s)? " root))
               (eca--session-add-workspace-folder session root)))))))))

(defun ai-code-eca--auto-switch-session-hook (&optional _frame)
  "Auto-switch ECA sessions when the active project is different."
  (when (and ai-code-eca-auto-switch-session
             buffer-file-name
             (featurep 'eca)
             (ai-code-eca-list-sessions))
    (let* ((project-root (ai-code-eca--file-project-root buffer-file-name))
           (current-session (ignore-errors (eca-session)))
           (current-session-id (when current-session
                                 (ignore-errors (eca--session-id current-session)))))
      (when (and project-root
                 (not (string= project-root ai-code-eca--last-project-root)))
        (let ((target-session (ai-code-eca--session-for-project-root project-root))
              (root (ai-code-eca--normalize-folder-path project-root)))
          (setq ai-code-eca--last-project-root root)
          (when (and target-session
                     (not (eq target-session current-session-id)))
            (pcase ai-code-eca-auto-switch-session
              ('t
               (ai-code-eca-switch-to-session target-session)
               (message "Auto-switched to ECA session %d for %s"
                        target-session root))
              ('prompt
               (when (y-or-n-p (format "Switch to session %d for %s? "
                                       target-session root))
                 (ai-code-eca-switch-to-session target-session))))))))))

(defun ai-code-eca--auto-create-session-hook ()
  "Auto-create or extend ECA sessions when visiting a project without one."
  (when (and ai-code-eca-auto-create-session
             buffer-file-name
             (featurep 'eca))
    (let* ((project-root (ai-code-eca--file-project-root buffer-file-name))
           (existing-session (when project-root
                               (ai-code-eca--session-for-project-root project-root)))
           (any-sessions (ai-code-eca-list-sessions)))
      (when (and project-root (not existing-session))
        (let ((root (ai-code-eca--normalize-folder-path project-root)))
          (pcase ai-code-eca-auto-create-session
            ('t
             (if any-sessions
                 (let ((session (eca-session)))
                   (if session
                       (progn
                         (eca--session-add-workspace-folder session root)
                         (message "Auto-added %s to current ECA session" root))
                     (let ((session (eca-create-session (list root))))
                       (when session
                         (message "Auto-created ECA session %d for %s"
                                  (eca--session-id session) root)))))
               (let ((session (eca-create-session (list root))))
                 (when session
                   (message "Auto-created ECA session %d for %s"
                            (eca--session-id session) root)
                   (eca-chat-open session)))))
            ('prompt
             (when (y-or-n-p (format "Create ECA session for %s? " root))
               (let ((session (eca-create-session (list root))))
                 (when session
                   (eca-chat-open session)))))))))))

(defun ai-code-eca--setup-eca-hooks (&rest _args)
  "Install hooks that depend on the ECA package."
  (when (featurep 'eca)
    (add-hook 'find-file-hook #'ai-code-eca--auto-add-workspace-hook)
    (add-hook 'find-file-hook #'ai-code-eca--auto-create-session-hook 90)
    (add-hook 'window-buffer-change-functions
              #'ai-code-eca--auto-switch-session-hook)
    (remove-hook 'after-load-functions #'ai-code-eca--setup-eca-hooks)))

(if (featurep 'eca)
    (ai-code-eca--setup-eca-hooks)
  (add-hook 'after-load-functions #'ai-code-eca--setup-eca-hooks))

;;; Shared Context

(defun ai-code-eca-share-file-context (file-path)
  "Add FILE-PATH to the shared context for all ECA sessions."
  (interactive "fShare file across sessions: ")
  (let ((file-path (expand-file-name file-path)))
    (setq ai-code-eca--shared-context
          (plist-put
           ai-code-eca--shared-context
           :files
           (cl-adjoin file-path (plist-get ai-code-eca--shared-context :files) :test #'string=)))
    (message "Shared file across all ECA sessions: %s" file-path)))

(defun ai-code-eca-share-repo-map-context (project-root)
  "Add PROJECT-ROOT repo map to the shared context for all ECA sessions."
  (interactive "DShare repo map across sessions: ")
  (let ((root (expand-file-name project-root)))
    (setq ai-code-eca--shared-context
          (plist-put
           ai-code-eca--shared-context
           :repo-maps
           (cl-adjoin root (plist-get ai-code-eca--shared-context :repo-maps) :test #'string=)))
    (message "Shared repo map across all ECA sessions: %s" root)))

(defun ai-code-eca--apply-shared-context-internal (session)
  "Apply shared context to SESSION without interactive queries."
  (let ((files (plist-get ai-code-eca--shared-context :files))
        (repo-maps (plist-get ai-code-eca--shared-context :repo-maps)))
    (when (or files repo-maps)
      (dolist (file files)
        (when (file-exists-p file)
          (ai-code-eca-chat-add-file-context session file)))
      (dolist (root repo-maps)
        (when (file-directory-p root)
          (unless (member (ai-code-eca--normalize-folder-path root)
                          (mapcar #'ai-code-eca--normalize-folder-path
                                  (or (ai-code-eca-list-workspace-folders session) '())))
            (when (fboundp 'ai-code-eca-add-workspace-folder)
              (ai-code-eca-add-workspace-folder root session)))
          (ai-code-eca-chat-add-repo-map-context session)))
      (message "Applied shared context: %d files, %d repo maps"
               (length files) (length repo-maps)))))

(defun ai-code-eca-clear-shared-context ()
  "Clear all shared context items."
  (interactive)
  (setq ai-code-eca--shared-context nil)
  (message "Cleared shared context"))

;;; Backend interface functions for ai-code-backends.el

(defun ai-code-eca-send (line)
  "Send LINE to ECA chat."
  (interactive "sECA> ")
  (ai-code-eca--ensure-available)
  (let ((session (eca-session)))
    (if session
        (progn
          (eca-chat-open session)
          (eca-chat-send-prompt line))
      (user-error "No ECA session; run M-x ai-code-eca-start first"))))

(defun ai-code-eca-resume (&optional _arg)
  "Resume/switch to ECA chat buffer.
ARG is ignored (for backend interface compatibility)."
  (interactive "P")
  (ai-code-eca-switch))

(defun ai-code-eca-upgrade-package ()
  "Upgrade ECA Emacs package.

If installed via `package-vc', uses `package-vc-upgrade'.
Otherwise uses package.el to refresh and reinstall."
  (interactive)
  (cond
   ((and (featurep 'package-vc)
         (alist-get 'eca package-vc-selected-packages))
    (message "Upgrading ECA via package-vc...")
    (package-vc-upgrade 'eca)
    (message "ECA upgraded. Restart Emacs or re-evaluate for changes."))
   ((package-installed-p 'eca)
    (package-refresh-contents)
    (package-install 'eca)
    (message "ECA upgraded successfully"))
   (t
    (user-error "ECA is not installed as a package"))))

(defun ai-code-eca-install-skills ()
  "Install skills for ECA by prompting for a skills repo URL.
ECA manages skills as files under ~/.eca/ or project .eca/ directory."
  (interactive)
  (ai-code-eca--ensure-available)
  (let* ((url (read-string "Skills repo URL for ECA: " nil nil
                           "https://github.com/obra/superpowers"))
         (prompt (format
                  "Install the skill from %s for this ECA session. Read the repository README to understand the installation instructions and follow them. Set up the skill files under the appropriate directory (e.g. ~/.eca/ or the project .eca/ directory) so they are available in future sessions."
                  url)))
    (ai-code-eca-send prompt)))

;;; Aliases for menu compatibility

(defalias 'ai-code-eca-switch-session #'ai-code-eca-switch-to-session
  "Alias for `ai-code-eca-switch-to-session' for menu compatibility.")

;;; Binary Upgrade System:

(defvar ai-code-eca-upgrade--pinned-version "0.115.0"
  "Pinned fallback version when binary and GitHub API are unavailable.")

(defun ai-code-eca-upgrade--resolve-version ()
  "Return the current eca version string as \"X.Y.Z\"."
  (cl-flet ((parse-semver (raw)
              (and (stringp raw)
                   (string-match "\\([0-9]+\\.[0-9]+\\.[0-9]+\\)" raw)
                   (match-string 1 raw))))
    (or
     (when-let* ((bin (executable-find "eca"))
                 (raw (string-trim
                       (shell-command-to-string
                        (concat (shell-quote-argument bin) " --version")))))
       (parse-semver raw))
     (when-let* ((curl (or (executable-find "curl") (executable-find "curl.exe")))
                 (raw  (string-trim
                        (shell-command-to-string
                         (format "%s -s -f --max-time 5 %s"
                                 (shell-quote-argument curl)
                                 "https://api.github.com/repos/editor-code-assistant/eca/releases/latest"))))
                 ((not (string-blank-p raw)))
                 ((string-match "\"tag_name\"\\s-*:\\s-*\"\\([^\"]+\\)\"" raw)))
       (parse-semver (match-string 1 raw)))
     (when (featurep 'package)
       (when-let* ((pkg-desc (assq 'eca package-alist))
                   (ver-list (package-desc-version (cadr pkg-desc))))
         (package-version-join ver-list)))
     ai-code-eca-upgrade--pinned-version)))

(defvar ai-code-eca-upgrade--last-check-file
  (expand-file-name "eca/eca-update-check" user-emacs-directory)
  "Timestamp file for last auto-update check.")

(defun ai-code-eca-upgrade--check-due-p ()
  "Return non-nil when more than 24 h have passed since last check."
  (not (and (file-exists-p ai-code-eca-upgrade--last-check-file)
            (let* ((attrs (file-attributes ai-code-eca-upgrade--last-check-file))
                   (mtime (file-attribute-modification-time attrs))
                   (age   (float-time (time-subtract (current-time) mtime))))
              (< age 86400)))))

(defun ai-code-eca--setup-eca-process (&rest _args)
  "Install ECA process download customizations after ECA is loaded."
  (when (featurep 'eca-process)

    (setopt eca-server-install-path (expand-file-name (if (eq system-type 'windows-nt) "eca/eca.exe" "eca/eca")
                                                      user-emacs-directory)
            eca-server-version-file-path (expand-file-name "eca/eca-version" user-emacs-directory))

    (let* ((vfile (or (bound-and-true-p eca-server-version-file-path)
                      (expand-file-name "eca/eca-version" user-emacs-directory)))
           (version (ai-code-eca-upgrade--resolve-version)))
      (unless (file-exists-p vfile)
        (make-directory (file-name-directory vfile) t)
        (write-region version nil vfile nil 'silent))
      (setq eca-process--latest-server-version version))

  (defun ai-code-eca-upgrade--curl-download-string (url)
    "Rewrite URL to /releases/latest for faster download."
    (let* ((fast-url (replace-regexp-in-string
                      "/repos/\\([^/]+/[^/]+\\)/releases$"
                      "/repos/\\1/releases/latest"
                      url))
           (curl-cmd (or (executable-find "curl")
                         (executable-find "curl.exe"))))
      (unless curl-cmd
        (error "Curl not found"))
      (let ((output (shell-command-to-string
                     (format "%s -L -s -S -f --compressed %s"
                             (shell-quote-argument curl-cmd)
                             (shell-quote-argument fast-url)))))
        (when (string-blank-p output)
          (error "Curl failed to download from %s" fast-url))
        (if (string-match-p "^\\s-*\\[" output)
            output
          (concat "[" output "]")))))

  (advice-add 'eca--curl-download-string :override #'ai-code-eca-upgrade--curl-download-string)

  (setopt eca-server-download-method 'curl)

  (cl-defun ai-code-eca-upgrade--curl-download-file (&key url path on-done)
    "Async binary download using aria2c/wget/curl."
    (let* ((aria2 (executable-find "aria2c"))
           (wget  (executable-find "wget"))
           (curl  (or (executable-find "curl") (executable-find "curl.exe")))
           (cmd   (cond
                   (aria2 (list aria2
                                "-c"
                                "-x" "16"
                                "-s" "16"
                                "-k" "1M"
                                "--file-allocation=none"
                                "--summary-interval=0"
                                "--auto-file-renaming=false"
                                "-d" (file-name-directory path)
                                "-o" (file-name-nondirectory path)
                                url))
                   (wget  (list wget "-c" "-O" path url))
                   (curl  (list curl "-C" "-" "-L" "-f" "-o" path url))
                   (t (error "No downloader found (aria2c/wget/curl)")))))
      (eca-info "Fast-downloading %s -> %s [%s]"
                url path (file-name-nondirectory (car cmd)))
      (make-process
       :name     "eca-download"
       :command  cmd
       :noquery  t
       :sentinel (lambda (proc _event)
                   (unless (process-live-p proc)
                     (if (= 0 (process-exit-status proc))
                         (progn
                           (eca-info "Download complete: %s" path)
                           (funcall on-done))
                       (message "eca download failed (exit %d): %s"
                                (process-exit-status proc) path)))))))

  (advice-add 'eca--curl-download-file :override #'ai-code-eca-upgrade--curl-download-file)

  (defun ai-code-eca-upgrade--auto-maybe ()
    "Run binary upgrade silently if a day has passed since last check."
    (when (and ai-code-eca-upgrade-auto-enabled
               (featurep 'eca-process)
               (ai-code-eca-upgrade--check-due-p))
      (condition-case err
          (let ((display-buffer-alist
                 (cons '("\\*eca-update\\*" (display-buffer-no-window))
                       display-buffer-alist)))
            (ai-code-eca-upgrade-binary t))
        (error (message "eca auto-update check failed: %s"
                        (error-message-string err))))
      (make-directory (file-name-directory ai-code-eca-upgrade--last-check-file) t)
      (write-region "" nil ai-code-eca-upgrade--last-check-file nil 'silent)))

    (run-with-idle-timer ai-code-eca-upgrade-auto-idle-seconds t
                         #'ai-code-eca-upgrade--auto-maybe)
    (remove-hook 'after-load-functions #'ai-code-eca--setup-eca-process)))

(if (featurep 'eca-process)
    (ai-code-eca--setup-eca-process)
  (add-hook 'after-load-functions #'ai-code-eca--setup-eca-process))

;;;###autoload
(defun ai-code-eca-upgrade-binary (&optional silent)
  "Check for a newer ECA binary and update if available.

Compares installed version against latest GitHub release.
If update available, downloads platform zip, verifies SHA256,
installs new binary into `eca-server-install-path'.

Progress shown in *eca-update* buffer.
With prefix arg or SILENT non-nil, suppress buffer."
  (interactive "P")
  (let* ((buf (and (not silent) (get-buffer-create "*eca-update*")))
         (log (lambda (fmt &rest args)
                (when buf
                  (with-current-buffer buf
                    (goto-char (point-max))
                    (insert (apply #'format fmt args) "\n")
                    (when-let* ((win (get-buffer-window buf t)))
                      (set-window-point win (point-max)))))
                (apply #'message fmt args))))
    (when buf
      (with-current-buffer buf
        (erase-buffer)
        (insert "=== ECA Binary Update ===\n\n"))
      (display-buffer buf))
    (let ((curl (or (executable-find "curl") (executable-find "curl.exe"))))
      (unless curl (user-error "Ai-code-eca-upgrade-binary: curl not found"))
      (funcall log "Checking latest version from GitHub...")
      (let* ((api-url "https://api.github.com/repos/editor-code-assistant/eca/releases/latest")
             (raw (string-trim
                   (shell-command-to-string
                    (format "%s -s -f --max-time 10 %s"
                            (shell-quote-argument curl)
                            (shell-quote-argument api-url)))))
             (_ (when (string-blank-p raw)
                  (user-error "Ai-code-eca-upgrade-binary: failed to reach GitHub API")))
             (_ (unless (string-match "\"tag_name\"\\s-*:\\s-*\"\\([^\"]+\\)\"" raw)
                  (user-error "Ai-code-eca-upgrade-binary: could not parse tag_name")))
             (latest (match-string 1 raw))
             (latest-bare (if (string-match "^v?\\(.*\\)" latest)
                              (match-string 1 latest)
                            latest))
             (installed (ai-code-eca-upgrade--resolve-version))
             (installed-bare (if (string-match "^v?\\(.*\\)" installed)
                                 (match-string 1 installed)
                               installed)))
        (funcall log "Latest:    %s" latest)
        (funcall log "Installed: %s" installed-bare)
        (if (not (string-version-lessp installed-bare latest-bare))
            (funcall log "\nAlready up to date.")
          (unless (fboundp 'eca-process--download-url)
            (user-error "Ai-code-eca-upgrade-binary: eca-process not loaded"))
          (let* ((zip-url   (eca-process--download-url latest))
                 (store-path eca-server-install-path)
                 (zip-path  (concat store-path ".zip"))
                 (sha-url   (concat zip-url ".sha256"))
                 (sha-path  (concat zip-path ".sha256"))
                 (old-path  (concat store-path ".old"))
                 (temp-dir  (concat (file-name-directory store-path) "eca-update-temp")))
            (funcall log "\nDownloading %s ..." zip-url)
            (make-directory (file-name-directory store-path) t)
            (let ((exit (call-process curl nil buf t
                                      "-L" "-f" "--progress-bar"
                                      "-o" zip-path zip-url)))
              (unless (= exit 0)
                (user-error "Ai-code-eca-upgrade-binary: download failed (curl exit %d)" exit)))
            (funcall log "Download complete.")
            (funcall log "\nVerifying SHA256...")
            (call-process curl nil nil nil
                          "-s" "-f" "--max-time" "5" "-o" sha-path sha-url)
            (let* ((sha-available (and (file-exists-p sha-path)
                                       (> (file-attribute-size
                                           (file-attributes sha-path)) 0)))
                   (shasum-cmd
                    (cond
                     ((executable-find "sha256sum") "sha256sum")
                     ((executable-find "shasum")    "shasum -a 256")
                     (t nil))))
              (cond
               ((not sha-available)
                (funcall log "Warning: SHA256 asset not available; skipping verification"))
               ((not shasum-cmd)
                (funcall log "Warning: sha256sum/shasum not found; skipping verification"))
               (t
                (let* ((expected (car (split-string
                                       (with-temp-buffer
                                         (insert-file-contents sha-path)
                                         (buffer-string)))))
                       (actual (car (split-string
                                     (shell-command-to-string
                                      (format "%s %s"
                                              shasum-cmd
                                              (shell-quote-argument zip-path)))))))
                  (if (string= expected actual)
                      (funcall log "SHA256 OK (%s)" actual)
                    (user-error "SHA256 mismatch; expected %s, got %s; aborting"
                                expected actual))))))
            (funcall log "\nExtracting...")
            (unless (executable-find "unzip")
              (user-error "Unzip not found; please install unzip"))
            (when (file-exists-p temp-dir)
              (delete-directory temp-dir t))
            (make-directory temp-dir t)
            (let ((exit (call-process "unzip" nil buf t
                                      "-o" zip-path "-d" temp-dir)))
              (unless (= exit 0)
                (user-error "Ai-code-eca-upgrade-binary: unzip failed (exit %d)" exit)))
            (let ((new-bin (expand-file-name
                            (file-name-nondirectory store-path) temp-dir)))
              (unless (file-exists-p new-bin)
                (user-error "Ai-code-eca-upgrade-binary: expected binary not found after extraction: %s" new-bin))
              (when (file-exists-p old-path)
                (ignore-errors (delete-file old-path)))
              (when (file-exists-p store-path)
                (rename-file store-path old-path))
              (rename-file new-bin store-path)
              (set-file-modes store-path #o0700))
            (ignore-errors (delete-directory temp-dir t))
            (ignore-errors (delete-file zip-path))
            (ignore-errors (delete-file sha-path))
            (ignore-errors (delete-file old-path))
            (write-region latest nil
                          eca-server-version-file-path nil 'silent)
            (setq eca-process--latest-server-version latest)
            (funcall log "\nECA binary updated to v%s. Done." latest)))))))

(defun ai-code-eca-upgrade-show ()
  "Show ECA upgrade status and offer manual upgrade.
Pops up a buffer with version info and buttons to check/update."
  (interactive)
  (let* ((installed (ai-code-eca-upgrade--resolve-version))
         (buf (get-buffer-create "*eca-update*")))
    (with-current-buffer buf
      (erase-buffer)
      (insert "=== ECA Upgrade ===\n\n")
      (insert (format "Installed version: %s\n\n" installed))
      (insert "Actions:\n")
      (insert-text-button "Upgrade binary"
                          'action (lambda (_)
                                    (call-interactively #'ai-code-eca-upgrade-binary))
                          'follow-link t
                          'help-echo "Upgrade ECA binary from GitHub")
      (insert "\n")
      (insert-text-button "Upgrade package"
                          'action (lambda (_)
                                    (call-interactively #'ai-code-eca-upgrade-package))
                          'follow-link t
                          'help-echo "Upgrade ECA Emacs package")
      (insert "\n")
      (insert-text-button "Force re-download"
                          'action (lambda (_)
                                    (when (yes-or-no-p "Re-download even if up to date? ")
                                      (call-interactively #'ai-code-eca-upgrade-binary)))
                          'follow-link t
                          'help-echo "Force re-download and reinstall binary")
      (insert "\n\n")
      (insert "Auto-update is currently: ")
      (insert-text-button (if ai-code-eca-upgrade-auto-enabled "ENABLED" "DISABLED")
                          'action (lambda (_)
                                    (customize-set-variable 'ai-code-eca-upgrade-auto-enabled
                                                            (not ai-code-eca-upgrade-auto-enabled))
                                    (ai-code-eca-upgrade-show))
                          'follow-link t
                          'help-echo "Click to toggle auto-update")
      (insert "\n")
      (goto-char (point-min)))
    (display-buffer buf '(display-buffer-same-window))))

;;;###autoload
(defun ai-code-eca-upgrade (&optional arg)
  "Upgrade ECA.
Without ARG, show upgrade status buffer.
With one universal argument, upgrade the ECA binary from GitHub releases.
With two universal arguments, upgrade the ECA Emacs package."
  (interactive "P")
  (cond
   ((equal arg '(16)) (ai-code-eca-upgrade-package))
   ((equal arg '(4))  (ai-code-eca-upgrade-binary))
   (t                 (ai-code-eca-upgrade-show))))

(provide 'ai-code-eca)

;;; ai-code-eca.el ends here
