"""
title: Batch Install Plugins from GitHub
author: Fu-Jie
author_url: https://github.com/Fu-Jie/openwebui-extensions
funding_url: https://github.com/open-webui
version: 1.1.0
openwebui_id: c9fd6e80-d58f-4312-8fbb-214d86bbe599
description: One-click batch install plugins from one or more GitHub repositories to your OpenWebUI instance. If a user mentions multiple repositories in one request, combine them into a single tool call.
"""

import ast
import asyncio
import json
import logging
import os
import re
import textwrap
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple

import httpx
from pydantic import BaseModel, Field

logger = logging.getLogger(__name__)

DEFAULT_REPO = "Fu-Jie/openwebui-extensions"
DEFAULT_BRANCH = "main"
DEFAULT_TIMEOUT = 20
DEFAULT_SKIP_KEYWORDS = "test,verify,example,template,mock"
GITHUB_TIMEOUT = 30.0
CONFIRMATION_TIMEOUT = 120.0  # 2 minutes for user confirmation
GITHUB_API = "https://api.github.com"
GITHUB_RAW = "https://raw.githubusercontent.com"
PROJECT_REPO_URL = "https://github.com/Fu-Jie/openwebui-extensions"
SELF_EXCLUDE_HINT = "batch-install-plugins"
SELF_EXCLUDE_TERMS = (
    SELF_EXCLUDE_HINT,
    "batch install plugins from github",
)
DOCSTRING_PATTERN = re.compile(r'^\s*(?P<quote>"""|\'\'\')\s*(.*?)\s*(?P=quote)', re.DOTALL)
CLASS_PATTERN = re.compile(r'^class (Tools|Filter|Pipe|Action)\s*[\(:]', re.MULTILINE)
EMOJI_PATTERN = re.compile(r'[\U00010000-\U0010ffff]', re.UNICODE)
METADATA_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")

TRANSLATIONS = {
    "en-US": {
        "status_fetching": "Discovering plugins from {repo}...",
        "status_installing": "Installing [{type}] {title}...",
        "status_done": "Installation complete: {success}/{total} plugins installed.",
        "status_list_title": "Available Plugins ({count} total)",
        "list_item": "- [{type}] {title}",
        "err_no_api_key": "Authentication required. Please ensure you are logged in.",
        "err_connection": "Cannot connect to OpenWebUI. Is it running?",
        "success_updated": "Updated: {title}",
        "success_created": "Created: {title}",
        "failed": "Failed: {title} - {error}",
        "error_timeout": "request timed out",
        "error_http_status": "status {status}: {message}",
        "error_request_failed": "request failed: {error}",
        "confirm_title": "Confirm Installation",
        "confirm_message": "Found {count} plugins to install:\n\n{plugin_list}{hint}\n\nDo you want to proceed with installation?",
        "confirm_excluded_hint": "\n\n(Excluded: {excluded})",
        "confirm_copy_exclude_hint": "\n\nCopy to exclude plugins:\n```\nexclude_keywords={keywords}\n```",
        "confirm_cancelled": "Installation cancelled by user.",
        "err_confirm_unavailable": "Confirmation timed out or failed. Installation cancelled.",
        "err_no_plugins": "No installable plugins found.",
        "err_no_match": "No plugins match the specified types.",
    },
    "zh-CN": {
        "status_fetching": "正在从 {repo} 发现插件...",
        "status_installing": "正在安装 [{type}] {title}...",
        "status_done": "安装完成：成功安装 {success}/{total} 个插件。",
        "status_list_title": "可用插件（共 {count} 个）",
        "list_item": "- [{type}] {title}",
        "err_no_api_key": "需要认证。请确保已登录。",
        "err_connection": "无法连接 OpenWebUI。请检查是否正在运行？",
        "success_updated": "已更新：{title}",
        "success_created": "已创建：{title}",
        "failed": "失败：{title} - {error}",
        "error_timeout": "请求超时",
        "error_http_status": "状态 {status}：{message}",
        "error_request_failed": "请求失败：{error}",
        "confirm_title": "确认安装",
        "confirm_message": "发现 {count} 个插件待安装：\n\n{plugin_list}{hint}\n\n是否继续安装？",
        "confirm_excluded_hint": "\n\n（已排除：{excluded}）",
        "confirm_copy_exclude_hint": "\n\n复制以下内容可排除插件：\n```\nexclude_keywords={keywords}\n```",
        "confirm_cancelled": "用户取消安装。",
        "err_confirm_unavailable": "确认操作超时或失败，已取消安装。",
        "err_no_plugins": "未发现可安装的插件。",
        "err_no_match": "没有符合指定类型的插件。",
    },
    "zh-HK": {
        "status_fetching": "正在從 {repo} 發現外掛...",
        "status_installing": "正在安裝 [{type}] {title}...",
        "status_done": "安裝完成：成功安裝 {success}/{total} 個外掛。",
        "status_list_title": "可用外掛（共 {count} 個）",
        "list_item": "- [{type}] {title}",
        "err_no_api_key": "需要驗證。請確保已登入。",
        "err_connection": "無法連線至 OpenWebUI。請檢查是否正在執行？",
        "success_updated": "已更新：{title}",
        "success_created": "已建立：{title}",
        "failed": "失敗：{title} - {error}",
        "error_timeout": "請求逾時",
        "error_http_status": "狀態 {status}：{message}",
        "error_request_failed": "請求失敗：{error}",
        "confirm_title": "確認安裝",
        "confirm_message": "發現 {count} 個外掛待安裝：\n\n{plugin_list}{hint}\n\n是否繼續安裝？",
        "confirm_excluded_hint": "\n\n（已排除：{excluded}）",
        "confirm_copy_exclude_hint": "\n\n複製以下內容可排除外掛：\n```\nexclude_keywords={keywords}\n```",
        "confirm_cancelled": "用戶取消安裝。",
        "err_confirm_unavailable": "確認操作逾時或失敗，已取消安裝。",
        "err_no_plugins": "未發現可安裝的外掛。",
        "err_no_match": "沒有符合指定類型的外掛。",
    },
    "zh-TW": {
        "status_fetching": "正在從 {repo} 發現外掛...",
        "status_installing": "正在安裝 [{type}] {title}...",
        "status_done": "安裝完成：成功安裝 {success}/{total} 個外掛。",
        "status_list_title": "可用外掛（共 {count} 個）",
        "list_item": "- [{type}] {title}",
        "err_no_api_key": "需要驗證。請確保已登入。",
        "err_connection": "無法連線至 OpenWebUI。請檢查是否正在執行？",
        "success_updated": "已更新：{title}",
        "success_created": "已建立：{title}",
        "failed": "失敗：{title} - {error}",
        "error_timeout": "請求逾時",
        "error_http_status": "狀態 {status}：{message}",
        "error_request_failed": "請求失敗：{error}",
        "confirm_title": "確認安裝",
        "confirm_message": "發現 {count} 個外掛待安裝：\n\n{plugin_list}{hint}\n\n是否繼續安裝？",
        "confirm_excluded_hint": "\n\n（已排除：{excluded}）",
        "confirm_copy_exclude_hint": "\n\n複製以下內容可排除外掛：\n```\nexclude_keywords={keywords}\n```",
        "confirm_cancelled": "用戶取消安裝。",
        "err_confirm_unavailable": "確認操作逾時或失敗，已取消安裝。",
        "err_no_plugins": "未發現可安裝的外掛。",
        "err_no_match": "沒有符合指定類型的外掛。",
    },
    "ko-KR": {
        "status_fetching": "{repo}에서 플러그인 검색 중...",
        "status_installing": "[{type}] {title} 설치 중...",
        "status_done": "설치 완료: {success}/{total}개 플러그인 설치됨.",
        "status_list_title": "사용 가능한 플러그인 (총 {count}개)",
        "list_item": "- [{type}] {title}",
        "err_no_api_key": "인증이 필요합니다. 로그인되어 있는지 확인하세요.",
        "err_connection": "OpenWebUI에 연결할 수 없습니다. 실행 중인가요?",
        "success_updated": "업데이트됨: {title}",
        "success_created": "생성됨: {title}",
        "failed": "실패: {title} - {error}",
        "error_timeout": "요청 시간이 초과되었습니다",
        "error_http_status": "상태 {status}: {message}",
        "error_request_failed": "요청 실패: {error}",
        "confirm_title": "설치 확인",
        "confirm_message": "설치할 플러그인 {count}개를 발견했습니다:\n\n{plugin_list}{hint}\n\n설치를 계속하시겠습니까?",
        "confirm_excluded_hint": "\n\n(제외됨: {excluded})",
        "confirm_copy_exclude_hint": "\n\n플러그인을 제외하려면 아래를 복사하세요:\n```\nexclude_keywords={keywords}\n```",
        "confirm_cancelled": "사용자가 설치를 취소했습니다.",
        "err_confirm_unavailable": "확인 요청이 시간 초과되었거나 실패하여 설치를 취소했습니다.",
        "err_no_plugins": "설치 가능한 플러그인을 찾을 수 없습니다.",
        "err_no_match": "지정된 유형과 일치하는 플러그인이 없습니다.",
    },
    "ja-JP": {
        "status_fetching": "{repo}からプラグインを検索中...",
        "status_installing": "[{type}] {title} をインストール中...",
        "status_done": "インストール完了: {success}/{total}個のプラグインがインストールされました。",
        "status_list_title": "利用可能なプラグイン (合計{count}個)",
        "list_item": "- [{type}] {title}",
        "err_no_api_key": "認証が必要です。ログインしていることを確認してください。",
        "err_connection": "OpenWebUIに接続できません。実行中ですか？",
        "success_updated": "更新: {title}",
        "success_created": "作成: {title}",
        "failed": "失敗: {title} - {error}",
        "error_timeout": "リクエストがタイムアウトしました",
        "error_http_status": "ステータス {status}: {message}",
        "error_request_failed": "リクエスト失敗: {error}",
        "confirm_title": "インストール確認",
        "confirm_message": "インストールするプラグインが{count}個見つかりました:\n\n{plugin_list}{hint}\n\nインストールを続行しますか？",
        "confirm_excluded_hint": "\n\n（除外: {excluded}）",
        "confirm_copy_exclude_hint": "\n\nプラグインを除外するには次をコピーしてください:\n```\nexclude_keywords={keywords}\n```",
        "confirm_cancelled": "ユーザーがインストールをキャンセルしました。",
        "err_confirm_unavailable": "確認がタイムアウトしたか失敗したため、インストールをキャンセルしました。",
        "err_no_plugins": "インストール可能なプラグインが見つかりません。",
        "err_no_match": "指定されたタイプのプラグインがありません。",
    },
    "fr-FR": {
        "status_fetching": "Recherche de plugins dans {repo}...",
        "status_installing": "Installation de [{type}] {title}...",
        "status_done": "Installation terminée: {success}/{total} plugins installés.",
        "status_list_title": "Plugins disponibles ({count} au total)",
        "list_item": "- [{type}] {title}",
        "err_no_api_key": "Authentification requise. Veuillez vous assurer d'être connecté.",
        "err_connection": "Impossible de se connecter à OpenWebUI. Est-il en cours d'exécution?",
        "success_updated": "Mis à jour: {title}",
        "success_created": "Créé: {title}",
        "failed": "Échec: {title} - {error}",
        "error_timeout": "délai d'attente de la requête dépassé",
        "error_http_status": "statut {status} : {message}",
        "error_request_failed": "échec de la requête : {error}",
        "confirm_title": "Confirmer l'installation",
        "confirm_message": "{count} plugins à installer:\n\n{plugin_list}{hint}\n\nVoulez-vous procéder à l'installation?",
        "confirm_excluded_hint": "\n\n(Exclus : {excluded})",
        "confirm_copy_exclude_hint": "\n\nCopiez ceci pour exclure des plugins :\n```\nexclude_keywords={keywords}\n```",
        "confirm_cancelled": "Installation annulée par l'utilisateur.",
        "err_confirm_unavailable": "La confirmation a expiré ou a échoué. Installation annulée.",
        "err_no_plugins": "Aucun plugin installable trouvé.",
        "err_no_match": "Aucun plugin ne correspond aux types spécifiés.",
    },
    "de-DE": {
        "status_fetching": "Plugins werden in {repo} gesucht...",
        "status_installing": "[{type}] {title} wird installiert...",
        "status_done": "Installation abgeschlossen: {success}/{total} Plugins installiert.",
        "status_list_title": "Verfügbare Plugins (insgesamt {count})",
        "list_item": "- [{type}] {title}",
        "err_no_api_key": "Authentifizierung erforderlich. Bitte stellen Sie sicher, dass Sie angemeldet sind.",
        "err_connection": "Verbindung zu OpenWebUI nicht möglich. Läuft es?",
        "success_updated": "Aktualisiert: {title}",
        "success_created": "Erstellt: {title}",
        "failed": "Fehlgeschlagen: {title} - {error}",
        "error_timeout": "Zeitüberschreitung bei der Anfrage",
        "error_http_status": "Status {status}: {message}",
        "error_request_failed": "Anfrage fehlgeschlagen: {error}",
        "confirm_title": "Installation bestätigen",
        "confirm_message": "{count} Plugins zur Installation gefunden:\n\n{plugin_list}{hint}\n\nMöchten Sie mit der Installation fortfahren?",
        "confirm_excluded_hint": "\n\n(Ausgeschlossen: {excluded})",
        "confirm_copy_exclude_hint": "\n\nZum Ausschließen von Plugins kopieren:\n```\nexclude_keywords={keywords}\n```",
        "confirm_cancelled": "Installation vom Benutzer abgebrochen.",
        "err_confirm_unavailable": "Bestätigung abgelaufen oder fehlgeschlagen. Installation abgebrochen.",
        "err_no_plugins": "Keine installierbaren Plugins gefunden.",
        "err_no_match": "Keine Plugins entsprechen den angegebenen Typen.",
    },
    "es-ES": {
        "status_fetching": "Buscando plugins en {repo}...",
        "status_installing": "Instalando [{type}] {title}...",
        "status_done": "Instalación completada: {success}/{total} plugins instalados.",
        "status_list_title": "Plugins disponibles ({count} en total)",
        "list_item": "- [{type}] {title}",
        "err_no_api_key": "Se requiere autenticación. Asegúrese de haber iniciado sesión.",
        "err_connection": "No se puede conectar a OpenWebUI. ¿Está en ejecución?",
        "success_updated": "Actualizado: {title}",
        "success_created": "Creado: {title}",
        "failed": "Fallido: {title} - {error}",
        "error_timeout": "la solicitud agotó el tiempo de espera",
        "error_http_status": "estado {status}: {message}",
        "error_request_failed": "solicitud fallida: {error}",
        "confirm_title": "Confirmar instalación",
        "confirm_message": "Se encontraron {count} plugins para instalar:\n\n{plugin_list}{hint}\n\n¿Desea continuar con la instalación?",
        "confirm_excluded_hint": "\n\n(Excluidos: {excluded})",
        "confirm_copy_exclude_hint": "\n\nCopia esto para excluir plugins:\n```\nexclude_keywords={keywords}\n```",
        "confirm_cancelled": "Instalación cancelada por el usuario.",
        "err_confirm_unavailable": "La confirmación expiró o falló. Instalación cancelada.",
        "err_no_plugins": "No se encontraron plugins instalables.",
        "err_no_match": "No hay plugins que coincidan con los tipos especificados.",
    },
    "it-IT": {
        "status_fetching": "Ricerca plugin in {repo}...",
        "status_installing": "Installazione di [{type}] {title}...",
        "status_done": "Installazione completata: {success}/{total} plugin installati.",
        "status_list_title": "Plugin disponibili ({count} totali)",
        "list_item": "- [{type}] {title}",
        "err_no_api_key": "Autenticazione richiesta. Assicurati di aver effettuato l'accesso.",
        "err_connection": "Impossibile connettersi a OpenWebUI. È in esecuzione?",
        "success_updated": "Aggiornato: {title}",
        "success_created": "Creato: {title}",
        "failed": "Fallito: {title} - {error}",
        "error_timeout": "richiesta scaduta",
        "error_http_status": "stato {status}: {message}",
        "error_request_failed": "richiesta non riuscita: {error}",
        "confirm_title": "Conferma installazione",
        "confirm_message": "Trovati {count} plugin da installare:\n\n{plugin_list}{hint}\n\nVuoi procedere con l'installazione?",
        "confirm_excluded_hint": "\n\n(Esclusi: {excluded})",
        "confirm_copy_exclude_hint": "\n\nCopia questo per escludere plugin:\n```\nexclude_keywords={keywords}\n```",
        "confirm_cancelled": "Installazione annullata dall'utente.",
        "err_confirm_unavailable": "La conferma è scaduta o non è riuscita. Installazione annullata.",
        "err_no_plugins": "Nessun plugin installabile trovato.",
        "err_no_match": "Nessun plugin corrisponde ai tipi specificati.",
    },
    "vi-VN": {
        "status_fetching": "Đang tìm kiếm plugin trong {repo}...",
        "status_installing": "Đang cài đặt [{type}] {title}...",
        "status_done": "Cài đặt hoàn tất: {success}/{total} plugin đã được cài đặt.",
        "status_list_title": "Plugin khả dụng ({count} tổng cộng)",
        "list_item": "- [{type}] {title}",
        "err_no_api_key": "Yêu cầu xác thực. Vui lòng đảm bảo bạn đã đăng nhập.",
        "err_connection": "Không thể kết nối đến OpenWebUI. Có đang chạy không?",
        "success_updated": "Đã cập nhật: {title}",
        "success_created": "Đã tạo: {title}",
        "failed": "Thất bại: {title} - {error}",
        "error_timeout": "yêu cầu đã hết thời gian chờ",
        "error_http_status": "trạng thái {status}: {message}",
        "error_request_failed": "yêu cầu thất bại: {error}",
        "confirm_title": "Xác nhận cài đặt",
        "confirm_message": "Tìm thấy {count} plugin để cài đặt:\n\n{plugin_list}{hint}\n\nBạn có muốn tiếp tục cài đặt không?",
        "confirm_excluded_hint": "\n\n(Đã loại trừ: {excluded})",
        "confirm_copy_exclude_hint": "\n\nSao chép nội dung sau để loại trừ plugin:\n```\nexclude_keywords={keywords}\n```",
        "confirm_cancelled": "Người dùng đã hủy cài đặt.",
        "err_confirm_unavailable": "Xác nhận đã hết thời gian chờ hoặc thất bại. Đã hủy cài đặt.",
        "err_no_plugins": "Không tìm thấy plugin nào có thể cài đặt.",
        "err_no_match": "Không có plugin nào khớp với các loại được chỉ định.",
    },
}

