"""OpenCode server lifecycle + HTTP API wrapper."""

from __future__ import annotations

import asyncio
from contextlib import asynccontextmanager
import json
import logging
import os
import signal
import socket
import subprocess
import time
import threading
from asyncio.subprocess import Process
from typing import Any, Dict, List, Optional

import aiohttp

from config import paths
from vibe import runtime
from vibe.opencode_config import load_first_opencode_user_config

logger = logging.getLogger(__name__)

DEFAULT_OPENCODE_PORT = 4096
DEFAULT_OPENCODE_HOST = "127.0.0.1"
SERVER_START_TIMEOUT = 15


class OpenCodeServerManager:
    """Manages a singleton OpenCode server process shared across all working directories."""

    _instance: Optional["OpenCodeServerManager"] = None
    _class_lock: threading.Lock = threading.Lock()

    def __init__(
        self,
        binary: str = "opencode",
        port: int = DEFAULT_OPENCODE_PORT,
        request_timeout_seconds: int = 60,
    ):
        self.binary = binary
        self.port = port
        self.request_timeout_seconds = request_timeout_seconds
        self.host = DEFAULT_OPENCODE_HOST
        self._process: Optional[Process] = None
        self._base_url: Optional[str] = None
        self._http_session: Optional[aiohttp.ClientSession] = None
        self._http_session_loop: Optional[asyncio.AbstractEventLoop] = None
        self._lock: Optional[asyncio.Lock] = None
        self._lock_loop: Optional[asyncio.AbstractEventLoop] = None
        self._pid_file = paths.get_logs_dir() / "opencode_server.json"
        self._active_requests = 0
        self._active_run_sessions: set[str] = set()
        self._auth_refresh_pending = False

    def _get_lock(self) -> asyncio.Lock:
        """Get or create an asyncio.Lock bound to the current event loop."""
        current_loop = asyncio.get_event_loop()
        if self._lock is None or self._lock_loop is not current_loop:
            self._lock = asyncio.Lock()
            self._lock_loop = current_loop
        return self._lock

    @classmethod
    async def get_instance(
        cls,
        binary: str = "opencode",
        port: int = DEFAULT_OPENCODE_PORT,
        request_timeout_seconds: int = 60,
    ) -> "OpenCodeServerManager":
        with cls._class_lock:
            if cls._instance is None:
                cls._instance = cls(
                    binary=binary,
                    port=port,
                    request_timeout_seconds=request_timeout_seconds,
                )
            elif (
                cls._instance.binary != binary
                or cls._instance.port != port
                or cls._instance.request_timeout_seconds != request_timeout_seconds
            ):
                logger.warning(
                    "OpenCodeServerManager already initialized with "
                    f"binary={cls._instance.binary}, port={cls._instance.port}, "
                    f"request_timeout_seconds={cls._instance.request_timeout_seconds}; "
                    f"ignoring new params binary={binary}, port={port}, "
                    f"request_timeout_seconds={request_timeout_seconds}"
                )
            return cls._instance

    @property
    def base_url(self) -> str:
        if self._base_url:
            return self._base_url
        return f"http://{self.host}:{self.port}"

    async def _get_http_session(self) -> aiohttp.ClientSession:
        current_loop = asyncio.get_running_loop()
        # Recreate session if it's closed or bound to a different event loop
        if self._http_session is None or self._http_session.closed or self._http_session_loop is not current_loop:
            # Close old session if it exists and is not closed
            if self._http_session is not None and not self._http_session.closed:
                try:
                    await self._http_session.close()
                except Exception:
                    pass
            total_timeout: Optional[int] = None if self.request_timeout_seconds <= 0 else self.request_timeout_seconds
            self._http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=total_timeout))
            self._http_session_loop = current_loop
        return self._http_session

    async def _close_http_session_locked(self) -> None:
        if self._http_session:
            await self._http_session.close()
            self._http_session = None
            self._http_session_loop = None

    async def _restart_for_auth_refresh_locked(self) -> None:
        await self._close_http_session_locked()

        targets: list[int] = []
        info = self._read_pid_file()
        pid = info.get("pid") if isinstance(info, dict) else None
        if isinstance(pid, int) and self._pid_exists(pid):
            cmd = self._get_pid_command(pid)
            if cmd and self._is_opencode_serve_cmd(cmd, self.port):
                targets.append(pid)
            elif isinstance(info, dict) and info.get("port") == self.port and self._pid_owns_listening_port(pid, self.port):
                logger.info(
                    "Trusting OpenCode pid file for pid=%s because it still owns port %s",
                    pid,
                    self.port,
                )
                targets.append(pid)

        if not targets:
            for candidate in self._find_opencode_serve_pids(self.port):
                cmd = self._get_pid_command(candidate)
                if cmd and self._is_opencode_serve_cmd(cmd, self.port):
                    targets.append(candidate)

        for target_pid in dict.fromkeys(targets):
            await self._terminate_pid(target_pid, reason="auth refresh")

        self._clear_pid_file()
        self._process = None
        self._base_url = None
        self._auth_refresh_pending = False

    def _has_active_run_sessions(self) -> bool:
        return bool(self._active_run_sessions)

    async def mark_run_active(self, session_id: str) -> None:
        async with self._get_lock():
            self._active_run_sessions.add(session_id)

    async def mark_run_inactive(self, session_id: str) -> None:
        async with self._get_lock():
            self._active_run_sessions.discard(session_id)

    @asynccontextmanager
    async def _request_scope(self):
        async with self._get_lock():
            if self._auth_refresh_pending and self._active_requests == 0 and not self._has_active_run_sessions():
                await self._restart_for_auth_refresh_locked()
            self._active_requests += 1
        try:
            yield
        finally:
            async with self._get_lock():
                self._active_requests = max(0, self._active_requests - 1)

    def _read_pid_file(self) -> Optional[Dict[str, Any]]:
        try:
            raw = self._pid_file.read_text()
        except FileNotFoundError:
            return None
        except Exception as e:
            logger.debug(f"Failed to read OpenCode pid file: {e}")
            return None

        try:
            data = json.loads(raw)
        except Exception as e:
            logger.debug(f"Failed to parse OpenCode pid file: {e}")
            return None

        return data if isinstance(data, dict) else None

    def _write_pid_file(self, pid: int) -> None:
        try:
            self._pid_file.parent.mkdir(parents=True, exist_ok=True)
            payload = {
                "pid": pid,
                "port": self.port,
                "host": self.host,
                "started_at": time.time(),
            }
            self._pid_file.write_text(json.dumps(payload))
        except Exception as e:
            logger.debug(f"Failed to write OpenCode pid file: {e}")

    def _clear_pid_file(self) -> None:
        try:
            if self._pid_file.exists():
                self._pid_file.unlink()
        except Exception as e:
            logger.debug(f"Failed to clear OpenCode pid file: {e}")

    @staticmethod
    def _pid_exists(pid: int) -> bool:
        return runtime.pid_alive(pid)

    @staticmethod
    def _get_pid_command(pid: int) -> Optional[str]:
        return runtime.get_process_command(pid)

    @staticmethod
    def _is_opencode_serve_cmd(command: str, port: int) -> bool:
        if not command:
            return False
        return "opencode" in command and " serve" in command and f"--port={port}" in command

    @staticmethod
    def _pid_owns_listening_port(pid: int, port: int) -> bool:
        if os.name == "nt" or pid <= 0:
            return False

        proc_fd_dir = f"/proc/{pid}/fd"
        try:
            fd_entries = os.listdir(proc_fd_dir)
        except OSError:
            return False

        inodes: set[str] = set()
        for entry in fd_entries:
            try:
                target = os.readlink(f"{proc_fd_dir}/{entry}")
            except OSError:
                continue
            if target.startswith("socket:[") and target.endswith("]"):
                inodes.add(target[8:-1])

        if not inodes:
            return False

        for table in ("/proc/net/tcp", "/proc/net/tcp6"):
            try:
                with open(table, encoding="utf-8") as handle:
                    next(handle, None)
                    for raw_line in handle:
                        parts = raw_line.split()
                        if len(parts) < 10:
                            continue
                        local_address = parts[1]
                        state = parts[3]
                        inode = parts[9]
                        if state != "0A" or inode not in inodes:
                            continue
                        _, _, port_hex = local_address.rpartition(":")
                        try:
                            if int(port_hex, 16) == port:
                                return True
                        except ValueError:
                            continue
            except OSError:
                continue

        return False

    def _is_port_available(self) -> bool:
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                sock.bind((self.host, self.port))
            return True
        except OSError:
            return False

    @staticmethod
    def _find_opencode_serve_pids(port: int) -> List[int]:
        if os.name == "nt":
            try:
                result = subprocess.run(
                    ["netstat", "-ano", "-p", "tcp"],
                    capture_output=True,
                    text=True,
                    check=False,
                )
            except Exception:
                return []

            pids: List[int] = []
            for line in (result.stdout or "").splitlines():
                parts = line.split()
                if len(parts) < 5 or parts[0].upper() != "TCP":
                    continue
                local_addr = parts[1]
                state = parts[3].upper()
                pid_str = parts[4]
                if state != "LISTENING" or local_addr.rsplit(":", 1)[-1] != str(port):
                    continue
                try:
                    pid = int(pid_str)
                except ValueError:
                    continue
                command = runtime.get_process_command(pid)
                if command and OpenCodeServerManager._is_opencode_serve_cmd(command, port):
                    pids.append(pid)
            return pids

        try:
            result = subprocess.run(
                ["ps", "-ax", "-o", "pid=,command="],
                capture_output=True,
                text=True,
                check=False,
            )
        except Exception:
            return []

        needle = f"--port={port}"
        pids: List[int] = []
        for line in (result.stdout or "").splitlines():
            line = line.strip()
            if not line:
                continue
            parts = line.split(None, 1)
            if len(parts) != 2:
                continue
            pid_str, cmd = parts
            if "opencode" in cmd and " serve" in cmd and needle in cmd:
                try:
                    pids.append(int(pid_str))
                except ValueError:
                    continue
        return pids

    async def _terminate_pid(self, pid: int, reason: str) -> None:
        logger.info(f"Stopping OpenCode server pid={pid} ({reason})")
        if not runtime.stop_pid(pid, timeout=5) and self._pid_exists(pid):
            logger.debug("Failed to terminate OpenCode server pid=%s", pid)

    async def _cleanup_orphaned_managed_server(self) -> None:
        info = self._read_pid_file()
        if not info:
            return

        pid = info.get("pid")
        port = info.get("port")
        if not isinstance(pid, int) or port != self.port:
            self._clear_pid_file()
            return

        if self._process and self._process.returncode is None and self._process.pid == pid:
            return

        # Check if the server is healthy before deciding to kill it.
        # If it's healthy, we should adopt it rather than kill it.
        if await self._is_healthy():
            # Update PID file to reflect the actual running process.
            # The PID in the file may be stale if OpenCode was restarted externally.
            actual_pids = self._find_opencode_serve_pids(self.port)
            if actual_pids:
                actual_pid = actual_pids[0]
                if actual_pid != pid:
                    logger.info(f"Adopting healthy OpenCode server (updating stale PID {pid} -> {actual_pid})")
                    self._write_pid_file(actual_pid)
                else:
                    logger.info(f"Adopting healthy OpenCode server pid={pid} from previous run")
            else:
                # Server is healthy but we can't find its PID - clear stale file
                logger.info(f"Adopting healthy OpenCode server (clearing stale PID file, pid={pid} not found)")
                self._clear_pid_file()
            return

        cmd = self._get_pid_command(pid)
        if self._pid_exists(pid):
            if cmd and self._is_opencode_serve_cmd(cmd, self.port):
                await self._terminate_pid(pid, reason="orphaned and unhealthy")
            elif cmd is None and self._pid_owns_listening_port(pid, self.port):
                logger.info(
                    "Trusting OpenCode pid file for orphan cleanup pid=%s because it still owns port %s",
                    pid,
                    self.port,
                )
                await self._terminate_pid(pid, reason="orphaned and unhealthy")
        self._clear_pid_file()

    async def ensure_running(self) -> str:
        async with self._get_lock():
            if self._auth_refresh_pending and self._active_requests == 0 and not self._has_active_run_sessions():
                await self._restart_for_auth_refresh_locked()
            await self._cleanup_orphaned_managed_server()

            if await self._is_healthy():
                # If the server is already running (e.g., started by a previous run),
                # record its PID so shutdown can clean it up.
                if not self._read_pid_file():
                    pids = self._find_opencode_serve_pids(self.port)
                    if pids:
                        pid = pids[0]
                        cmd = self._get_pid_command(pid)
                        if cmd and self._is_opencode_serve_cmd(cmd, self.port):
                            self._write_pid_file(pid)

                self._base_url = f"http://{self.host}:{self.port}"
                return self.base_url

            if not self._is_port_available():
                for pid in self._find_opencode_serve_pids(self.port):
                    await self._terminate_pid(pid, reason="port occupied but unhealthy")
                await asyncio.sleep(0.5)

            if not self._is_port_available():
                raise RuntimeError(
                    f"OpenCode port {self.port} is already in use but the server is not responding. "
                    "Stop the process using this port or set OPENCODE_PORT to a free port."
                )

            await self._start_server()
            return self.base_url

    async def _is_healthy(self) -> bool:
        try:
            session = await self._get_http_session()
            async with session.get(f"{self.base_url}/global/health", timeout=aiohttp.ClientTimeout(total=5)) as resp:
                if resp.status == 200:
                    data = await resp.json()
                    return data.get("healthy", False)
        except Exception as e:
            logger.debug(f"Health check failed: {e}")
        return False

    async def _start_server(self) -> None:
        if self._process and self._process.returncode is None:
            try:
                self._process.terminate()
                await asyncio.wait_for(self._process.wait(), timeout=5)
            except Exception:
                self._process.kill()

        # Ensure any stale pid file is cleared before starting.
        self._clear_pid_file()

        cmd = [
            self.binary,
            "serve",
            f"--hostname={self.host}",
            f"--port={self.port}",
        ]

        logger.info(f"Starting OpenCode server: {' '.join(cmd)}")

        env = os.environ.copy()
        env["OPENCODE_ENABLE_EXA"] = "1"

        try:
            self._process = await asyncio.create_subprocess_exec(
                *cmd,
                stdout=asyncio.subprocess.DEVNULL,
                stderr=asyncio.subprocess.DEVNULL,
                env=env,
            )
            if self._process and self._process.pid:
                self._write_pid_file(self._process.pid)
        except FileNotFoundError:
            raise RuntimeError(
                f"OpenCode CLI not found at '{self.binary}'. Please install OpenCode or set OPENCODE_CLI_PATH."
            )

        start_time = time.monotonic()
        while time.monotonic() - start_time < SERVER_START_TIMEOUT:
            if await self._is_healthy():
                self._base_url = f"http://{self.host}:{self.port}"
                logger.info(f"OpenCode server started at {self._base_url}")
                return
            await asyncio.sleep(0.5)

        exit_code = self._process.returncode
        self._clear_pid_file()
        self._process = None
        raise RuntimeError(
            f"OpenCode server failed to start within {SERVER_START_TIMEOUT}s. Process exit code: {exit_code}"
        )

    async def stop(self) -> None:
        async with self._get_lock():
            await self._close_http_session_locked()

            # Don't terminate OpenCode server on vibe-remote shutdown.
            # Let it continue running so the next vibe-remote instance can adopt it.
            # This prevents interrupting tasks that are still in progress.
            logger.info("OpenCode server left running for next vibe-remote instance to adopt")

            # Keep pid_file so next instance knows about the running server.
            self._process = None

    def stop_sync(self) -> None:
        if self._http_session and self._http_session_loop:
            try:
                future = asyncio.run_coroutine_threadsafe(self._http_session.close(), self._http_session_loop)
                future.result(timeout=5)
            except Exception as e:
                logger.debug(f"Failed to close OpenCode HTTP session: {e}")
            finally:
                self._http_session = None
                self._http_session_loop = None

        # Don't terminate OpenCode server on vibe-remote shutdown.
        # Let it continue running so the next vibe-remote instance can adopt it.
        # This prevents interrupting tasks that are still in progress.
        logger.info("OpenCode server left running for next vibe-remote instance to adopt")

        # Keep pid_file so next instance knows about the running server.
        # Don't clear _process reference - just let it be garbage collected.
        self._process = None

    async def restart_for_auth_refresh(self) -> None:
        """Terminate the shared server so the next request reloads refreshed auth."""
        async with self._get_lock():
            if self._active_requests > 0 or self._has_active_run_sessions():
                self._auth_refresh_pending = True
                logger.info(
                    "Deferring OpenCode auth refresh restart until %s active request(s) and %s active run(s) finish",
                    self._active_requests,
                    len(self._active_run_sessions),
                )
                return
            await self._restart_for_auth_refresh_locked()

    @classmethod
    def stop_instance_sync(cls) -> None:
        if cls._instance:
            cls._instance.stop_sync()
            return

        # Don't terminate OpenCode server on vibe-remote shutdown.
        # Let it continue running so the next vibe-remote instance can adopt it.
        logger.info("OpenCode server left running for next vibe-remote instance to adopt")

    async def create_session(self, directory: str, title: Optional[str] = None) -> Dict[str, Any]:
        async with self._request_scope():
            session = await self._get_http_session()
            body: Dict[str, Any] = {}
            if title:
                body["title"] = title

            async with session.post(
                f"{self.base_url}/session",
                json=body,
                headers={"x-opencode-directory": directory},
            ) as resp:
                if resp.status != 200:
                    text = await resp.text()
                    raise RuntimeError(f"Failed to create session: {resp.status} {text}")
                return await resp.json()

    async def send_message(
        self,
        session_id: str,
        directory: str,
        text: str,
        agent: Optional[str] = None,
        model: Optional[Dict[str, str]] = None,
        reasoning_effort: Optional[str] = None,
    ) -> Dict[str, Any]:
        async with self._request_scope():
            session = await self._get_http_session()

            body: Dict[str, Any] = {
                "parts": [{"type": "text", "text": text}],
            }
            if agent:
                body["agent"] = agent
            if model:
                body["model"] = model
            if reasoning_effort:
                body["variant"] = reasoning_effort

            async with session.post(
                f"{self.base_url}/session/{session_id}/message",
                json=body,
                headers={"x-opencode-directory": directory},
            ) as resp:
                if resp.status != 200:
                    error_text = await resp.text()
                    raise RuntimeError(f"Failed to send message: {resp.status} {error_text}")
                return await resp.json()

    async def prompt_async(
        self,
        session_id: str,
        directory: str,
        text: str,
        agent: Optional[str] = None,
        model: Optional[Dict[str, str]] = None,
        reasoning_effort: Optional[str] = None,
        system: Optional[str] = None,
        tools: Optional[Dict[str, bool]] = None,
    ) -> None:
        """Start a prompt asynchronously without holding the HTTP request open."""

        async with self._request_scope():
            session = await self._get_http_session()

            body: Dict[str, Any] = {
                "parts": [{"type": "text", "text": text}],
            }
            if agent:
                body["agent"] = agent
            if model:
                body["model"] = model
            if reasoning_effort:
                body["variant"] = reasoning_effort
            if system:
                body["system"] = system
            if tools:
                body["tools"] = tools

            async with session.post(
                f"{self.base_url}/session/{session_id}/prompt_async",
                json=body,
                headers={"x-opencode-directory": directory},
            ) as resp:
                # OpenCode returns 204 when accepted.
                if resp.status not in (200, 204):
                    error_text = await resp.text()
                    raise RuntimeError(f"Failed to start async prompt: {resp.status} {error_text}")

    async def list_messages(self, session_id: str, directory: str) -> List[Dict[str, Any]]:
        async with self._request_scope():
            session = await self._get_http_session()
            async with session.get(
                f"{self.base_url}/session/{session_id}/message",
                headers={"x-opencode-directory": directory},
            ) as resp:
                if resp.status != 200:
                    error_text = await resp.text()
                    raise RuntimeError(f"Failed to list messages: {resp.status} {error_text}")
                return await resp.json()

    async def get_message(self, session_id: str, message_id: str, directory: str) -> Dict[str, Any]:
        async with self._request_scope():
            session = await self._get_http_session()
            async with session.get(
                f"{self.base_url}/session/{session_id}/message/{message_id}",
                headers={"x-opencode-directory": directory},
            ) as resp:
                if resp.status != 200:
                    error_text = await resp.text()
                    raise RuntimeError(f"Failed to get message: {resp.status} {error_text}")
                return await resp.json()

    async def list_questions(self, directory: Optional[str] = None) -> List[Dict[str, Any]]:
        async with self._request_scope():
            session = await self._get_http_session()
            params = {"directory": directory} if directory else None
            async with session.get(
                f"{self.base_url}/question",
                params=params,
            ) as resp:
                if resp.status != 200:
                    error_text = await resp.text()
                    raise RuntimeError(f"Failed to list questions: {resp.status} {error_text}")
                data = await resp.json()
                return data if isinstance(data, list) else []

    async def reply_question(self, question_id: str, directory: str, answers: List[List[str]]) -> bool:
        async with self._request_scope():
            session = await self._get_http_session()
            async with session.post(
                f"{self.base_url}/question/{question_id}/reply",
                params={"directory": directory},
                json={"answers": answers},
            ) as resp:
                if resp.status != 200:
                    error_text = await resp.text()
                    raise RuntimeError(f"Failed to reply question: {resp.status} {error_text}")
                data = await resp.json()
                return bool(data)

    async def abort_session(self, session_id: str, directory: str) -> bool:
        async with self._request_scope():
            session = await self._get_http_session()

            try:
                async with session.post(
                    f"{self.base_url}/session/{session_id}/abort",
                    headers={"x-opencode-directory": directory},
                ) as resp:
                    return resp.status == 200
            except Exception as e:
                logger.warning(f"Failed to abort session {session_id}: {e}")
                return False

    async def get_session(self, session_id: str, directory: str) -> Optional[Dict[str, Any]]:
        async with self._request_scope():
            session = await self._get_http_session()
            try:
                async with session.get(
                    f"{self.base_url}/session/{session_id}",
                    headers={"x-opencode-directory": directory},
                ) as resp:
                    if resp.status == 200:
                        return await resp.json()
                    return None
            except Exception as e:
                logger.debug(f"Failed to get session {session_id}: {e}")
                return None

    async def get_available_agents(self, directory: str) -> List[Dict[str, Any]]:
        """Fetch available agents from OpenCode server.

        Returns:
            List of agent dicts with 'name', 'mode', 'native', etc.
        """

        async with self._request_scope():
            session = await self._get_http_session()
            try:
                async with session.get(
                    f"{self.base_url}/agent",
                    headers={"x-opencode-directory": directory},
                ) as resp:
                    if resp.status == 200:
                        agents = await resp.json()
                        # Filter to primary agents (build, plan), exclude hidden/subagent
                        return [a for a in agents if a.get("mode") == "primary" and not a.get("hidden", False)]
                    return []
            except Exception as e:
                logger.warning(f"Failed to get available agents: {e}")
                return []

    async def get_available_models(self, directory: str) -> Dict[str, Any]:
        """Fetch available models from OpenCode server.

        Returns:
            Dict with 'providers' list and 'default' dict mapping provider to default model.
        """

        async with self._request_scope():
            session = await self._get_http_session()
            try:
                async with session.get(
                    f"{self.base_url}/config/providers",
                    headers={"x-opencode-directory": directory},
                ) as resp:
                    if resp.status == 200:
                        return await resp.json()
                    return {"providers": [], "default": {}}
            except Exception as e:
                logger.warning(f"Failed to get available models: {e}")
                return {"providers": [], "default": {}}

    async def get_default_config(self, directory: str) -> Dict[str, Any]:
        """Fetch current default config from OpenCode server.

        Returns:
            Config dict including 'model' (current default), 'agent' configs, etc.
        """

        async with self._request_scope():
            session = await self._get_http_session()
            try:
                async with session.get(
                    f"{self.base_url}/config",
                    headers={"x-opencode-directory": directory},
                ) as resp:
                    if resp.status == 200:
                        return await resp.json()
                    return {}
            except Exception as e:
                logger.warning(f"Failed to get default config: {e}")
                return {}

    async def set_api_key_auth(self, provider_id: str, api_key: str) -> None:
        """Persist provider API auth via OpenCode's own auth endpoint."""

        await self.ensure_running()

        async with self._request_scope():
            session = await self._get_http_session()
            async with session.put(
                f"{self.base_url}/auth/{provider_id}",
                json={"type": "api", "key": api_key},
            ) as resp:
                if resp.status != 200:
                    error_text = await resp.text()
                    raise RuntimeError(f"Failed to set OpenCode auth: {resp.status} {error_text}")

    def _load_opencode_user_config(self) -> Optional[Dict[str, Any]]:
        """Load and cache opencode.json config file.

        Checks both ~/.config/opencode/opencode.json and ~/.opencode/opencode.json
        since OpenCode supports multiple config locations.

        Returns:
            Parsed config dict, or None if file doesn't exist or is invalid.
        """
        probe = load_first_opencode_user_config(logger_instance=logger)
        return probe.config

    def _get_agent_config(self, config: Dict[str, Any], agent_name: Optional[str]) -> Dict[str, Any]:
        """Get agent-specific config from opencode.json with type safety."""

        if not agent_name:
            return {}
        agents = config.get("agent", {})
        if not isinstance(agents, dict):
            return {}
        agent_config = agents.get(agent_name, {})
        if not isinstance(agent_config, dict):
            return {}
        return agent_config

    def get_agent_model_from_config(self, agent_name: Optional[str]) -> Optional[str]:
        """Read agent's default model from user's opencode.json config file.

        This is a workaround for OpenCode server not using agent-specific models
        when only the agent parameter is passed to the message API.
        """

        config = self._load_opencode_user_config()
        if not config:
            return None

        # Try agent-specific model first
        agent_config = self._get_agent_config(config, agent_name)
        model = agent_config.get("model")
        if isinstance(model, str) and model:
            logger.debug(f"Found model '{model}' for agent '{agent_name}' in opencode.json")
            return model

        # Fall back to global default model
        model = config.get("model")
        if isinstance(model, str) and model:
            logger.debug(f"Using global default model '{model}' from opencode.json")
            return model
        return None

    def get_agent_reasoning_effort_from_config(self, agent_name: Optional[str]) -> Optional[str]:
        """Read agent's reasoningEffort from user's opencode.json config file."""

        config = self._load_opencode_user_config()
        if not config:
            return None

        # Valid reasoning effort values
        valid_efforts = {"none", "minimal", "low", "medium", "high", "xhigh", "max"}

        # Try agent-specific reasoningEffort first
        agent_config = self._get_agent_config(config, agent_name)
        reasoning_effort = agent_config.get("reasoningEffort")
        if isinstance(reasoning_effort, str) and reasoning_effort:
            if reasoning_effort in valid_efforts:
                logger.debug(f"Found reasoningEffort '{reasoning_effort}' for agent '{agent_name}' in opencode.json")
                return reasoning_effort
            else:
                logger.debug(f"Ignoring unknown reasoningEffort '{reasoning_effort}' for agent '{agent_name}'")

        # Fall back to global default reasoningEffort
        reasoning_effort = config.get("reasoningEffort")
        if isinstance(reasoning_effort, str) and reasoning_effort:
            if reasoning_effort in valid_efforts:
                logger.debug(f"Using global default reasoningEffort '{reasoning_effort}' from opencode.json")
                return reasoning_effort
            else:
                logger.debug(f"Ignoring unknown global reasoningEffort '{reasoning_effort}'")
        return None

    def get_default_agent_from_config(self) -> Optional[str]:
        """Read the default agent from user's opencode.json config file.

        OpenCode server doesn't automatically use its configured default agent
        when called via API, so we need to read and pass it explicitly.
        """

        # OpenCode doesn't have an explicit "default agent" config field.
        # Users can override via channel settings.
        # Default to "build" agent which uses the agent's configured model,
        # avoiding fallback to global model which may use restricted credentials.
        return "build"
