# ashyterm/terminal/ai_assistant.py

"""AI assistant integration for AshyTerm terminals."""

from __future__ import annotations

import json
import os
import re
import threading
import weakref
from typing import Any, Callable, Dict, List, Optional, Tuple

import gi

gi.require_version("GLib", "2.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, GObject

from ..data.ai_history_manager import get_ai_history_manager
from ..utils.logger import get_logger
from ..utils.translation_utils import _

# Lazy-loaded requests module (avoid import overhead on startup)
_requests_module = None


def _get_requests():
    """Get the requests module, importing lazily on first use."""
    global _requests_module
    if _requests_module is None:
        import requests  # type: ignore[import-untyped]

        _requests_module = requests
    return _requests_module


class TerminalAiAssistant(GObject.Object):
    """Coordinates conversations with an external AI service."""

    __gsignals__ = {
        # Signal emitted when streaming message chunks arrive
        # Args: (chunk: str, is_done: bool)
        "streaming-chunk": (GObject.SignalFlags.RUN_FIRST, None, (str, bool)),
        # Signal emitted when a full response is ready
        # Args: (reply: str, commands: list)
        "response-ready": (GObject.SignalFlags.RUN_FIRST, None, (str, object)),
        # Signal emitted on error
        # Args: (error_message: str)
        "error": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
    }

    DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
    DEFAULT_GROQ_MODEL = "llama-3.1-8b-instant"
    DEFAULT_OPENROUTER_MODEL = "openrouter/polaris-alpha"
    DEFAULT_LOCAL_MODEL = "llama3.2"
    DEFAULT_CEREBRAS_MODEL = "llama-3.3-70b"
    DEFAULT_GITHUB_MODEL = "gpt-4o-mini"
    DEFAULT_MISTRAL_MODEL = "mistral-small-latest"

    # PROMPT OTIMIZADO, DIRETO E DINÂMICO
    _SYSTEM_PROMPT_TEMPLATE = (
        "You are an expert Linux terminal assistant running on {os_context}."
        " Your goal is to provide accurate, safe, and executable command-line solutions."
        "\n\n"
        "**CRITICAL RULES:**\n"
        "1. **OUTPUT FORMAT:** You must respond with RAW JSON only. Do NOT wrap the output in markdown code blocks (like ```json ... ```).\n"
        '2. **JSON STRUCTURE:** {{ "reply": "<explanation using markdown>", "commands": ["<cmd1>", "<cmd2>"] }}\n'
        "3. **LANGUAGE:** Respond strictly in {language}.\n"
        "4. **SCOPE:** Answer only Linux, networking, coding, and sysadmin questions. Politely refuse off-topic requests.\n"
        "\n"
        "**FIELD DETAILS:**\n"
        "- 'reply': The explanation text. You MAY use Markdown (bold, lists, inline code) inside this string for readability.\n"
        "- 'commands': A list of standalone, executable shell commands appropriate for {os_context}. Do not include placeholders like '<file>' unless necessary.\n"
    )

    @staticmethod
    def _sanitize_os_value(value: str) -> str:
        """Sanitize OS-release values to prevent prompt injection.
        Only allow alphanumeric, spaces, dots, hyphens, parens, slashes."""
        import re

        sanitized = re.sub(r"[^\w .\-()/:,]", "", value)
        return sanitized[:100]

    @staticmethod
    def _parse_os_release() -> Tuple[str, str]:
        """Parse /etc/os-release and return (os_name, base_distro)."""
        os_name = "Linux"
        base_distro = ""
        try:
            with open("/etc/os-release", "r") as f:
                for line in f:
                    if line.startswith("PRETTY_NAME="):
                        raw = line.split("=", 1)[1].strip().strip('"')
                        os_name = TerminalAiAssistant._sanitize_os_value(raw)
                    elif line.startswith("ID_LIKE="):
                        raw = line.split("=", 1)[1].strip().strip('"')
                        base_distro = TerminalAiAssistant._sanitize_os_value(raw)
        except Exception:
            pass
        return os_name, base_distro

    @staticmethod
    def _parse_lsb_release() -> str:
        """Parse /etc/lsb-release and return os_name."""
        os_name = "Linux"
        try:
            with open("/etc/lsb-release", "r") as f:
                for line in f:
                    if line.startswith("DISTRIB_DESCRIPTION="):
                        raw = line.split("=", 1)[1].strip().strip('"')
                        os_name = TerminalAiAssistant._sanitize_os_value(raw)
                        break
        except Exception:
            pass
        return os_name

    @classmethod
    def _detect_os_context(cls) -> str:
        """Detects the OS name and base to give context to the AI."""
        if os.path.exists("/etc/os-release"):
            os_name, base_distro = cls._parse_os_release()
            if base_distro:
                return f"{os_name} (based on {base_distro})"
            return os_name

        if os.path.exists("/etc/lsb-release"):
            return cls._parse_lsb_release()

        return "Linux"

    @classmethod
    def _get_system_prompt(cls) -> str:
        """Get the system prompt with the system's default language and OS context."""
        import locale

        try:
            # Get the system language
            lang_code = locale.getdefaultlocale()[0] or "en_US"
            # Map common locale codes to language names
            lang_map = {
                "pt": "Portuguese",
                "en": "English",
                "es": "Spanish",
                "fr": "French",
                "de": "German",
                "it": "Italian",
                "zh": "Chinese",
                "ja": "Japanese",
                "ko": "Korean",
                "ru": "Russian",
                "ar": "Arabic",
                "nl": "Dutch",
                "pl": "Polish",
                "tr": "Turkish",
                "uk": "Ukrainian",
                "cs": "Czech",
                "sv": "Swedish",
                "da": "Danish",
                "fi": "Finnish",
                "no": "Norwegian",
                "hu": "Hungarian",
                "ro": "Romanian",
                "bg": "Bulgarian",
                "el": "Greek",
                "he": "Hebrew",
                "hr": "Croatian",
                "sk": "Slovak",
                "et": "Estonian",
                "is": "Icelandic",
            }
            lang_prefix = lang_code.split("_")[0].lower()
            language = lang_map.get(lang_prefix, "English")
        except Exception:
            language = "English"

        os_context = cls._detect_os_context()

        return cls._SYSTEM_PROMPT_TEMPLATE.format(
            language=language, os_context=os_context
        )

    def __init__(self, window, settings_manager, terminal_manager):
        super().__init__()
        self.logger = get_logger("ashyterm.terminal.ai_assistant")
        self._window_ref = weakref.ref(window)
        self.settings_manager = settings_manager
        self.terminal_manager = terminal_manager
        self._conversations: Dict[int, List[Dict[str, str]]] = {}
        self._max_conversation_messages = 40  # Keep last N messages per terminal
        self._terminal_refs: Dict[int, weakref.ReferenceType] = {}
        self._inflight: Dict[int, bool] = {}
        self._cancel_flags: Dict[int, bool] = {}
        self._active_responses: Dict[int, Any] = {}
        self._thread_local = threading.local()
        self._lock = threading.RLock()
        self._history_manager_instance = None  # Lazy loaded via property
        # Callbacks for streaming updates
        self._streaming_callback: Optional[Callable[[str, bool], None]] = None

    @property
    def _history_manager(self):
        """Lazy load the AI history manager on first access."""
        if self._history_manager_instance is None:
            self._history_manager_instance = get_ai_history_manager()
        return self._history_manager_instance

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------
    def is_enabled(self) -> bool:
        return self.settings_manager.get("ai_assistant_enabled", False)

    def missing_configuration(self) -> List[str]:
        missing = []
        provider = self.settings_manager.get("ai_assistant_provider", "").strip()

        api_key = self.settings_manager.get(
            f"ai_assistant_{provider}_api_key", ""
        ).strip()
        if not api_key:
            api_key = self.settings_manager.get("ai_assistant_api_key", "").strip()
        if not provider:
            missing.append("provider")
            return missing
        if provider in {"groq", "gemini", "openrouter", "cerebras", "github", "mistral"}:
            if not api_key:
                missing.append("api_key")
        elif provider == "local":
            # Local providers may not need API key
            base_url = self.settings_manager.get("ai_local_base_url", "").strip()
            if not base_url:
                missing.append("base_url")
        else:
            missing.append("provider")
        return missing

    def request_assistance(
        self,
        terminal,
        prompt: str,
        streaming_callback: Optional[Callable[[str, bool], None]] = None,
    ) -> bool:
        """Kick off an assistant request for the provided terminal."""
        if not prompt:
            return False
        if not self.is_enabled():
            self._queue_toast(
                "Enable the AI assistant in Preferences before requesting help."
            )
            return False
        try:
            terminal_id = self._ensure_terminal_reference(terminal)
        except ValueError:
            self._queue_toast("Unable to identify the active terminal.")
            return False

        with self._lock:
            if self._inflight.get(terminal_id):
                self._queue_toast(
                    "The assistant is still processing the previous request."
                )
                return False
            self._inflight[terminal_id] = True
            self._streaming_callback = streaming_callback

        # Save user message to history
        self._history_manager.add_user_message(prompt)

        worker = threading.Thread(
            target=self._process_request_thread, args=(terminal_id, prompt), daemon=True
        )
        worker.start()
        return True

    def request_assistance_simple(
        self,
        prompt: str,
        streaming_callback: Optional[Callable[[str, bool], None]] = None,
    ) -> bool:
        """
        Request assistance without a specific terminal context.
        Used by the AI overlay panel.
        """
        if not prompt:
            return False
        if not self.is_enabled():
            self._queue_toast(
                "Enable the AI assistant in Preferences before requesting help."
            )
            return False

        # Use a special terminal_id for non-terminal requests
        terminal_id = -1  # Special ID for overlay panel

        with self._lock:
            if self._inflight.get(terminal_id):
                self._queue_toast(
                    "The assistant is still processing the previous request."
                )
                return False
            self._inflight[terminal_id] = True
            self._streaming_callback = streaming_callback

        # Save user message to history
        self._history_manager.add_user_message(prompt)

        worker = threading.Thread(
            target=self._process_request_thread, args=(terminal_id, prompt), daemon=True
        )
        worker.start()
        return True

    def clear_conversation_for_terminal(self, terminal) -> None:
        terminal_id = getattr(terminal, "terminal_id", None)
        if terminal_id is None:
            return
        with self._lock:
            self._cleanup_terminal_state(terminal_id)

    def clear_all_conversations(self) -> None:
        with self._lock:
            for tid in list(self._inflight.keys()):
                self.cancel_request(tid)
            self._conversations.clear()
            self._terminal_refs.clear()
            self._inflight.clear()
            self._cancel_flags.clear()
            self._active_responses.clear()

    def shutdown(self) -> None:
        """Clean up all state for window destruction."""
        self.clear_all_conversations()

    def cancel_request(self, terminal_id: int = -1) -> None:
        """Cancel an inflight request for the given terminal."""
        with self._lock:
            if self._inflight.get(terminal_id):
                self._cancel_flags[terminal_id] = True
                response = self._active_responses.get(terminal_id)
                if response:
                    try:
                        response.close()
                    except Exception:
                        pass

    def handle_setting_changed(self, key: str, _old_value: Any, new_value: Any) -> None:
        if key == "ai_assistant_enabled" and not new_value:
            self.clear_all_conversations()
        if (
            key
            in {
                "ai_assistant_provider",
                "ai_assistant_api_key",
                "ai_assistant_model",
                "ai_local_base_url",
            }
            or key.endswith("_api_key")
            or key.endswith("_model")
        ):
            self.clear_all_conversations()

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------
    def _ensure_terminal_reference(self, terminal) -> int:
        terminal_id = getattr(terminal, "terminal_id", None)
        if terminal_id is None:
            raise ValueError("terminal is missing a terminal_id attribute")

        if terminal_id not in self._terminal_refs:
            self._terminal_refs[terminal_id] = weakref.ref(
                terminal,
                lambda _ref, tid=terminal_id: self._cleanup_terminal_state(tid),  # type: ignore[misc]
            )
        return terminal_id

    def _process_request_thread(self, terminal_id: int, prompt: str) -> None:
        self._thread_local.terminal_id = terminal_id
        with self._lock:
            self._cancel_flags[terminal_id] = False

        try:
            messages = self._build_messages(terminal_id, prompt)
            config = self._load_configuration()

            # Check if we should use streaming
            if self._streaming_callback:
                content = self._perform_streaming_request(config, messages)
            else:
                content = self._perform_request(config, messages)

            reply, commands, code_snippets = self._parse_assistant_payload(content)
            self._record_assistant_message(terminal_id, reply)
            # Save to history with commands (convert dicts to strings for storage)
            command_strings_for_history = [
                cmd.get("command", "")
                for cmd in commands
                if isinstance(cmd, dict) and cmd.get("command")
            ]
            self._history_manager.add_assistant_message(
                reply, command_strings_for_history
            )

            GLib.idle_add(
                self._display_assistant_reply,
                terminal_id,
                reply,
                commands,
                code_snippets,
            )
        except Exception as exc:  # pylint: disable=broad-except
            self.logger.error("AI assistant request failed: %s", exc)
            error_message = "Sorry, I couldn't complete the request: {}".format(exc)
            self._record_assistant_message(terminal_id, error_message)
            GLib.idle_add(
                self._display_error_reply,
                terminal_id,
                error_message,
            )
            # Emit error signal
            GLib.idle_add(self.emit, "error", error_message)
        finally:
            with self._lock:
                self._inflight.pop(terminal_id, None)
                self._cancel_flags.pop(terminal_id, None)
                self._active_responses.pop(terminal_id, None)
                self._streaming_callback = None

    def _build_messages(self, terminal_id: int, prompt: str) -> List[Dict[str, str]]:
        with self._lock:
            history = self._conversations.setdefault(terminal_id, [])
            history.append({"role": "user", "content": prompt})
            # Cap conversation length to prevent unbounded memory growth
            if len(history) > self._max_conversation_messages:
                history[:] = history[-self._max_conversation_messages :]
            messages: List[Dict[str, str]] = [
                {"role": "system", "content": self._get_system_prompt()}
            ]
            messages.extend(history)
            return messages

    def _load_configuration(self) -> Dict[str, str]:
        provider = self.settings_manager.get("ai_assistant_provider", "").strip()

        api_key = self.settings_manager.get(
            f"ai_assistant_{provider}_api_key", ""
        ).strip()
        if not api_key:
            api_key = self.settings_manager.get("ai_assistant_api_key", "").strip()

        model = self.settings_manager.get(f"ai_assistant_{provider}_model", "").strip()
        if not model:
            model = self.settings_manager.get("ai_assistant_model", "").strip()

        config = {
            "provider": provider,
            "model": model,
            "api_key": api_key,
        }
        config["local_base_url"] = self.settings_manager.get(
            "ai_local_base_url", "http://localhost:11434/v1"
        ).strip()
        if not config["provider"]:
            raise RuntimeError(
                "Select a provider in Preferences > Terminal > AI Assistant."
            )
        if config["provider"] == "groq" and not config["model"]:
            config["model"] = self.DEFAULT_GROQ_MODEL
        elif config["provider"] == "gemini" and not config["model"]:
            config["model"] = self.DEFAULT_GEMINI_MODEL
        elif config["provider"] == "openrouter" and not config["model"]:
            config["model"] = self.DEFAULT_OPENROUTER_MODEL
        elif config["provider"] == "local" and not config["model"]:
            config["model"] = self.DEFAULT_LOCAL_MODEL
        elif config["provider"] == "cerebras" and not config["model"]:
            config["model"] = self.DEFAULT_CEREBRAS_MODEL
        elif config["provider"] == "github" and not config["model"]:
            config["model"] = self.DEFAULT_GITHUB_MODEL
        elif config["provider"] == "mistral" and not config["model"]:
            config["model"] = self.DEFAULT_MISTRAL_MODEL
        return config

    # -------------------------------------------------------------------------
    # Generic OpenAI-compatible API helpers (eliminates duplicate code)
    # -------------------------------------------------------------------------

    def _make_api_request(
        self,
        requests,
        url: str,
        headers: Dict[str, str],
        payload: Dict[str, Any],
        provider_name: str,
        timeout: int,
    ) -> Dict[str, Any]:
        """Make HTTP request and return parsed JSON response."""
        try:
            terminal_id = getattr(self._thread_local, "terminal_id", -1)
            with self._lock:
                if self._cancel_flags.get(terminal_id):
                    raise RuntimeError(_("Request cancelled by user."))

            response = requests.post(
                url, headers=headers, json=payload, timeout=timeout
            )

            with self._lock:
                if self._cancel_flags.get(terminal_id):
                    response.close()
                    raise RuntimeError(_("Request cancelled by user."))

        except requests.RequestException as exc:
            raise RuntimeError(
                f"Failed to query the {provider_name} service: {exc}"
            ) from exc

        if response.status_code >= 400:
            raise RuntimeError(self._format_api_error(response, provider_name))

        try:
            return response.json()
        except ValueError as exc:
            raise RuntimeError(
                f"{provider_name} returned an invalid JSON response."
            ) from exc

    def _extract_openai_content(
        self, response_data: Dict[str, Any], provider_name: str
    ) -> str:
        """Extract content from OpenAI-compatible response."""
        choices = response_data.get("choices") or []
        if not choices:
            raise RuntimeError("The server response did not contain any suggestions.")

        message = choices[0].get("message") if isinstance(choices[0], dict) else None
        content = message.get("content") if isinstance(message, dict) else None

        if isinstance(content, list):
            content = "\n".join(
                part.get("text", "")
                for part in content
                if isinstance(part, dict) and part.get("text")
            )

        if not isinstance(content, str) or not content.strip():
            raise RuntimeError(f"{provider_name} did not return any usable content.")
        return content.strip()

    def _openai_compat_request(
        self,
        url: str,
        headers: Dict[str, str],
        payload: Dict[str, Any],
        provider_name: str,
        timeout: int = 60,
    ) -> str:
        """Generic non-streaming request for OpenAI-compatible APIs."""
        requests = _get_requests()
        response_data = self._make_api_request(
            requests, url, headers, payload, provider_name, timeout
        )
        return self._extract_openai_content(response_data, provider_name)

    def _parse_sse_line(self, line_str: str) -> Tuple[Optional[str], bool]:
        """Parse SSE line. Returns (content_chunk, is_eof)."""
        if not line_str.startswith("data: "):
            return None, False

        data_str = line_str[6:]
        if data_str == "[DONE]":
            return "", True

        try:
            data = json.loads(data_str)
            if "error" in data:
                err_msg = data["error"].get("message", "API Error")
                raise RuntimeError(err_msg)
            choices = data.get("choices", [])
            if choices:
                delta = choices[0].get("delta", {})
                content = delta.get("content", "")
                if content is None:
                    return "", False
                return content, False
        except json.JSONDecodeError:
            pass
        return None, False

    def _process_streaming_response(self, response) -> str:
        """Process streaming response and return full content."""
        terminal_id = getattr(self._thread_local, "terminal_id", -1)

        content_type = response.headers.get("Content-Type", "")
        if "application/json" in content_type:
            return self._handle_json_response(response)

        full_content = ""
        try:
            for line in response.iter_lines():
                if self._cancel_flags.get(terminal_id):
                    raise RuntimeError(_("Request cancelled by user."))
                if not line:
                    continue
                line_str = line.decode("utf-8").strip()
                chunk, is_eof = self._parse_sse_line(line_str)
                if chunk is None:
                    continue
                if is_eof:
                    break
                if chunk:
                    full_content += chunk
                    if self._streaming_callback:
                        GLib.idle_add(self._streaming_callback, chunk, False)
        except Exception:
            if self._cancel_flags.get(terminal_id):
                raise RuntimeError(_("Request cancelled by user."))
            raise
        finally:
            try:
                response.close()
            except Exception:
                pass

        if self._streaming_callback:
            GLib.idle_add(self._streaming_callback, "", True)

        return full_content

    def _handle_json_response(self, response) -> str:
        """Handle a non-streaming JSON response (server ignored stream=True)."""
        try:
            data = response.json()
        except ValueError:
            data = {}
        if "error" in data:
            raise RuntimeError(
                data["error"].get("message", "Unknown API error inside JSON")
            )
        choices = data.get("choices", [])
        if choices:
            content = choices[0].get("message", {}).get("content", "")
            if content:
                if self._streaming_callback:
                    GLib.idle_add(self._streaming_callback, content, True)
                return content
        raise RuntimeError(
            _("Provider returned JSON instead of an event stream: {}").format(
                str(data)[:100]
            )
        )

    def _openai_compat_streaming(
        self,
        url: str,
        headers: Dict[str, str],
        payload: Dict[str, Any],
        provider_name: str,
        timeout: int = 60,
    ) -> str:
        """Generic streaming request for OpenAI-compatible APIs (SSE protocol)."""
        requests = _get_requests()
        payload["stream"] = True

        try:
            response = requests.post(
                url, headers=headers, json=payload, timeout=timeout, stream=True
            )
            terminal_id = getattr(self._thread_local, "terminal_id", -1)
            with self._lock:
                if self._cancel_flags.get(terminal_id):
                    response.close()
                    raise RuntimeError(_("Request cancelled by user."))
                self._active_responses[terminal_id] = response
        except requests.RequestException as exc:
            raise RuntimeError(
                f"Failed to query the {provider_name} service: {exc}"
            ) from exc

        if response.status_code >= 400:
            raise RuntimeError(self._format_api_error(response, provider_name))

        return self._process_streaming_response(response)

    # -------------------------------------------------------------------------
    # Provider-specific dispatchers
    # -------------------------------------------------------------------------

    def _perform_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        provider = config["provider"]
        if provider == "groq":
            return self._perform_groq_request(config, messages)
        if provider == "gemini":
            return self._perform_gemini_request(config, messages)
        if provider == "openrouter":
            return self._perform_openrouter_request(config, messages)
        if provider == "local":
            return self._perform_local_request(config, messages)
        if provider == "cerebras":
            return self._perform_cerebras_request(config, messages)
        if provider == "github":
            return self._perform_github_request(config, messages)
        if provider == "mistral":
            return self._perform_mistral_request(config, messages)
        raise RuntimeError(f"Provider '{provider}' is not supported in this version.")

    def _perform_streaming_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform a streaming request, sending chunks via callback."""
        provider = config["provider"]
        if provider == "local":
            return self._perform_local_streaming_request(config, messages)
        if provider == "openrouter":
            return self._perform_openrouter_streaming_request(config, messages)
        if provider == "groq":
            return self._perform_groq_streaming_request(config, messages)
        if provider == "cerebras":
            return self._perform_cerebras_streaming_request(config, messages)
        if provider == "github":
            return self._perform_github_streaming_request(config, messages)
        if provider == "mistral":
            return self._perform_mistral_streaming_request(config, messages)
        # Fall back to non-streaming for providers that don't support it well
        return self._perform_request(config, messages)

    def _perform_local_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform request to local OpenAI-compatible API (Ollama, LM Studio, etc.)."""
        base_url = config.get("local_base_url", "http://localhost:11434/v1").rstrip("/")
        if base_url.endswith(":11434"):
            base_url += "/v1"
        model = config.get("model", "").strip() or self.DEFAULT_LOCAL_MODEL
        url = f"{base_url}/chat/completions"

        headers = {"Content-Type": "application/json"}
        api_key = config.get("api_key", "").strip()
        if api_key:
            headers["Authorization"] = f"Bearer {api_key}"

        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_request(
            url, headers, payload, "Local AI", timeout=120
        )

    def _perform_local_streaming_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform streaming request to local OpenAI-compatible API."""
        base_url = config.get("local_base_url", "http://localhost:11434/v1").rstrip("/")
        if base_url.endswith(":11434"):
            base_url += "/v1"
        model = config.get("model", "").strip() or self.DEFAULT_LOCAL_MODEL
        url = f"{base_url}/chat/completions"

        headers = {"Content-Type": "application/json"}
        api_key = config.get("api_key", "").strip()
        if api_key:
            headers["Authorization"] = f"Bearer {api_key}"

        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_streaming(
            url, headers, payload, "Local AI", timeout=120
        )

    def _perform_groq_streaming_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform streaming request to Groq API."""
        api_key = config.get("api_key", "").strip()
        if not api_key:
            raise RuntimeError("Configure the Groq API key in Preferences.")

        model = config.get("model", "").strip() or self.DEFAULT_GROQ_MODEL
        url = "https://api.groq.com/openai/v1/chat/completions"

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        }
        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_streaming(url, headers, payload, "Groq")

    def _perform_openrouter_streaming_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform streaming request to OpenRouter API."""
        api_key = config.get("api_key", "").strip()
        if not api_key:
            raise RuntimeError("Configure the OpenRouter API key in Preferences.")

        model = config.get("model", "").strip() or self.DEFAULT_OPENROUTER_MODEL
        url = "https://openrouter.ai/api/v1/chat/completions"

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

        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_streaming(url, headers, payload, "OpenRouter")

    def _make_gemini_api_call(
        self, url: str, headers: Dict[str, str], payload: Dict[str, Any]
    ) -> Dict[str, Any]:
        """Make API call to Gemini and return parsed response."""
        requests = _get_requests()
        try:
            response = requests.post(url, headers=headers, json=payload, timeout=60)
        except requests.RequestException as exc:
            raise RuntimeError(f"Failed to query the Gemini service: {exc}") from exc

        if response.status_code >= 400:
            raise RuntimeError(self._format_api_error(response, "Gemini"))

        try:
            return response.json()
        except ValueError as exc:
            raise RuntimeError("Gemini returned an invalid JSON response.") from exc

    def _extract_gemini_content(self, response_data: Dict[str, Any]) -> str:
        """Extract text content from Gemini response."""
        candidates = response_data.get("candidates") or []
        if not candidates:
            raise RuntimeError("The server response did not contain any suggestions.")

        collected: List[str] = []
        for candidate in candidates:
            content = candidate.get("content") if isinstance(candidate, dict) else None
            parts = content.get("parts") if isinstance(content, dict) else None
            if not parts:
                continue
            for part in parts:
                if isinstance(part, dict) and part.get("text"):
                    collected.append(part["text"])

        if collected:
            return "\n".join(collected)

        raise RuntimeError("Gemini did not return any usable content.")

    def _perform_gemini_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        api_key = config.get("api_key", "").strip()
        if not api_key:
            raise RuntimeError("Configure the Gemini API key in Preferences.")

        model = config.get("model", "").strip() or self.DEFAULT_GEMINI_MODEL
        system_instruction, contents = self._build_gemini_conversation(messages)

        url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
        payload: Dict[str, Any] = {"contents": contents}
        if system_instruction:
            payload["system_instruction"] = {"parts": [{"text": system_instruction}]}

        headers = {
            "Content-Type": "application/json",
            "x-goog-api-key": api_key,
        }

        response_data = self._make_gemini_api_call(url, headers, payload)
        return self._extract_gemini_content(response_data)

    def _perform_groq_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform non-streaming request to Groq API."""
        api_key = config.get("api_key", "").strip()
        if not api_key:
            raise RuntimeError("Configure the Groq API key in Preferences.")

        model = config.get("model", "").strip() or self.DEFAULT_GROQ_MODEL
        url = "https://api.groq.com/openai/v1/chat/completions"

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        }
        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_request(url, headers, payload, "Groq")

    def _perform_openrouter_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform non-streaming request to OpenRouter API."""
        api_key = config.get("api_key", "").strip()
        if not api_key:
            raise RuntimeError("Configure the OpenRouter API key in Preferences.")

        model = config.get("model", "").strip() or self.DEFAULT_OPENROUTER_MODEL
        url = "https://openrouter.ai/api/v1/chat/completions"

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

        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_request(url, headers, payload, "OpenRouter")

    def _perform_cerebras_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform non-streaming request to Cerebras API."""
        api_key = config.get("api_key", "").strip()
        if not api_key:
            raise RuntimeError("Configure the Cerebras API key in Preferences.")

        model = config.get("model", "").strip() or self.DEFAULT_CEREBRAS_MODEL
        url = "https://api.cerebras.ai/v1/chat/completions"

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        }
        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_request(url, headers, payload, "Cerebras")

    def _perform_cerebras_streaming_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform streaming request to Cerebras API."""
        api_key = config.get("api_key", "").strip()
        if not api_key:
            raise RuntimeError("Configure the Cerebras API key in Preferences.")

        model = config.get("model", "").strip() or self.DEFAULT_CEREBRAS_MODEL
        url = "https://api.cerebras.ai/v1/chat/completions"

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        }
        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_streaming(url, headers, payload, "Cerebras")

    def _perform_github_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform non-streaming request to GitHub Models API."""
        api_key = config.get("api_key", "").strip()
        if not api_key:
            raise RuntimeError("Configure the GitHub Personal Access Token in Preferences.")

        model = config.get("model", "").strip() or self.DEFAULT_GITHUB_MODEL
        url = "https://models.inference.ai.azure.com/chat/completions"

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        }
        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_request(url, headers, payload, "GitHub Models")

    def _perform_github_streaming_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform streaming request to GitHub Models API."""
        api_key = config.get("api_key", "").strip()
        if not api_key:
            raise RuntimeError("Configure the GitHub Personal Access Token in Preferences.")

        model = config.get("model", "").strip() or self.DEFAULT_GITHUB_MODEL
        url = "https://models.inference.ai.azure.com/chat/completions"

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        }
        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_streaming(url, headers, payload, "GitHub Models")

    def _perform_mistral_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform non-streaming request to Mistral API."""
        api_key = config.get("api_key", "").strip()
        if not api_key:
            raise RuntimeError("Configure the Mistral API key in Preferences.")

        model = config.get("model", "").strip() or self.DEFAULT_MISTRAL_MODEL
        url = "https://api.mistral.ai/v1/chat/completions"

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        }
        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_request(url, headers, payload, "Mistral")

    def _perform_mistral_streaming_request(
        self, config: Dict[str, str], messages: List[Dict[str, str]]
    ) -> str:
        """Perform streaming request to Mistral API."""
        api_key = config.get("api_key", "").strip()
        if not api_key:
            raise RuntimeError("Configure the Mistral API key in Preferences.")

        model = config.get("model", "").strip() or self.DEFAULT_MISTRAL_MODEL
        url = "https://api.mistral.ai/v1/chat/completions"

        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {api_key}",
        }
        payload: Dict[str, Any] = {
            "model": model,
            "messages": self._build_openai_messages(messages),
        }
        return self._openai_compat_streaming(url, headers, payload, "Mistral")

    def _format_api_error(self, response: Any, provider_name: str) -> str:
        """Format error from HTTP response. Response type is requests.Response."""
        status = response.status_code
        fallback = response.text.strip() or _("Unknown error.")
        try:
            payload = response.json()
        except ValueError:
            return _("{provider} responded with HTTP {status}: {message}").format(
                provider=provider_name, status=status, message=fallback
            )

        error_obj = payload.get("error")
        if not isinstance(error_obj, dict):
            return _("{provider} responded with HTTP {status}: {message}").format(
                provider=provider_name, status=status, message=fallback
            )

        message = error_obj.get("message")
        metadata = error_obj.get("metadata", {})
        provider_detail = metadata.get("provider_name")
        raw_detail = metadata.get("raw")
        details = []
        if provider_detail:
            details.append(provider_detail)
        if raw_detail:
            details.append(raw_detail)
        extra = f" ({' | '.join(details)})" if details else ""

        clean_message = message or fallback
        base = _("{provider} responded with HTTP {status}: {message}{detail}").format(
            provider=provider_name,
            status=status,
            message=clean_message,
            detail=extra,
        )

        # Add helpful hint for common OpenRouter errors
        if provider_name == "OpenRouter" and status == 404:
            msg_lower = (clean_message or "").lower()
            if "guardrail" in msg_lower or "data policy" in msg_lower:
                base += "\n" + _(
                    "Tip: check your privacy settings at "
                    "https://openrouter.ai/settings/privacy — "
                    "some providers require enabling free endpoints "
                    "or allowing data collection."
                )

        return base

    def _build_gemini_conversation(
        self, messages: List[Dict[str, str]]
    ) -> Tuple[str, List[Dict[str, Any]]]:
        system_instruction = ""
        contents: List[Dict[str, Any]] = []
        for message in messages:
            role = message.get("role", "user")
            text = message.get("content", "")
            if not text:
                continue
            if role == "system" and not system_instruction:
                system_instruction = text
                continue
            mapped_role = "model" if role == "assistant" else "user"
            contents.append(
                {
                    "role": mapped_role,
                    "parts": [{"text": text}],
                }
            )
        if not contents:
            contents.append({"role": "user", "parts": [{"text": ""}]})
        return system_instruction, contents

    def _build_openai_messages(
        self, messages: List[Dict[str, str]]
    ) -> List[Dict[str, str]]:
        formatted: List[Dict[str, str]] = []
        for message in messages:
            role = message.get("role", "user")
            content = message.get("content", "")
            if not isinstance(content, str) or not content:
                continue
            role_mapped = role
            if role not in {"system", "user", "assistant"}:
                role_mapped = "user"
            formatted.append({"role": role_mapped, "content": content})
        return formatted

    def _parse_assistant_payload(
        self, content: str
    ) -> Tuple[str, List[Dict[str, str]], List[Dict[str, str]]]:
        # Tenta limpar marcadores de markdown que as IAs adoram colocar
        clean_content = self._clean_response(content)

        reply_text = ""
        commands: List[Dict[str, str]] = []
        code_snippets: List[Dict[str, str]] = []
        payload = None

        # Tentativa 1: Parse direto do JSON limpo
        try:
            payload = json.loads(clean_content)
        except json.JSONDecodeError:
            # Tentativa 2: Tentar encontrar o primeiro objeto JSON válido na string
            payload = self._extract_json_object(clean_content)

        if isinstance(payload, dict):
            # Sucesso no JSON
            reply_text = payload.get("reply", "")
            commands_field = payload.get("commands", [])
            commands = self._normalize_commands(commands_field)
        else:
            # FALHA NO JSON (Fallback):
            # A IA respondeu texto puro. Vamos usar o texto como resposta
            # e tentar extrair comandos via Regex dos blocos de código.
            reply_text = content  # Usa o conteúdo original para manter formatação

            # Extrai comandos de blocos de código bash/sh/zsh automaticamente
            # Regex procura por ```bash ou ```sh seguido de conteúdo
            code_block_pattern = r"```(?:bash|sh|zsh)?\n(.*?)```"
            matches = re.findall(code_block_pattern, content, re.DOTALL)

            for match in matches:
                cmd_str = match.strip()
                # Evita adicionar scripts longos como botões de comando único
                if cmd_str and len(cmd_str.splitlines()) == 1:
                    commands.append(
                        {
                            "command": cmd_str,
                            "description": "Suggested command",
                        }
                    )

        return reply_text, commands, code_snippets

    def _clean_response(self, raw_content: str) -> str:
        """Remove markdown code fences wrapping the JSON."""
        clean = raw_content.strip()

        # Remove ```json no início e ``` no fim
        if clean.startswith("```"):
            # Encontra a primeira quebra de linha
            first_newline = clean.find("\n")
            if first_newline != -1:
                # Remove a primeira linha (ex: ```json)
                clean = clean[first_newline + 1 :]

            # Remove o fechamento ``` se existir no final
            if clean.endswith("```"):
                clean = clean[:-3]

        return clean.strip()

    def _extract_json_object(self, text: str) -> Optional[Dict[str, Any]]:
        start = text.find("{")
        while start != -1:
            brace_level = 0
            for end in range(start, len(text)):
                char = text[end]
                if char == "{":
                    brace_level += 1
                elif char == "}":
                    brace_level -= 1
                    if brace_level == 0:
                        candidate = text[start : end + 1]
                        try:
                            return json.loads(candidate)
                        except json.JSONDecodeError:
                            break
            start = text.find("{", start + 1)
        return None

    def _normalize_commands(self, value: Any) -> List[Dict[str, str]]:
        commands: List[Dict[str, str]] = []
        if isinstance(value, list):
            for item in value:
                if isinstance(item, str) and item.strip():
                    commands.append({"command": item.strip(), "description": ""})
                elif isinstance(item, dict):
                    candidate = item.get("command") or item.get("cmd")
                    description = item.get("description") or ""
                    if isinstance(candidate, str) and candidate.strip():
                        commands.append(
                            {
                                "command": candidate.strip(),
                                "description": description.strip()
                                if isinstance(description, str)
                                else "",
                            }
                        )
        elif isinstance(value, str) and value.strip():
            commands.append({"command": value.strip(), "description": ""})
        return commands

    def _record_assistant_message(self, terminal_id: int, message: str) -> None:
        with self._lock:
            history = self._conversations.setdefault(terminal_id, [])
            history.append({"role": "assistant", "content": message})
            # Cap conversation length to prevent unbounded memory growth
            if len(history) > self._max_conversation_messages:
                history[:] = history[-self._max_conversation_messages :]

    def _display_assistant_reply(
        self,
        terminal_id: int,
        reply: str,
        commands: List[Dict[str, str]],
        code_snippets: List[Dict[str, str]],
    ) -> bool:
        command_strings = [
            cmd.get("command", "") for cmd in commands if isinstance(cmd, dict)
        ]
        self.emit("response-ready", reply, command_strings)
        return False

    def _display_error_reply(self, terminal_id: int, message: str) -> bool:
        self._queue_toast(message)
        return False

    def _get_terminal(self, terminal_id: int):
        ref = self._terminal_refs.get(terminal_id)
        return ref() if ref else None

    def _cleanup_terminal_state(self, terminal_id: int) -> None:
        self._conversations.pop(terminal_id, None)
        self._terminal_refs.pop(terminal_id, None)
        self._inflight.pop(terminal_id, None)
        self._cancel_flags.pop(terminal_id, None)
        self._active_responses.pop(terminal_id, None)

    def _queue_toast(self, message: str) -> None:
        def _show_toast():
            window = self._window_ref()
            if window and hasattr(window, "toast_overlay"):
                toast = Adw.Toast(title=message)
                window.toast_overlay.add_toast(toast)
            return False

        GLib.idle_add(_show_toast)