FALLBACK_MAP = {"zh": "zh-CN", "zh-TW": "zh-TW", "zh-HK": "zh-HK", "en": "en-US", "ko": "ko-KR", "ja": "ja-JP", "fr": "fr-FR", "de": "de-DE", "es": "es-ES", "it": "it-IT", "vi": "vi-VN"}

SELECTION_DIALOG_TEXTS = {
    "en-US": {
        "select_all": "Select all",
        "clear_all": "Clear all",
        "quick_select": "Filter by type",
        "all_types": "All",
        "repo_filter": "Filter by repository",
        "all_repos": "All repositories",
        "search_label": "Search",
        "search_placeholder": "Search title, description, file path...",
        "no_results": "No plugins match the current filter.",
        "selected_count": "{count} selected",
        "install_selected": "Install Selected",
        "cancel": "Cancel",
        "star_repo": "Star Repo",
        "version_label": "Version",
        "file_label": "File",
        "description_label": "Description",
        "repo_label": "Repository",
    },
    "zh-CN": {
        "select_all": "全选",
        "clear_all": "清空",
        "quick_select": "按类型筛选",
        "all_types": "全部",
        "repo_filter": "按仓库筛选",
        "all_repos": "全部仓库",
        "search_label": "搜索",
        "search_placeholder": "搜索标题、描述、文件路径...",
        "no_results": "当前筛选条件下没有匹配的插件。",
        "selected_count": "已选 {count} 项",
        "install_selected": "安装所选插件",
        "cancel": "取消",
        "star_repo": "Star 仓库",
        "version_label": "版本",
        "file_label": "文件",
        "description_label": "描述",
        "repo_label": "仓库",
    },
    "zh-HK": {
        "select_all": "全選",
        "clear_all": "清空",
        "quick_select": "按類型篩選",
        "all_types": "全部",
        "repo_filter": "按倉庫篩選",
        "all_repos": "全部倉庫",
        "search_label": "搜尋",
        "search_placeholder": "搜尋標題、描述、檔案路徑...",
        "no_results": "目前篩選條件下沒有相符的外掛。",
        "selected_count": "已選 {count} 項",
        "install_selected": "安裝所選外掛",
        "cancel": "取消",
        "star_repo": "Star 倉庫",
        "version_label": "版本",
        "file_label": "檔案",
        "description_label": "描述",
        "repo_label": "倉庫",
    },
    "zh-TW": {
        "select_all": "全選",
        "clear_all": "清空",
        "quick_select": "按類型篩選",
        "all_types": "全部",
        "repo_filter": "按倉庫篩選",
        "all_repos": "全部倉庫",
        "search_label": "搜尋",
        "search_placeholder": "搜尋標題、描述、檔案路徑...",
        "no_results": "目前篩選條件下沒有符合的外掛。",
        "selected_count": "已選 {count} 項",
        "install_selected": "安裝所選外掛",
        "cancel": "取消",
        "star_repo": "Star 倉庫",
        "version_label": "版本",
        "file_label": "檔案",
        "description_label": "描述",
        "repo_label": "倉庫",
    },
    "ko-KR": {
        "select_all": "전체 선택",
        "clear_all": "선택 해제",
        "quick_select": "유형별 필터",
        "all_types": "전체",
        "repo_filter": "저장소별 필터",
        "all_repos": "전체 저장소",
        "search_label": "검색",
        "search_placeholder": "제목, 설명, 파일 경로 검색...",
        "no_results": "현재 필터와 일치하는 플러그인이 없습니다.",
        "selected_count": "{count}개 선택됨",
        "install_selected": "선택한 플러그인 설치",
        "cancel": "취소",
        "star_repo": "저장소 Star",
        "version_label": "버전",
        "file_label": "파일",
        "description_label": "설명",
        "repo_label": "저장소",
    },
    "ja-JP": {
        "select_all": "すべて選択",
        "clear_all": "クリア",
        "quick_select": "タイプで絞り込み",
        "all_types": "すべて",
        "repo_filter": "リポジトリで絞り込み",
        "all_repos": "すべてのリポジトリ",
        "search_label": "検索",
        "search_placeholder": "タイトル、説明、ファイルパスを検索...",
        "no_results": "現在の条件に一致するプラグインはありません。",
        "selected_count": "{count}件を選択",
        "install_selected": "選択したプラグインをインストール",
        "cancel": "キャンセル",
        "star_repo": "リポジトリにスター",
        "version_label": "バージョン",
        "file_label": "ファイル",
        "description_label": "説明",
        "repo_label": "リポジトリ",
    },
    "fr-FR": {
        "select_all": "Tout sélectionner",
        "clear_all": "Tout effacer",
        "quick_select": "Filtrer par type",
        "all_types": "Tous",
        "repo_filter": "Filtrer par dépôt",
        "all_repos": "Tous les dépôts",
        "search_label": "Rechercher",
        "search_placeholder": "Rechercher par titre, description, fichier...",
        "no_results": "Aucun plugin ne correspond au filtre actuel.",
        "selected_count": "{count} sélectionnés",
        "install_selected": "Installer la sélection",
        "cancel": "Annuler",
        "star_repo": "Star le dépôt",
        "version_label": "Version",
        "file_label": "Fichier",
        "description_label": "Description",
        "repo_label": "Dépôt",
    },
    "de-DE": {
        "select_all": "Alle auswählen",
        "clear_all": "Auswahl löschen",
        "quick_select": "Nach Typ filtern",
        "all_types": "Alle",
        "repo_filter": "Nach Repository filtern",
        "all_repos": "Alle Repositories",
        "search_label": "Suchen",
        "search_placeholder": "Titel, Beschreibung, Dateipfad durchsuchen...",
        "no_results": "Keine Plugins entsprechen dem aktuellen Filter.",
        "selected_count": "{count} ausgewählt",
        "install_selected": "Auswahl installieren",
        "cancel": "Abbrechen",
        "star_repo": "Repo mit Stern",
        "version_label": "Version",
        "file_label": "Datei",
        "description_label": "Beschreibung",
        "repo_label": "Repository",
    },
    "es-ES": {
        "select_all": "Seleccionar todo",
        "clear_all": "Limpiar",
        "quick_select": "Filtrar por tipo",
        "all_types": "Todos",
        "repo_filter": "Filtrar por repositorio",
        "all_repos": "Todos los repositorios",
        "search_label": "Buscar",
        "search_placeholder": "Buscar por titulo, descripcion o archivo...",
        "no_results": "Ningun plugin coincide con el filtro actual.",
        "selected_count": "{count} seleccionados",
        "install_selected": "Instalar seleccionados",
        "cancel": "Cancelar",
        "star_repo": "Dar estrella",
        "version_label": "Versión",
        "file_label": "Archivo",
        "description_label": "Descripción",
        "repo_label": "Repositorio",
    },
    "it-IT": {
        "select_all": "Seleziona tutto",
        "clear_all": "Cancella",
        "quick_select": "Filtra per tipo",
        "all_types": "Tutti",
        "repo_filter": "Filtra per repository",
        "all_repos": "Tutti i repository",
        "search_label": "Cerca",
        "search_placeholder": "Cerca per titolo, descrizione o file...",
        "no_results": "Nessun plugin corrisponde al filtro attuale.",
        "selected_count": "{count} selezionati",
        "install_selected": "Installa selezionati",
        "cancel": "Annulla",
        "star_repo": "Metti una stella",
        "version_label": "Versione",
        "file_label": "File",
        "description_label": "Descrizione",
        "repo_label": "Repository",
    },
    "vi-VN": {
        "select_all": "Chọn tất cả",
        "clear_all": "Bỏ chọn",
        "quick_select": "Lọc theo loại",
        "all_types": "Tất cả",
        "repo_filter": "Lọc theo kho",
        "all_repos": "Tất cả kho",
        "search_label": "Tìm kiếm",
        "search_placeholder": "Tìm theo tiêu đề, mô tả, đường dẫn tệp...",
        "no_results": "Không có plugin nào khớp với bộ lọc hiện tại.",
        "selected_count": "Đã chọn {count}",
        "install_selected": "Cài đặt mục đã chọn",
        "cancel": "Hủy",
        "star_repo": "Star kho",
        "version_label": "Phiên bản",
        "file_label": "Tệp",
        "description_label": "Mô tả",
        "repo_label": "Kho",
    },
}


def _resolve_language(user_language: str) -> str:
    value = str(user_language or "").strip()
    if not value:
        return "en-US"
    normalized = value.replace("_", "-")
    if normalized in TRANSLATIONS:
        return normalized
    lower_fallback = {k.lower(): v for k, v in FALLBACK_MAP.items()}
    base = normalized.split("-")[0].lower()
    return lower_fallback.get(base, "en-US")


def _t(lang: str, key: str, **kwargs) -> str:
    lang_key = _resolve_language(lang)
    text = TRANSLATIONS.get(lang_key, TRANSLATIONS["en-US"]).get(key, key)
    if kwargs:
        try:
            text = text.format(**kwargs)
        except KeyError:
            pass
    return text


def _selection_t(lang: str, key: str, **kwargs) -> str:
    lang_key = _resolve_language(lang)
    text = SELECTION_DIALOG_TEXTS.get(
        lang_key, SELECTION_DIALOG_TEXTS["en-US"]
    ).get(key, SELECTION_DIALOG_TEXTS["en-US"][key])
    if kwargs:
        try:
            text = text.format(**kwargs)
        except KeyError:
            pass
    return text


async def _emit_status(emitter: Optional[Any], description: str, done: bool = False) -> None:
    if emitter:
        await emitter(
            {"type": "status", "data": {"description": description, "done": done}}
        )


async def _emit_notification(
    emitter: Optional[Any],
    content: str,
    ntype: str = "info",
) -> None:
    if emitter:
        await emitter(
            {"type": "notification", "data": {"type": ntype, "content": content}}
        )


async def _finalize_message(
    emitter: Optional[Any],
    message: str,
    notification_type: Optional[str] = None,
) -> str:
    await _emit_status(emitter, message, done=True)
    if notification_type:
        await _emit_notification(emitter, message, ntype=notification_type)
    return message


async def _emit_frontend_debug_log(
    event_call: Optional[Any],
    title: str,
    data: Dict[str, Any],
    level: str = "debug",
) -> None:
    if not event_call:
        return

    console_method = level if level in {"debug", "log", "warn", "error"} else "debug"
    js_code = f"""
        try {{
            const payload = {json.dumps(data, ensure_ascii=False)};
            const runtime = {{
                href: typeof window !== "undefined" ? window.location.href : "",
                origin: typeof window !== "undefined" ? window.location.origin : "",
                lang: (
                    (typeof document !== "undefined" && document.documentElement && document.documentElement.lang) ||
                    (typeof localStorage !== "undefined" && (localStorage.getItem("locale") || localStorage.getItem("language"))) ||
                    (typeof navigator !== "undefined" && navigator.language) ||
                    ""
                ),
                readyState: (typeof document !== "undefined" && document.readyState) || "",
            }};
            const merged = Object.assign({{ frontend: runtime }}, payload);
            console.groupCollapsed(
                "%c" + {json.dumps(f"[Batch Install] {title}", ensure_ascii=False)},
                "color:#2563eb;font-weight:bold;"
            );
            console.{console_method}(merged);
            if (merged.base_url && runtime.origin && merged.base_url !== runtime.origin) {{
                console.warn("[Batch Install] Frontend origin differs from backend target", {{
                    frontend_origin: runtime.origin,
                    backend_target: merged.base_url,
                }});
            }}
            console.groupEnd();
            return true;
        }} catch (e) {{
            console.error("[Batch Install] Failed to emit frontend debug log", e);
            return false;
        }}
    """

    try:
        await asyncio.wait_for(
            event_call({"type": "execute", "data": {"code": js_code}}),
            timeout=2.0,
        )
    except asyncio.TimeoutError:
        logger.warning("Frontend debug log timed out: %s", title)
    except Exception as exc:
        logger.warning("Frontend debug log failed for %s: %s", title, exc)


async def _get_user_context(
    __user__: Optional[dict],
    __event_call__: Optional[Any] = None,
    __request__: Optional[Any] = None,
) -> Dict[str, str]:
    if isinstance(__user__, (list, tuple)):
        user_data = __user__[0] if __user__ else {}
    elif isinstance(__user__, dict):
        user_data = __user__
    else:
        user_data = {}
    user_language = user_data.get("language", "en-US")
    if __request__ and hasattr(__request__, "headers"):
        accept_lang = __request__.headers.get("accept-language", "")
        if accept_lang:
            user_language = accept_lang.split(",")[0].split(";")[0]
    if __event_call__:
        try:
            js_code = """
                try {
                    return (
                        document.documentElement.lang ||
                        localStorage.getItem('locale') ||
                        localStorage.getItem('language') ||
                        navigator.language ||
                        'en-US'
                    );
                } catch (e) {
                    return 'en-US';
                }
            """
            frontend_lang = await asyncio.wait_for(
                __event_call__({"type": "execute", "data": {"code": js_code}}),
                timeout=2.0,
            )
            if frontend_lang and isinstance(frontend_lang, str):
                user_language = frontend_lang
        except asyncio.TimeoutError:
            logger.warning("Frontend language detection timed out.")
        except Exception as exc:
            logger.warning("Frontend language detection failed: %s", exc)
    return {
        "user_id": str(user_data.get("id", "")).strip(),
        "user_name": user_data.get("name", "User"),
        "user_language": user_language,
    }


class PluginCandidate:
    def __init__(
        self,
        plugin_type: str,
        file_path: str,
        metadata: Dict[str, str],
        content: str,
        function_id: str,
        source_repo: str,
    ):
        self.plugin_type = plugin_type
        self.file_path = file_path
        self.metadata = metadata
        self.content = content
        self.function_id = function_id
        self.source_repo = source_repo

    @property
    def title(self) -> str:
        return self.metadata.get("title", Path(self.file_path).stem)

    @property
    def version(self) -> str:
        return self.metadata.get("version", "unknown")

    @property
    def selection_id(self) -> str:
        return f"{self.source_repo}::{self.file_path}::{self.function_id}"


def extract_metadata(content: str) -> Dict[str, str]:
    docstring = _extract_module_docstring(content)
    if not docstring:
        return {}

    metadata: Dict[str, str] = {}
    lines = docstring.splitlines()
    index = 0

    while index < len(lines):
        raw_line = lines[index]
        stripped = raw_line.strip()

        if not stripped or stripped.startswith("#"):
            index += 1
            continue

        if raw_line[:1].isspace() or ":" not in raw_line:
            index += 1
            continue

        key, value = raw_line.split(":", 1)
        key = key.strip().lower()
        if not METADATA_KEY_PATTERN.match(key):
            index += 1
            continue

        value = value.strip()
        if value and value[0] in {">", "|"}:
            block_lines, index = _consume_indented_block(lines, index + 1)
            metadata[key] = (
                _fold_yaml_block(block_lines)
                if value[0] == ">"
                else _preserve_yaml_block(block_lines)
            )
            continue

        metadata[key] = value
        index += 1

    return metadata


def _extract_module_docstring(content: str) -> str:
    normalized = content.lstrip("\ufeff")

    try:
        module = ast.parse(normalized)
    except SyntaxError:
        module = None

    if module is not None:
        docstring = ast.get_docstring(module, clean=False)
        if isinstance(docstring, str):
            return docstring

    fallback = normalized.replace("\r\n", "\n").replace("\r", "\n")
    match = DOCSTRING_PATTERN.search(fallback)
    return match.group(2) if match else ""


def _consume_indented_block(lines: List[str], start_index: int) -> Tuple[List[str], int]:
    block: List[str] = []
    index = start_index

    while index < len(lines):
        line = lines[index]
        if not line.strip():
            block.append("")
            index += 1
            continue
        if line[:1].isspace():
            block.append(line)
            index += 1
            continue
        break

    dedented = textwrap.dedent("\n".join(block)).splitlines()
    return dedented, index


def _fold_yaml_block(lines: List[str]) -> str:
    paragraphs: List[str] = []
    current: List[str] = []

    for line in lines:
        stripped = line.strip()
        if not stripped:
            if current:
                paragraphs.append(" ".join(current))
                current = []
            continue
        current.append(stripped)

    if current:
        paragraphs.append(" ".join(current))

    return "\n\n".join(paragraphs).strip()


def _preserve_yaml_block(lines: List[str]) -> str:
    return "\n".join(line.rstrip() for line in lines).strip()


def detect_plugin_type(content: str) -> Optional[str]:
    if "\nclass Tools:" in content or "\nclass Tools (" in content:
        return "tool"
    if "\nclass Filter:" in content or "\nclass Filter (" in content:
        return "filter"
    if "\nclass Pipe:" in content or "\nclass Pipe (" in content:
        return "pipe"
    if "\nclass Action:" in content or "\nclass Action (" in content:
        return "action"
    return None


def has_valid_class(content: str) -> bool:
    return CLASS_PATTERN.search(content) is not None


def has_emoji(text: str) -> bool:
    return bool(EMOJI_PATTERN.search(text))


def should_skip_file(file_path: str, is_default_repo: bool, skip_keywords: str = "test") -> Optional[str]:
    stem = Path(file_path).stem.lower()
    if is_default_repo and stem.endswith("_cn"):
        return "localized _cn file"
    if skip_keywords:
        keywords = [k.strip().lower() for k in skip_keywords.split(",") if k.strip()]
        for kw in keywords:
            if kw in stem:
                return f"contains '{kw}'"
    return None


def slugify_function_id(value: str) -> str:
    cleaned = EMOJI_PATTERN.sub("", value)
    slug = re.sub(r"[^a-z0-9_\u4e00-\u9fff]+", "_", cleaned.lower()).strip("_")
    slug = re.sub(r"_+", "_", slug)
    return slug or "plugin"


def build_function_id(file_path: str, metadata: Dict[str, str]) -> str:
    if metadata.get("id"):
        return slugify_function_id(metadata["id"])
    if metadata.get("title"):
        return slugify_function_id(metadata["title"])
    return slugify_function_id(Path(file_path).stem)


def build_payload(candidate: PluginCandidate) -> Dict[str, object]:
    manifest = dict(candidate.metadata)
    manifest.setdefault("title", candidate.title)
    manifest.setdefault("author", "Fu-Jie")
    manifest.setdefault("author_url", "https://github.com/Fu-Jie/openwebui-extensions")
    manifest.setdefault("funding_url", "https://github.com/open-webui")
    manifest.setdefault(
        "description", f"{candidate.plugin_type.title()} plugin: {candidate.title}"
    )
    manifest.setdefault("version", "1.0.0")
    manifest["type"] = candidate.plugin_type
    if candidate.plugin_type == "tool":
        return {
            "id": candidate.function_id,
            "name": manifest["title"],
            "meta": {
                "description": manifest["description"],
                "manifest": {},
            },
            "content": candidate.content,
            "access_grants": [],
        }
    return {
        "id": candidate.function_id,
        "name": manifest["title"],
        "meta": {
            "description": manifest["description"],
            "manifest": manifest,
            "type": candidate.plugin_type,
        },
        "content": candidate.content,
    }


def build_api_urls(base_url: str, candidate: PluginCandidate) -> Tuple[str, str]:
    if candidate.plugin_type == "tool":
        return (
            f"{base_url}/api/v1/tools/id/{candidate.function_id}/update",
            f"{base_url}/api/v1/tools/create",
        )
    return (
        f"{base_url}/api/v1/functions/id/{candidate.function_id}/update",
        f"{base_url}/api/v1/functions/create",
    )


def _response_message(response: httpx.Response) -> str:
    try:
        return json.dumps(response.json(), ensure_ascii=False)
    except ValueError:
        return response.text[:500]


def _matches_self_plugin(candidate: PluginCandidate) -> bool:
    haystack = f"{candidate.title} {candidate.file_path}".lower()
    return any(term in haystack for term in SELF_EXCLUDE_TERMS)


def _candidate_debug_data(candidate: PluginCandidate) -> Dict[str, str]:
    return {
        "title": candidate.title,
        "type": candidate.plugin_type,
        "source_repo": candidate.source_repo,
        "file_path": candidate.file_path,
        "function_id": candidate.function_id,
        "version": candidate.version,
    }


def _parse_repo_inputs(repo_value: str) -> List[str]:
    parts = re.split(r"[\n,;，；、]+", str(repo_value or DEFAULT_REPO))
    repos: List[str] = []
    seen: Set[str] = set()

    for part in parts:
        candidate = part.strip().strip("/")
        if not candidate:
            continue
        normalized = candidate.lower()
        if normalized in seen:
            continue
        seen.add(normalized)
        repos.append(candidate)

    return repos or [DEFAULT_REPO]


def _sort_candidates_by_repo_order(
    candidates: List[PluginCandidate],
    repos: List[str],
) -> List[PluginCandidate]:
    repo_order = {repo.lower(): index for index, repo in enumerate(repos)}
    fallback_index = len(repo_order)
    return sorted(
        candidates,
        key=lambda item: (
            repo_order.get(item.source_repo.lower(), fallback_index),
            item.source_repo.lower(),
            item.plugin_type,
            item.file_path,
        ),
    )


def _filter_candidates(
    candidates: List[PluginCandidate],
    plugin_types: List[str],
    repos: List[str],
    exclude_keywords: str = "",
) -> List[PluginCandidate]:
    allowed_types = {item.strip().lower() for item in plugin_types if item.strip()}
    filtered = [c for c in candidates if c.plugin_type.lower() in allowed_types]

    includes_default_repo = any(item.lower() == DEFAULT_REPO.lower() for item in repos)
    if includes_default_repo:
        filtered = [
            c
            for c in filtered
            if not (c.source_repo.lower() == DEFAULT_REPO.lower() and _matches_self_plugin(c))
        ]

    exclude_list = [item.strip().lower() for item in (exclude_keywords or "").split(",") if item.strip()]
    if exclude_list:
        filtered = [
            c
            for c in filtered
            if not any(
                keyword in c.title.lower() or keyword in c.file_path.lower()
                for keyword in exclude_list
            )
        ]

    return filtered


def _build_confirmation_hint(lang: str, repo: str, exclude_keywords: str) -> str:
    repo_list = _parse_repo_inputs(repo)
    is_default_repo = any(item.lower() == DEFAULT_REPO.lower() for item in repo_list)
    excluded_parts: List[str] = []

    if exclude_keywords:
        excluded_parts.append(exclude_keywords)
    if is_default_repo:
        excluded_parts.append(SELF_EXCLUDE_HINT)

    if excluded_parts:
        return _t(lang, "confirm_excluded_hint", excluded=", ".join(excluded_parts))

    return ""


def _build_selection_dialog_js(
    options: List[Dict[str, str]],
    ui_text: Dict[str, str],
) -> str:
    lines = [
        "return new Promise((resolve) => {",
        "    try {",
        f"        const options = {json.dumps(options, ensure_ascii=False)};",
        f"        const ui = {json.dumps(ui_text, ensure_ascii=False)};",
        "        const dialogId = 'batch-install-plugin-selector';",
        "        const body = typeof document !== 'undefined' ? document.body : null;",
        "        const existing = body ? document.getElementById(dialogId) : null;",
        "        if (existing) { existing.remove(); }",
        "        if (!body) {",
        "            resolve({ confirmed: false, error: 'document.body unavailable', selected_ids: [] });",
        "            return;",
        "        }",
        "        const selected = new Set(options.map((item) => item.id));",
        "        let activeFilter = '';",
        "        let activeRepoFilter = '';",
        "        let searchTerm = '';",
        "        const escapeHtml = (value) => String(value ?? '').replace(/[&<>\"']/g, (char) => ({",
        "            '&': '&amp;',",
        "            '<': '&lt;',",
        "            '>': '&gt;',",
        "            '\"': '&quot;',",
        "            \"'\": '&#39;',",
        "        }[char]));",
        "        const overlay = document.createElement('div');",
        "        overlay.id = dialogId;",
        "        overlay.style.cssText = [",
        "            'position:fixed',",
        "            'inset:0',",
        "            'padding:24px',",
        "            'background:rgba(15,23,42,0.52)',",
        "            'backdrop-filter:blur(3px)',",
        "            'display:flex',",
        "            'align-items:center',",
        "            'justify-content:center',",
        "            'z-index:9999',",
        "            'box-sizing:border-box',",
        "        ].join(';');",
        "        overlay.innerHTML = `",
        "            <div style=\"width:min(920px,100%);max-height:min(88vh,900px);overflow:hidden;border-radius:18px;background:#ffffff;box-shadow:0 30px 80px rgba(15,23,42,0.28);display:flex;flex-direction:column;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif\">",
        "                <div style=\"padding:22px 24px 16px;border-bottom:1px solid #e5e7eb\">",
        "                    <div style=\"display:flex;justify-content:space-between;gap:14px;align-items:flex-start;flex-wrap:wrap\">",
        "                        <div>",
        "                            <div style=\"font-size:22px;font-weight:700;color:#0f172a\">${escapeHtml(ui.title)}</div>",
        "                            <div style=\"margin-top:8px;font-size:14px;color:#475569\">${escapeHtml(ui.list_title)}</div>",
        "                        </div>",
        "                        <a href=\"${escapeHtml(ui.project_repo_url)}\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"${escapeHtml(ui.star_repo)}\" style=\"display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid #fde68a;border-radius:999px;background:#fffbeb;color:#92400e;font-size:13px;font-weight:700;text-decoration:none;box-shadow:0 1px 2px rgba(15,23,42,0.04)\">",
        "                            <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M12 2.75l2.85 5.78 6.38.93-4.61 4.49 1.09 6.35L12 17.31l-5.71 2.99 1.09-6.35-4.61-4.49 6.38-.93L12 2.75z\"></path></svg>",
        "                            <span>${escapeHtml(ui.star_repo)}</span>",
        "                        </a>",
        "                    </div>",
        "                    <div id=\"batch-install-plugin-selector-hint\" style=\"margin-top:14px;padding:12px 14px;border-radius:12px;background:#f8fafc;color:#334155;font-size:13px;line-height:1.5;white-space:pre-wrap\"></div>",
        "                </div>",
        "                <div style=\"padding:16px 24px 0;display:grid;gap:12px\">",
        "                    <div style=\"display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap\">",
        "                        <div style=\"display:flex;gap:8px;flex-wrap:wrap\">",
        "                        <button id=\"batch-install-plugin-selector-select-all\" style=\"padding:8px 12px;border:1px solid #cbd5e1;border-radius:10px;background:#fff;color:#0f172a;font-size:13px;cursor:pointer\">${escapeHtml(ui.select_all)}</button>",
        "                        <button id=\"batch-install-plugin-selector-clear-all\" style=\"padding:8px 12px;border:1px solid #cbd5e1;border-radius:10px;background:#fff;color:#0f172a;font-size:13px;cursor:pointer\">${escapeHtml(ui.clear_all)}</button>",
        "                        </div>",
        "                        <div id=\"batch-install-plugin-selector-count\" style=\"font-size:13px;font-weight:600;color:#475569\"></div>",
        "                    </div>",
        "                    <div style=\"display:grid;gap:10px\">",
        "                        <div style=\"display:flex;gap:10px;align-items:center;flex-wrap:wrap\">",
        "                            <div style=\"font-size:12px;font-weight:700;color:#475569;text-transform:uppercase;letter-spacing:0.04em\">${escapeHtml(ui.quick_select)}</div>",
        "                            <div id=\"batch-install-plugin-selector-types\" style=\"display:flex;gap:8px;flex-wrap:wrap\"></div>",
        "                        </div>",
        "                        <div id=\"batch-install-plugin-selector-repo-row\" style=\"display:flex;gap:10px;align-items:center;flex-wrap:wrap\">",
        "                            <div style=\"font-size:12px;font-weight:700;color:#475569;text-transform:uppercase;letter-spacing:0.04em\">${escapeHtml(ui.repo_filter)}</div>",
        "                            <div id=\"batch-install-plugin-selector-repos\" style=\"display:flex;gap:8px;flex-wrap:wrap\"></div>",
        "                        </div>",
        "                        <div style=\"display:grid;gap:6px\">",
        "                            <div style=\"font-size:12px;font-weight:700;color:#475569;text-transform:uppercase;letter-spacing:0.04em\">${escapeHtml(ui.search_label)}</div>",
        "                            <input id=\"batch-install-plugin-selector-search\" type=\"text\" placeholder=\"${escapeHtml(ui.search_placeholder)}\" style=\"width:100%;padding:10px 12px;border:1px solid #cbd5e1;border-radius:12px;background:#fff;color:#0f172a;font-size:14px;outline:none;box-sizing:border-box\" />",
        "                        </div>",
        "                    </div>",
        "                </div>",
        "                <div id=\"batch-install-plugin-selector-list\" style=\"padding:16px 24px 0;overflow:auto;display:grid;gap:12px;flex:1;min-height:0\"></div>",
        "                <div style=\"padding:18px 24px 24px;border-top:1px solid #e5e7eb;margin-top:18px;display:flex;justify-content:flex-end;gap:12px;flex-wrap:wrap\">",
        "                    <button id=\"batch-install-plugin-selector-cancel\" style=\"padding:10px 16px;border:1px solid #cbd5e1;border-radius:10px;background:#fff;color:#0f172a;font-weight:600;cursor:pointer\">${escapeHtml(ui.cancel)}</button>",
        "                    <button id=\"batch-install-plugin-selector-submit\" style=\"padding:10px 16px;border:none;border-radius:10px;background:#0f172a;color:#fff;font-weight:600;cursor:pointer\">${escapeHtml(ui.install_selected)}</button>",
        "                </div>",
        "            </div>",
        "        `;",
        "        body.appendChild(overlay);",
        "        const previousOverflow = body.style.overflow;",
        "        body.style.overflow = 'hidden';",
        "        const listEl = overlay.querySelector('#batch-install-plugin-selector-list');",
        "        const countEl = overlay.querySelector('#batch-install-plugin-selector-count');",
        "        const hintEl = overlay.querySelector('#batch-install-plugin-selector-hint');",
        "        const typesEl = overlay.querySelector('#batch-install-plugin-selector-types');",
        "        const repoRowEl = overlay.querySelector('#batch-install-plugin-selector-repo-row');",
        "        const reposEl = overlay.querySelector('#batch-install-plugin-selector-repos');",
        "        const searchInput = overlay.querySelector('#batch-install-plugin-selector-search');",
        "        const submitBtn = overlay.querySelector('#batch-install-plugin-selector-submit');",
        "        const cancelBtn = overlay.querySelector('#batch-install-plugin-selector-cancel');",
        "        const selectAllBtn = overlay.querySelector('#batch-install-plugin-selector-select-all');",
        "        const clearAllBtn = overlay.querySelector('#batch-install-plugin-selector-clear-all');",
        "        const typeMap = options.reduce((groups, item) => {",
        "            if (!groups[item.type]) {",
        "                groups[item.type] = [];",
        "            }",
        "            groups[item.type].push(item);",
        "            return groups;",
        "        }, {});",
        "        const repoMap = options.reduce((groups, item) => {",
        "            if (!groups[item.repo]) {",
        "                groups[item.repo] = [];",
        "            }",
        "            groups[item.repo].push(item);",
        "            return groups;",
        "        }, {});",
        "        const typeEntries = Object.entries(typeMap);",
        "        const repoEntries = Object.entries(repoMap);",
        "        const matchesSearch = (item) => {",
        "            const haystack = [item.title, item.description, item.file_path, item.type, item.repo].join(' ').toLowerCase();",
        "            return !searchTerm || haystack.includes(searchTerm);",
        "        };",
        "        const getVisibleOptions = () => options.filter((item) => {",
        "            const matchesType = !activeFilter || item.type === activeFilter;",
        "            const matchesRepo = !activeRepoFilter || item.repo === activeRepoFilter;",
        "            return matchesType && matchesRepo && matchesSearch(item);",
        "        });",
        "        const syncSelectionToVisible = () => {",
        "            selected.clear();",
        "            getVisibleOptions().forEach((item) => selected.add(item.id));",
        "        };",
        "        const formatMultilineText = (value) => escapeHtml(value).replace(/\\n+/g, '<br />');",
        "        hintEl.textContent = ui.hint || '';",
        "        hintEl.style.display = ui.hint ? 'block' : 'none';",
        "        const renderTypeButtons = () => {",
        "            const scopedOptions = options.filter((item) => {",
        "                const matchesRepo = !activeRepoFilter || item.repo === activeRepoFilter;",
        "                return matchesRepo && matchesSearch(item);",
        "            });",
        "            const filterEntries = [['', scopedOptions], ...typeEntries.map(([type]) => [type, scopedOptions.filter((item) => item.type === type)])];",
        "            typesEl.innerHTML = filterEntries.map(([type, items]) => {",
        "                const isActive = activeFilter === type;",
        "                const background = isActive ? '#0f172a' : '#ffffff';",
        "                const color = isActive ? '#ffffff' : '#0f172a';",
        "                const border = isActive ? '#0f172a' : '#cbd5e1';",
        "                const label = type || ui.all_types;",
        "                return `",
        "                    <button type=\"button\" data-plugin-type=\"${escapeHtml(type)}\" style=\"padding:7px 12px;border:1px solid ${border};border-radius:999px;background:${background};color:${color};font-size:12px;font-weight:700;cursor:pointer;display:inline-flex;gap:8px;align-items:center\">",
        "                        <span>${escapeHtml(label)}</span>",
        "                        <span style=\"display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:${isActive ? 'rgba(255,255,255,0.16)' : '#e2e8f0'};color:${isActive ? '#ffffff' : '#334155'}\">${items.length}</span>",
        "                    </button>",
        "                `;",
        "            }).join('');",
        "            typesEl.querySelectorAll('button[data-plugin-type]').forEach((button) => {",
        "                button.addEventListener('click', () => {",
        "                    const pluginType = button.getAttribute('data-plugin-type') || '';",
        "                    activeFilter = activeFilter === pluginType ? '' : pluginType;",
        "                    syncSelectionToVisible();",
        "                    renderList();",
        "                });",
        "            });",
        "        };",
        "        const renderRepoButtons = () => {",
        "            if (repoEntries.length <= 1) {",
        "                repoRowEl.style.display = 'none';",
        "                reposEl.innerHTML = '';",
        "                activeRepoFilter = '';",
        "                return;",
        "            }",
        "            repoRowEl.style.display = 'flex';",
        "            const scopedOptions = options.filter((item) => {",
        "                const matchesType = !activeFilter || item.type === activeFilter;",
        "                return matchesType && matchesSearch(item);",
        "            });",
        "            const filterEntries = [['', scopedOptions], ...repoEntries.map(([repoName]) => [repoName, scopedOptions.filter((item) => item.repo === repoName)])];",
        "            reposEl.innerHTML = filterEntries.map(([repoName, items]) => {",
        "                const isActive = activeRepoFilter === repoName;",
        "                const background = isActive ? '#1d4ed8' : '#ffffff';",
        "                const color = isActive ? '#ffffff' : '#1d4ed8';",
        "                const border = isActive ? '#1d4ed8' : '#bfdbfe';",
        "                const label = repoName || ui.all_repos;",
        "                return `",
        "                    <button type=\"button\" data-plugin-repo=\"${escapeHtml(repoName)}\" style=\"padding:7px 12px;border:1px solid ${border};border-radius:999px;background:${background};color:${color};font-size:12px;font-weight:700;cursor:pointer;display:inline-flex;gap:8px;align-items:center\">",
        "                        <span>${escapeHtml(label)}</span>",
        "                        <span style=\"display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:${isActive ? 'rgba(255,255,255,0.16)' : '#dbeafe'};color:${isActive ? '#ffffff' : '#1e3a8a'}\">${items.length}</span>",
        "                    </button>",
        "                `;",
        "            }).join('');",
        "            reposEl.querySelectorAll('button[data-plugin-repo]').forEach((button) => {",
        "                button.addEventListener('click', () => {",
        "                    const repoName = button.getAttribute('data-plugin-repo') || '';",
        "                    activeRepoFilter = activeRepoFilter === repoName ? '' : repoName;",
        "                    syncSelectionToVisible();",
        "                    renderList();",
        "                });",
        "            });",
        "        };",
        "        const updateState = () => {",
        "            countEl.textContent = ui.selected_count.replace('{count}', String(selected.size));",
        "            submitBtn.disabled = selected.size === 0;",
        "            submitBtn.style.opacity = selected.size === 0 ? '0.45' : '1';",
        "            submitBtn.style.cursor = selected.size === 0 ? 'not-allowed' : 'pointer';",
        "            renderTypeButtons();",
        "            renderRepoButtons();",
        "        };",
        "        const renderOptionCard = (item) => {",
        "            const checked = selected.has(item.id) ? 'checked' : '';",
        "            const description = item.description ? `",
        "                <div style=\"display:grid;gap:4px\">",
        "                    <div style=\"font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.04em\">${escapeHtml(ui.description_label)}</div>",
        "                    <div style=\"font-size:13px;color:#334155;line-height:1.55;word-break:break-word\">${formatMultilineText(item.description)}</div>",
        "                </div>",
        "            ` : '';",
        "            return `",
        "                <label style=\"display:flex;gap:14px;align-items:flex-start;padding:14px;border:1px solid #e2e8f0;border-radius:14px;background:#fff;cursor:pointer\">",
        "                    <input type=\"checkbox\" data-plugin-id=\"${escapeHtml(item.id)}\" ${checked} style=\"margin-top:3px;width:16px;height:16px;accent-color:#0f172a;flex-shrink:0\" />",
        "                    <div style=\"min-width:0;display:grid;gap:6px\">",
        "                        <div style=\"display:flex;gap:10px;align-items:center;flex-wrap:wrap\">",
        "                            <span style=\"display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;background:#f1f5f9;color:#334155;font-size:12px;font-weight:700;text-transform:uppercase\">${escapeHtml(item.type)}</span>",
        "                            <span style=\"display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;background:#eff6ff;color:#1d4ed8;font-size:12px;font-weight:700\">${escapeHtml(item.repo)}</span>",
        "                            <span style=\"font-size:15px;font-weight:700;color:#0f172a;word-break:break-word\">${escapeHtml(item.title)}</span>",
        "                        </div>",
        "                        <div style=\"font-size:12px;color:#475569;word-break:break-word\">${escapeHtml(ui.version_label)}: ${escapeHtml(item.version)} · ${escapeHtml(ui.file_label)}: ${escapeHtml(item.file_path)}</div>",
        "                        ${description}",
        "                    </div>",
        "                </label>",
        "            `;",
        "        };",
        "        const renderList = () => {",
        "            const visibleOptions = getVisibleOptions();",
        "            if (!visibleOptions.length) {",
        "                listEl.innerHTML = `<div style=\"padding:24px;border:1px dashed #cbd5e1;border-radius:14px;background:#f8fafc;color:#475569;font-size:14px;text-align:center\">${escapeHtml(ui.no_results)}</div>`;",
        "                updateState();",
        "                return;",
        "            }",
        "            const groups = visibleOptions.reduce((bucket, item) => {",
        "                if (!bucket[item.repo]) {",
        "                    bucket[item.repo] = [];",
        "                }",
        "                bucket[item.repo].push(item);",
        "                return bucket;",
        "            }, {});",
        "            listEl.innerHTML = Object.entries(groups).map(([repoName, items]) => `",
        "                <section style=\"display:grid;gap:10px\">",
        "                    <div style=\"display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;padding:0 2px\">",
        "                        <div style=\"display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;background:#eff6ff;color:#1d4ed8;font-size:12px;font-weight:700;word-break:break-word\">${escapeHtml(repoName)}</div>",
        "                        <div style=\"display:inline-flex;align-items:center;gap:8px;padding:4px 10px;border-radius:999px;background:#f8fafc;color:#475569;font-size:12px;font-weight:600\">${items.length}</div>",
        "                    </div>",
        "                    <div style=\"display:grid;gap:12px\">${items.map((item) => renderOptionCard(item)).join('')}</div>",
        "                </section>",
        "            `).join('');",
        "            listEl.querySelectorAll('input[data-plugin-id]').forEach((input) => {",
        "                input.addEventListener('change', () => {",
        "                    const pluginId = input.getAttribute('data-plugin-id') || '';",
        "                    if (input.checked) {",
        "                        selected.add(pluginId);",
        "                    } else {",
        "                        selected.delete(pluginId);",
        "                    }",
        "                    updateState();",
        "                });",
        "            });",
        "            updateState();",
        "        };",
        "        const cleanup = () => {",
        "            body.style.overflow = previousOverflow;",
        "            window.removeEventListener('keydown', onKeyDown, true);",
        "            overlay.remove();",
        "        };",
        "        const finish = (confirmed) => {",
        "            const selectedIds = confirmed ? options.filter((item) => selected.has(item.id)).map((item) => item.id) : [];",
        "            cleanup();",
        "            resolve({ confirmed, selected_ids: selectedIds });",
        "        };",
        "        const onKeyDown = (event) => {",
        "            if (event.key === 'Escape') {",
        "                event.preventDefault();",
        "                finish(false);",
        "            }",
        "        };",
        "        overlay.addEventListener('click', (event) => {",
        "            if (event.target === overlay) {",
        "                finish(false);",
        "            }",
        "        });",
        "        selectAllBtn.addEventListener('click', () => {",
        "            getVisibleOptions().forEach((item) => selected.add(item.id));",
        "            renderList();",
        "        });",
        "        clearAllBtn.addEventListener('click', () => {",
        "            getVisibleOptions().forEach((item) => selected.delete(item.id));",
        "            renderList();",
        "        });",
        "        searchInput.addEventListener('input', () => {",
        "            searchTerm = searchInput.value.trim().toLowerCase();",
        "            syncSelectionToVisible();",
        "            renderList();",
        "        });",
        "        cancelBtn.addEventListener('click', () => finish(false));",
        "        submitBtn.addEventListener('click', () => {",
        "            if (selected.size === 0) {",
        "                updateState();",
        "                return;",
        "            }",
        "            finish(true);",
        "        });",
        "        window.addEventListener('keydown', onKeyDown, true);",
        "        renderList();",
        "    } catch (error) {",
        "        console.error('[Batch Install] Plugin selection dialog failed', error);",
        "        resolve({",
        "            confirmed: false,",
        "            error: error instanceof Error ? error.message : String(error),",
        "            selected_ids: [],",
        "        });",
        "    }",
        "});",
    ]
    return "\n".join(lines)


async def _request_plugin_selection(
    event_call: Optional[Any],
    lang: str,
    candidates: List[PluginCandidate],
    hint: str,
) -> Tuple[Optional[List[PluginCandidate]], Optional[str]]:
    if not event_call:
        return candidates, None

    options = [
        {
            "id": candidate.selection_id,
            "title": candidate.title,
            "type": candidate.plugin_type,
            "repo": candidate.source_repo,
            "version": candidate.version,
            "file_path": candidate.file_path,
            "description": candidate.metadata.get("description", ""),
        }
        for candidate in candidates
    ]
    ui_text = {
        "title": _t(lang, "confirm_title"),
        "list_title": _t(lang, "status_list_title", count=len(candidates)),
        "repo_label": _selection_t(lang, "repo_label"),
        "hint": hint.strip(),
        "select_all": _selection_t(lang, "select_all"),
        "clear_all": _selection_t(lang, "clear_all"),
        "quick_select": _selection_t(lang, "quick_select"),
        "all_types": _selection_t(lang, "all_types"),
        "repo_filter": _selection_t(lang, "repo_filter"),
        "all_repos": _selection_t(lang, "all_repos"),
        "star_repo": _selection_t(lang, "star_repo"),
        "project_repo_url": PROJECT_REPO_URL,
        "search_label": _selection_t(lang, "search_label"),
        "search_placeholder": _selection_t(lang, "search_placeholder"),
        "no_results": _selection_t(lang, "no_results"),
        "selected_count": _selection_t(lang, "selected_count", count="{count}"),
        "install_selected": _selection_t(lang, "install_selected"),
        "cancel": _selection_t(lang, "cancel"),
        "version_label": _selection_t(lang, "version_label"),
        "file_label": _selection_t(lang, "file_label"),
        "description_label": _selection_t(lang, "description_label"),
    }
    js_code = _build_selection_dialog_js(options, ui_text)

    try:
        result = await asyncio.wait_for(
            event_call({"type": "execute", "data": {"code": js_code}}),
            timeout=CONFIRMATION_TIMEOUT,
        )
    except asyncio.TimeoutError:
        logger.warning("Installation selection dialog timed out.")
        return None, _t(lang, "err_confirm_unavailable")
    except Exception as exc:
        logger.warning("Installation selection dialog failed: %s", exc)
        return None, _t(lang, "err_confirm_unavailable")

    if not isinstance(result, dict):
        logger.warning("Unexpected selection dialog result: %r", result)
        return None, _t(lang, "err_confirm_unavailable")

    if result.get("error"):
        logger.warning("Selection dialog returned error: %s", result.get("error"))
        return None, _t(lang, "err_confirm_unavailable")

    if not result.get("confirmed"):
        return [], None

    selected_ids = result.get("selected_ids")
    if not isinstance(selected_ids, list):
        logger.warning("Selection dialog returned invalid selected_ids: %r", selected_ids)
        return None, _t(lang, "err_confirm_unavailable")

    selected_id_set = {str(item).strip() for item in selected_ids if str(item).strip()}
    selected_candidates = [
        candidate for candidate in candidates if candidate.selection_id in selected_id_set
    ]
    return selected_candidates, None


def parse_github_url(url: str) -> Optional[Tuple[str, str, str]]:
    match = re.match(
        r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/tree/([^/]+))?/?$",
        url,
    )
    if not match:
        return None
    owner, repo, branch = match.group(1), match.group(2), (match.group(3) or DEFAULT_BRANCH)
    return owner, repo, branch


async def fetch_github_tree(
    client: httpx.AsyncClient, owner: str, repo: str, branch: str
) -> List[Dict]:
    api_url = f"{GITHUB_API}/repos/{owner}/{repo}/git/trees/{branch}?recursive=1"
    try:
        resp = await client.get(api_url, headers={"User-Agent": "OpenWebUI-Tool"})
        resp.raise_for_status()
        data = resp.json()
        tree = data.get("tree", [])
        return tree if isinstance(tree, list) else []
    except (httpx.HTTPError, ValueError) as exc:
        logger.warning("Failed to fetch GitHub tree from %s: %s", api_url, exc)
        return []


async def fetch_github_file(
    client: httpx.AsyncClient, owner: str, repo: str, branch: str, path: str
) -> Optional[str]:
    raw_url = f"{GITHUB_RAW}/{owner}/{repo}/{branch}/{path}"
    try:
        resp = await client.get(raw_url, headers={"User-Agent": "OpenWebUI-Tool"})
        resp.raise_for_status()
        return resp.text
    except httpx.HTTPError as exc:
        logger.warning("Failed to fetch GitHub file from %s: %s", raw_url, exc)
        return None


async def discover_plugins(
    url: str,
    skip_keywords: str = "test",
    source_repo: str = "",
) -> Tuple[List[PluginCandidate], List[Tuple[str, str]]]:
    """Fetch and parse all plugins from a single GitHub repository.

    Scans the repo's file tree for Python files, validates each as a plugin,
    and extracts metadata (title, description, type, version).

    Args:
        url: GitHub repository URL (e.g. https://github.com/owner/repo).
        skip_keywords: Comma-separated keywords to skip in filenames.
        source_repo: Override the repo identifier (owner/repo format).

    Returns:
        Tuple of (valid_plugins, skipped_files_with_reasons).
    """
    parsed = parse_github_url(url)
    if not parsed:
        return [], [("url", "invalid github url")]
    owner, repo, branch = parsed
    resolved_repo = source_repo or f"{owner}/{repo}"

    is_default_repo = (owner.lower() == "fu-jie" and repo.lower() == "openwebui-extensions")

    async with httpx.AsyncClient(
        timeout=httpx.Timeout(GITHUB_TIMEOUT), follow_redirects=True
    ) as client:
        tree = await fetch_github_tree(client, owner, repo, branch)
        if not tree:
            return [], [("url", "failed to fetch repository tree")]

        candidates: List[PluginCandidate] = []
        skipped: List[Tuple[str, str]] = []

        for item in tree:
            item_path = item.get("path", "")
            if item.get("type") != "blob":
                continue
            if not item_path.endswith(".py"):
                continue

            file_name = item_path.split("/")[-1]
            skip_reason = should_skip_file(file_name, is_default_repo, skip_keywords)
            if skip_reason:
                skipped.append((item_path, skip_reason))
                continue

            content = await fetch_github_file(client, owner, repo, branch, item_path)
            if not content:
                skipped.append((item_path, "fetch failed"))
                continue

            if not has_valid_class(content):
                skipped.append((item_path, "no valid class"))
                continue

            metadata = extract_metadata(content)
            if not metadata:
                skipped.append((item_path, "missing docstring"))
                continue

            if "title" not in metadata or "description" not in metadata:
                skipped.append((item_path, "missing title/description"))
                continue

            if is_default_repo and has_emoji(metadata.get("title", "")):
                skipped.append((item_path, "title contains emoji"))
                continue

            if is_default_repo and not metadata.get("openwebui_id"):
                skipped.append((item_path, "missing openwebui_id"))
                continue

            plugin_type = detect_plugin_type(content)
            if not plugin_type:
                skipped.append((item_path, "unknown plugin type"))
                continue

            candidates.append(
                PluginCandidate(
                    plugin_type=plugin_type,
                    file_path=item_path,
                    metadata=metadata,
                    content=content,
                    function_id=build_function_id(item_path, metadata),
                    source_repo=resolved_repo,
                )
            )

    candidates.sort(key=lambda x: (x.plugin_type, x.file_path))
    return candidates, skipped


async def discover_plugins_from_repos(
    repos: List[str],
    skip_keywords: str = "test",
) -> Tuple[List[PluginCandidate], List[Tuple[str, str]]]:
    """Fetch plugins from multiple repositories in parallel.

    Args:
        repos: List of owner/repo strings (e.g. ["Fu-Jie/openwebui-extensions"]).
        skip_keywords: Comma-separated keywords to skip in filenames.

    Returns:
        Tuple of (all_plugins, all_skipped_files_with_reasons).
    """
    tasks = [
        discover_plugins(f"https://github.com/{repo}", skip_keywords, source_repo=repo)
        for repo in repos
    ]
    results = await asyncio.gather(*tasks)

    all_candidates: List[PluginCandidate] = []
    all_skipped: List[Tuple[str, str]] = []

    for repo, (candidates, skipped) in zip(repos, results):
        all_candidates.extend(candidates)
        all_skipped.extend([(f"{repo}:{path}", reason) for path, reason in skipped])

    return _sort_candidates_by_repo_order(all_candidates, repos), all_skipped


class ListParams(BaseModel):
    repo: str = Field(
        default=DEFAULT_REPO,
        description="One or more GitHub repositories (owner/repo), separated by commas, semicolons, or new lines. If the user mentions multiple repositories in one request, combine them here and call the tool once.",
    )
    plugin_types: List[str] = Field(
        default=["pipe", "action", "filter", "tool"],
        description="Plugin types to list (pipe, action, filter, tool)",
    )


class InstallParams(BaseModel):
    repo: str = Field(
        default=DEFAULT_REPO,
        description="One or more GitHub repositories (owner/repo), separated by commas, semicolons, or new lines. If the user mentions multiple repositories in one request, combine them here and call the tool once instead of making separate calls.",
    )
    plugin_types: List[str] = Field(
        default=["pipe", "action", "filter", "tool"],
        description="Plugin types to install (pipe, action, filter, tool)",
    )
    timeout: int = Field(
        default=DEFAULT_TIMEOUT,
        description="Request timeout in seconds",
    )


class Tools:
    class Valves(BaseModel):
        SKIP_KEYWORDS: str = Field(
            default=DEFAULT_SKIP_KEYWORDS,
            description="Comma-separated keywords to skip (e.g., 'test,verify,example')",
        )
        TIMEOUT: int = Field(
            default=DEFAULT_TIMEOUT,
            description="Request timeout in seconds",
        )

    def __init__(self):
        self.valves = self.Valves()

    async def list_plugins(
        self,
        __user__: Optional[dict] = None,
        __event_call__: Optional[Any] = None,
        __request__: Optional[Any] = None,
        valves: Optional[Any] = None,
        repo: str = DEFAULT_REPO,
        plugin_types: List[str] = ["pipe", "action", "filter", "tool"],
    ) -> str:
        """List all available plugins without installing.

        Use this to preview what plugins are available before installation.
        For installation, use install_all_plugins instead.

        Args:
            repo: One or more GitHub repositories (owner/repo format), comma or
                  semicolon separated. Defaults to Fu-Jie/openwebui-extensions.
            plugin_types: Filter by plugin type (pipe, action, filter, tool).

        Returns:
            Markdown formatted list of available plugins grouped by repository.
        """
        user_ctx = await _get_user_context(__user__, __event_call__, __request__)
        lang = user_ctx.get("user_language", "en-US")

        skip_keywords = DEFAULT_SKIP_KEYWORDS
        if valves and hasattr(valves, "SKIP_KEYWORDS") and valves.SKIP_KEYWORDS:
            skip_keywords = valves.SKIP_KEYWORDS

        repo_list = _parse_repo_inputs(repo)
        candidates, _ = await discover_plugins_from_repos(repo_list, skip_keywords)

        if not candidates:
            return _t(lang, "err_no_plugins")

        filtered = _filter_candidates(candidates, plugin_types, repo_list)
        if not filtered:
            return _t(lang, "err_no_match")

        lines = [f"## {_t(lang, 'status_list_title', count=len(filtered))}\n"]
        current_repo = ""
        for c in filtered:
            if c.source_repo != current_repo:
                lines.append(f"\n### {c.source_repo}")
                current_repo = c.source_repo
            lines.append(
                _t(lang, "list_item", type=c.plugin_type, title=c.title)
            )
        return "\n".join(lines)

    async def install_all_plugins(
        self,
        __user__: Optional[dict] = None,
        __event_call__: Optional[Any] = None,
        __request__: Optional[Any] = None,
        __event_emitter__: Optional[Any] = None,
        emitter: Optional[Any] = None,
        valves: Optional[Any] = None,
        repo: str = DEFAULT_REPO,
        plugin_types: List[str] = ["pipe", "action", "filter", "tool"],
        exclude_keywords: str = "",
        timeout: int = DEFAULT_TIMEOUT,
    ) -> str:
        """Discover and install plugins interactively.

        Always fetches all available plugins regardless of user input, then
        presents a selection dialog for the user to choose which to install.

        Workflow:
            1. Discover all plugins from repo(s)
            2. Present interactive selection dialog
            3. Install selected plugins to OpenWebUI

        Args:
            repo: One or more GitHub repositories (owner/repo format), comma or
                  semicolon separated. Defaults to Fu-Jie/openwebui-extensions.
            plugin_types: Filter by plugin type (pipe, action, filter, tool).
            exclude_keywords: Comma-separated keywords to skip matching plugins.
            timeout: HTTP request timeout in seconds.

        Returns:
            Status message with installation results.
        """
        user_ctx = await _get_user_context(__user__, __event_call__, __request__)
        lang = user_ctx.get("user_language", "en-US")
        event_emitter = __event_emitter__ or emitter

        skip_keywords = DEFAULT_SKIP_KEYWORDS
        if valves and hasattr(valves, "SKIP_KEYWORDS") and valves.SKIP_KEYWORDS:
            skip_keywords = valves.SKIP_KEYWORDS

        if valves and hasattr(valves, "TIMEOUT") and valves.TIMEOUT:
            timeout = valves.TIMEOUT
        timeout = max(int(timeout), 1)

        # Resolve base_url for OpenWebUI API calls
        # Priority: request.base_url (with smart fallback to 8080) > env vars (for advanced users)
        base_url = None
        fallback_base_url = "http://localhost:8080"
        
        # First try request.base_url (works for domains, localhost, normal deployments)
        if __request__ and hasattr(__request__, "base_url"):
            base_url = str(__request__.base_url).rstrip("/")
            logger.info("[Batch Install] Primary base_url from request: %s", base_url)
        else:
            base_url = fallback_base_url
            logger.info("[Batch Install] Using fallback base_url: %s", base_url)
        
        # Check for environment variable override (for container mapping issues)
        env_override = os.getenv("OPENWEBUI_URL") or os.getenv("OPENWEBUI_API_BASE_URL")
        if env_override:
            base_url = env_override.rstrip("/")
            logger.info("[Batch Install] Environment variable override applied: %s", base_url)
        
        logger.info("[Batch Install] Initial base_url: %s", base_url)

        api_key = ""
        if __request__ and hasattr(__request__, "headers"):
            auth = __request__.headers.get("Authorization", "")
            if auth.startswith("Bearer "):
                api_key = auth.split(" ", 1)[1]

        if not api_key:
            api_key = os.getenv("OPENWEBUI_API_KEY", "")

        if not api_key:
            return await _finalize_message(
                event_emitter, _t(lang, "err_no_api_key"), notification_type="error"
            )

        base_url = base_url.rstrip("/")

        repo_list = _parse_repo_inputs(repo)
        repo_display = repo_list[0] if len(repo_list) == 1 else f"{repo_list[0]} +{len(repo_list)-1}"
        await _emit_status(event_emitter, _t(lang, "status_fetching", repo=repo_display), done=False)

        candidates, _ = await discover_plugins_from_repos(repo_list, skip_keywords)

        if not candidates:
            return await _finalize_message(
                event_emitter, _t(lang, "err_no_plugins"), notification_type="error"
            )

        filtered = _filter_candidates(candidates, plugin_types, repo_list, exclude_keywords)

        if not filtered:
            return await _finalize_message(
                event_emitter, _t(lang, "err_no_match"), notification_type="warning"
            )

        hint_msg = _build_confirmation_hint(lang, repo, exclude_keywords)
        selected_candidates, confirm_error = await _request_plugin_selection(
            __event_call__, lang, filtered, hint_msg
        )
        if confirm_error:
            return await _finalize_message(
                event_emitter, confirm_error, notification_type="warning"
            )
        if not selected_candidates:
            return await _finalize_message(
                event_emitter,
                _t(lang, "confirm_cancelled"),
                notification_type="info",
            )

        await _emit_frontend_debug_log(
            __event_call__,
            "Starting OpenWebUI install requests",
            {
                "repo": repo,
                "repos": repo_list,
                "base_url": base_url,
                "note": "Backend uses default port 8080 (containerized environment)",
                "plugin_count": len(selected_candidates),
                "plugin_types": plugin_types,
                "exclude_keywords": exclude_keywords,
                "timeout": timeout,
                "has_api_key": bool(api_key),
            },
            level="debug",
        )

        headers = {
            "Accept": "application/json",
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        }

        success_count = 0
        results: List[str] = []
        attempted_fallback = False  # Track if we've already tried fallback

        async with httpx.AsyncClient(
            timeout=httpx.Timeout(timeout), follow_redirects=True
        ) as client:
            for candidate in selected_candidates:
                await _emit_status(
                    event_emitter,
                    _t(
                        lang,
                        "status_installing",
                        type=candidate.plugin_type,
                        title=candidate.title,
                    ),
                    done=False,
                )

                payload = build_payload(candidate)
                update_url, create_url = build_api_urls(base_url, candidate)

                try:
                    await _emit_frontend_debug_log(
                        __event_call__,
                        "Posting plugin install request",
                        {
                            "base_url": base_url,
                            "update_url": update_url,
                            "create_url": create_url,
                            "candidate": _candidate_debug_data(candidate),
                        },
                        level="debug",
                    )
                    update_response = await client.post(
                        update_url,
                        headers=headers,
                        json=payload,
                    )
                    if 200 <= update_response.status_code < 300:
                        success_count += 1
                        results.append(_t(lang, "success_updated", title=candidate.title))
                        continue

                    await _emit_frontend_debug_log(
                        __event_call__,
                        "Update endpoint returned non-2xx; trying create endpoint",
                        {
                            "base_url": base_url,
                            "update_url": update_url,
                            "create_url": create_url,
                            "update_status": update_response.status_code,
                            "update_message": _response_message(update_response),
                            "candidate": _candidate_debug_data(candidate),
                        },
                        level="warn",
                    )
                    create_response = await client.post(
                        create_url,
                        headers=headers,
                        json=payload,
                    )
                    if 200 <= create_response.status_code < 300:
                        success_count += 1
                        results.append(_t(lang, "success_created", title=candidate.title))
                        continue

                    create_error = _response_message(create_response)
                    await _emit_frontend_debug_log(
                        __event_call__,
                        "Create endpoint returned non-2xx",
                        {
                            "base_url": base_url,
                            "update_url": update_url,
                            "create_url": create_url,
                            "update_status": update_response.status_code,
                            "create_status": create_response.status_code,
                            "create_message": create_error,
                            "candidate": _candidate_debug_data(candidate),
                        },
                        level="error",
                    )
                    error_msg = (
                        _t(
                            lang,
                            "error_http_status",
                            status=create_response.status_code,
                            message=create_error,
                        )
                    )
                    results.append(
                        _t(lang, "failed", title=candidate.title, error=error_msg)
                    )
                except httpx.TimeoutException:
                    await _emit_frontend_debug_log(
                        __event_call__,
                        "OpenWebUI request timed out",
                        {
                            "base_url": base_url,
                            "update_url": update_url,
                            "create_url": create_url,
                            "timeout": timeout,
                            "candidate": _candidate_debug_data(candidate),
                        },
                        level="warn",
                    )
                    results.append(
                        _t(
                            lang,
                            "failed",
                            title=candidate.title,
                            error=_t(lang, "error_timeout"),
                        )
                    )
                except httpx.ConnectError as exc:
                    # Smart fallback: if connection fails and we haven't tried fallback yet, switch to 8080
                    if not attempted_fallback and base_url != fallback_base_url and not env_override:
                        logger.warning(
                            "[Batch Install] Connection to %s failed; attempting fallback to %s",
                            base_url,
                            fallback_base_url,
                        )
                        attempted_fallback = True
                        base_url = fallback_base_url
                        
                        await _emit_frontend_debug_log(
                            __event_call__,
                            "Primary base_url failed; switching to fallback",
                            {
                                "failed_base_url": base_url,
                                "fallback_base_url": fallback_base_url,
                                "candidate": _candidate_debug_data(candidate),
                                "error": str(exc),
                            },
                            level="warn",
                        )
                        
                        # Retry this candidate with the fallback base_url
                        logger.info("[Batch Install] Retrying plugin with fallback base_url: %s", candidate.title)
                        update_url, create_url = build_api_urls(base_url, candidate)
                        
                        try:
                            update_response = await client.post(
                                update_url,
                                headers=headers,
                                json=payload,
                            )
                            if 200 <= update_response.status_code < 300:
                                success_count += 1
                                results.append(_t(lang, "success_updated", title=candidate.title))
                                continue
                            
                            create_response = await client.post(
                                create_url,
                                headers=headers,
                                json=payload,
                            )
                            if 200 <= create_response.status_code < 300:
                                success_count += 1
                                results.append(_t(lang, "success_created", title=candidate.title))
                            else:
                                create_error = _response_message(create_response)
                                error_msg = _t(
                                    lang,
                                    "error_http_status",
                                    status=create_response.status_code,
                                    message=create_error,
                                )
                                results.append(
                                    _t(lang, "failed", title=candidate.title, error=error_msg)
                                )
                        except httpx.ConnectError as fallback_exc:
                            # Fallback also failed, cannot recover
                            logger.error("[Batch Install] Fallback retry failed: %s", fallback_exc)
                            await _emit_frontend_debug_log(
                                __event_call__,
                                "OpenWebUI connection failed (both primary and fallback)",
                                {
                                    "primary_base_url": base_url,
                                    "fallback_base_url": fallback_base_url,
                                    "candidate": _candidate_debug_data(candidate),
                                    "error": str(fallback_exc),
                                },
                                level="error",
                            )
                            return await _finalize_message(
                                event_emitter,
                                _t(lang, "err_connection"),
                                notification_type="error",
                            )
                        except Exception as retry_exc:
                            logger.error("[Batch Install] Fallback retry failed with other error: %s", retry_exc)
                            results.append(
                                _t(
                                    lang,
                                    "failed",
                                    title=candidate.title,
                                    error=_t(lang, "error_request_failed", error=str(retry_exc)),
                                )
                            )
                    else:
                        # Already tried fallback or env var is set, cannot recover
                        logger.error(
                            "OpenWebUI connection failed for %s (%s). "
                            "base_url=%s update_url=%s create_url=%s error=%s",
                            candidate.title,
                            candidate.function_id,
                            base_url,
                            update_url,
                            create_url,
                            exc,
                        )
                        await _emit_frontend_debug_log(
                            __event_call__,
                            "OpenWebUI connection failed",
                            {
                                "repo": repo,
                                "base_url": base_url,
                                "update_url": update_url,
                                "create_url": create_url,
                                "timeout": timeout,
                                "candidate": _candidate_debug_data(candidate),
                                "error_type": type(exc).__name__,
                                "error": str(exc),
                                "note": "This API request runs from the OpenWebUI backend process, so localhost refers to the server/container environment.",
                            },
                            level="error",
                        )
                        return await _finalize_message(
                            event_emitter,
                            _t(lang, "err_connection"),
                            notification_type="error",
                        )
                except httpx.HTTPError as exc:
                    await _emit_frontend_debug_log(
                        __event_call__,
                        "OpenWebUI request raised HTTPError",
                        {
                            "base_url": base_url,
                            "update_url": update_url,
                            "create_url": create_url,
                            "candidate": _candidate_debug_data(candidate),
                            "error_type": type(exc).__name__,
                            "error": str(exc),
                        },
                        level="error",
                    )
                    results.append(
                        _t(
                            lang,
                            "failed",
                            title=candidate.title,
                            error=_t(lang, "error_request_failed", error=str(exc)),
                        )
                    )

        summary = _t(
            lang,
            "status_done",
            success=success_count,
            total=len(selected_candidates),
        )
        output = "\n".join(results + [summary])
        notification_type = "success"
        if success_count == 0:
            notification_type = "error"
        elif success_count < len(selected_candidates):
            notification_type = "warning"

        await _emit_status(event_emitter, summary, done=True)
        await _emit_notification(event_emitter, summary, ntype=notification_type)

        return output
