"""
Official Linear MCP client session manager.

Maintains a long-lived MCP client session with reconnect and single-retry
semantics for transient failures.

Default transport uses the official `mcp-remote` stdio bridge so existing OAuth
flows (as used by Claude MCP config) are reused without custom token plumbing.
"""

from __future__ import annotations

import asyncio
import concurrent.futures
import hashlib
import json
import logging
import os
import re
import shlex
import threading
import time
from datetime import timedelta
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit

from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamablehttp_client

logger = logging.getLogger(__name__)

DEFAULT_OFFICIAL_MCP_URL = "https://mcp.linear.app/mcp"
DEFAULT_TRANSPORT = "stdio"
DEFAULT_STDIO_COMMAND = "npx"
DEFAULT_STDIO_ARGS_PREFIX = ["-y", "mcp-remote"]
DEFAULT_AUTH_RETRY_COOLDOWN_SECONDS = 300.0
DEFAULT_CLOSE_JOIN_TIMEOUT_SECONDS = 5.0
DEFAULT_SHARED_AUTH_RETRY_STATE_DIR = Path("/tmp/oh-my-linear")


class OfficialToolError(RuntimeError):
    """Raised when the official MCP call fails."""

    def __init__(self, code: str, message: str):
        super().__init__(message)
        self.code = code
        self.message = message


class OfficialMcpSessionManager:
    """Thread-safe synchronous wrapper around async MCP client session."""

    def __init__(
        self,
        transport: str | None = None,
        command: str | None = None,
        args: list[str] | None = None,
        env: dict[str, str] | None = None,
        cwd: str | None = None,
        url: str | None = None,
        headers: dict[str, str] | None = None,
        timeout_seconds: float = 30.0,
        sse_read_timeout_seconds: float = 300.0,
        read_timeout_seconds: float = 30.0,
        auth_timeout_seconds: float = 180.0,
        auth_retry_cooldown_seconds: float | None = None,
    ):
        self._transport = (transport or os.getenv("LINEAR_OFFICIAL_MCP_TRANSPORT", DEFAULT_TRANSPORT)).lower()
        self._url = url or os.getenv("LINEAR_OFFICIAL_MCP_URL", DEFAULT_OFFICIAL_MCP_URL)
        self._headers = headers or self._parse_headers_from_env()
        self._command = command or os.getenv("LINEAR_OFFICIAL_MCP_COMMAND", DEFAULT_STDIO_COMMAND)
        self._args = args or self._parse_stdio_args_from_env(default_url=self._url)
        self._env = env or self._parse_stdio_env_from_env()
        self._cwd = cwd or os.getenv("LINEAR_OFFICIAL_MCP_CWD")
        self._timeout_seconds = timeout_seconds
        self._sse_read_timeout_seconds = sse_read_timeout_seconds
        self._read_timeout_seconds = read_timeout_seconds
        self._auth_timeout_seconds = auth_timeout_seconds
        self._auth_retry_cooldown_seconds = self._parse_auth_retry_cooldown_seconds(
            auth_retry_cooldown_seconds
        )
        self._auth_retry_state_path = self._resolve_auth_retry_state_path(self._url)

        if self._transport not in {"stdio", "http"}:
            raise ValueError("LINEAR_OFFICIAL_MCP_TRANSPORT must be one of: stdio, http")

        self._loop: asyncio.AbstractEventLoop | None = None
        self._thread: threading.Thread | None = None
        self._lock = threading.RLock()

        self._transport_cm: Any = None
        self._session_cm: Any = None
        self._session: ClientSession | None = None

        self._failure_count = 0
        self._last_error: str | None = None
        self._last_failure_at: float | None = None
        self._last_connected_at: float | None = None
        self._last_connect_attempt_at: float | None = None
        self._auth_retry_not_before: float | None = None
        self._last_auth_failure_reason: str | None = None

    @staticmethod
    def _parse_headers_from_env() -> dict[str, str] | None:
        raw = os.getenv("LINEAR_OFFICIAL_MCP_HEADERS")
        if not raw:
            return None
        try:
            parsed = json.loads(raw)
            if isinstance(parsed, dict):
                return {str(k): str(v) for k, v in parsed.items()}
        except Exception:
            logger.warning("Ignoring invalid LINEAR_OFFICIAL_MCP_HEADERS value")
        return None

    @staticmethod
    def _parse_stdio_env_from_env() -> dict[str, str] | None:
        raw = os.getenv("LINEAR_OFFICIAL_MCP_ENV")
        if not raw:
            return None
        try:
            parsed = json.loads(raw)
            if isinstance(parsed, dict):
                return {str(k): str(v) for k, v in parsed.items()}
        except Exception:
            logger.warning("Ignoring invalid LINEAR_OFFICIAL_MCP_ENV value")
        return None

    @staticmethod
    def _parse_stdio_args_from_env(default_url: str) -> list[str]:
        raw = os.getenv("LINEAR_OFFICIAL_MCP_ARGS")
        if not raw:
            return [*DEFAULT_STDIO_ARGS_PREFIX, default_url]

        # Prefer JSON array for exact argument boundaries.
        try:
            parsed = json.loads(raw)
            if isinstance(parsed, list):
                return [str(item) for item in parsed]
        except Exception:
            pass

        # Fallback to shell-like parsing.
        try:
            return shlex.split(raw)
        except ValueError:
            logger.warning("Ignoring invalid LINEAR_OFFICIAL_MCP_ARGS value; using default args")
            return [*DEFAULT_STDIO_ARGS_PREFIX, default_url]

    @staticmethod
    def _parse_auth_retry_cooldown_seconds(override: float | None) -> float:
        if override is not None:
            return max(0.0, float(override))
        raw = os.getenv("LINEAR_OFFICIAL_AUTH_RETRY_COOLDOWN_SECONDS")
        if raw is None or raw == "":
            return DEFAULT_AUTH_RETRY_COOLDOWN_SECONDS
        try:
            return max(0.0, float(raw))
        except ValueError:
            logger.warning(
                "Ignoring invalid LINEAR_OFFICIAL_AUTH_RETRY_COOLDOWN_SECONDS value %r; using default %.0fs",
                raw,
                DEFAULT_AUTH_RETRY_COOLDOWN_SECONDS,
            )
            return DEFAULT_AUTH_RETRY_COOLDOWN_SECONDS

    @staticmethod
    def _resolve_auth_retry_state_path(url: str) -> Path:
        override = os.getenv("LINEAR_OFFICIAL_AUTH_RETRY_STATE_FILE")
        if override:
            return Path(override).expanduser()

        url_hash = hashlib.md5(url.encode()).hexdigest()  # noqa: S324
        return DEFAULT_SHARED_AUTH_RETRY_STATE_DIR / f"auth-retry-{url_hash}.json"

    def _write_shared_auth_retry_state(self, not_before: float, reason: str | None) -> None:
        """Persist auth cooldown across processes to suppress repeated OAuth popups."""
        payload = {
            "notBefore": not_before,
            "reason": self._redact_text(reason),
            "updatedAt": time.time(),
            "pid": os.getpid(),
        }
        try:
            self._auth_retry_state_path.parent.mkdir(parents=True, exist_ok=True)
            tmp_path = self._auth_retry_state_path.with_name(
                f"{self._auth_retry_state_path.name}.{os.getpid()}.tmp"
            )
            tmp_path.write_text(json.dumps(payload), encoding="utf-8")
            tmp_path.replace(self._auth_retry_state_path)
        except Exception as exc:
            logger.debug("Failed to write shared auth retry state: %s", exc)

    def _read_shared_auth_retry_state(self) -> tuple[float | None, str | None]:
        try:
            data = json.loads(self._auth_retry_state_path.read_text(encoding="utf-8"))
        except FileNotFoundError:
            return None, None
        except Exception as exc:
            logger.debug("Ignoring invalid shared auth retry state: %s", exc)
            return None, None

        not_before = data.get("notBefore")
        reason = data.get("reason")
        if not isinstance(not_before, (int, float)):
            return None, None
        parsed_reason = str(reason) if isinstance(reason, str) else None
        return float(not_before), parsed_reason

    def _clear_shared_auth_retry_state(self) -> None:
        try:
            self._auth_retry_state_path.unlink()
        except FileNotFoundError:
            return
        except OSError as exc:
            logger.debug("Failed to clear shared auth retry state: %s", exc)

    @staticmethod
    def _redact_text(value: str | None) -> str | None:
        if value is None:
            return None
        redacted = value
        redacted = re.sub(r"(?i)(authorization\s*[:=]\s*)([^\s,;]+)", r"\1***", redacted)
        redacted = re.sub(r"(?i)(bearer\s+)[^\s,;]+", r"\1***", redacted)
        redacted = re.sub(
            r"(?i)((?:api[_-]?key|token|secret|password)\s*[:=]\s*)([^\s,;]+)",
            r"\1***",
            redacted,
        )
        return redacted

    @classmethod
    def _redact_args(cls, args: list[str]) -> list[str]:
        redacted: list[str] = []
        redact_next_n = 0
        for arg in args:
            lower = arg.lower()
            if redact_next_n > 0:
                redacted.append("***")
                redact_next_n -= 1
                continue
            if lower == "--header":
                redacted.append(arg)
                # Some shells split "Authorization: Bearer <token>" into multiple args.
                redact_next_n = 2
                continue
            if lower in {"--auth-token", "--token", "--api-key"}:
                redacted.append(arg)
                redact_next_n = 1
                continue
            if any(marker in lower for marker in ("authorization", "bearer ", "token=", "api_key=", "apikey=", "secret=", "password=")):
                redacted.append("***")
            else:
                redacted.append(arg)
        return redacted

    def _ensure_loop(self) -> None:
        if self._loop and self._thread and self._thread.is_alive():
            return

        loop = asyncio.new_event_loop()

        def _run_loop() -> None:
            asyncio.set_event_loop(loop)
            loop.run_forever()

        thread = threading.Thread(target=_run_loop, daemon=True, name="linear-official-mcp")
        thread.start()

        self._loop = loop
        self._thread = thread

    def _submit(self, coro: Any, timeout: float | None = None) -> Any:
        if not self._loop:
            raise RuntimeError("event loop not initialized")
        future = asyncio.run_coroutine_threadsafe(coro, self._loop)
        try:
            return future.result(timeout=timeout or (self._read_timeout_seconds + 10))
        except concurrent.futures.TimeoutError:
            future.cancel()
            raise

    async def _connect_async(self, read_timeout: float | None = None) -> None:
        if self._session is not None:
            return

        effective_read_timeout = read_timeout or self._read_timeout_seconds

        if self._transport == "stdio":
            params = StdioServerParameters(
                command=self._command,
                args=self._args,
                env=self._env,
                cwd=self._cwd,
            )
            self._transport_cm = stdio_client(params)
        else:
            self._transport_cm = streamablehttp_client(
                self._url,
                headers=self._headers,
                timeout=self._timeout_seconds,
                sse_read_timeout=self._sse_read_timeout_seconds,
                terminate_on_close=False,
            )
        transport_streams = await self._transport_cm.__aenter__()
        if len(transport_streams) == 3:
            read_stream, write_stream, _ = transport_streams
        elif len(transport_streams) == 2:
            read_stream, write_stream = transport_streams
        else:
            raise RuntimeError("official MCP transport returned unexpected stream tuple")

        self._session_cm = ClientSession(
            read_stream,
            write_stream,
            read_timeout_seconds=timedelta(seconds=effective_read_timeout),
        )
        self._session = await self._session_cm.__aenter__()
        await self._session.initialize()
        self._last_connected_at = time.time()

    async def _disconnect_async(self) -> None:
        if self._session_cm is not None:
            try:
                await self._session_cm.__aexit__(None, None, None)
            except Exception as exc:
                self._log_cleanup_exception("Official MCP session cleanup failed", exc)
        if self._transport_cm is not None:
            try:
                await self._transport_cm.__aexit__(None, None, None)
            except Exception as exc:
                self._log_cleanup_exception("Official MCP transport cleanup failed", exc)

        self._session = None
        self._session_cm = None
        self._transport_cm = None

    def _ensure_connected(self) -> None:
        self._ensure_loop()
        self._submit(
            self._connect_async(),
            timeout=self._auth_timeout_seconds + 10,
        )

    @staticmethod
    def _log_cleanup_exception(prefix: str, exc: Exception) -> None:
        message = str(exc)
        if "Attempted to exit cancel scope in a different task" in message:
            logger.debug("%s: %s", prefix, exc)
            return
        logger.warning("%s: %s", prefix, exc)

    def _normalize_result(self, result: Any) -> Any:
        if getattr(result, "isError", False):
            text = self._extract_text(result)
            raise OfficialToolError(
                "official_tool_error", text or "official MCP returned an error"
            )

        structured = getattr(result, "structuredContent", None)
        if structured is not None:
            return structured

        text = self._extract_text(result)
        if text:
            try:
                return json.loads(text)
            except Exception:
                return {"text": text}

        if hasattr(result, "model_dump"):
            return result.model_dump()
        return result

    @staticmethod
    def _extract_text(result: Any) -> str:
        content = getattr(result, "content", None) or []
        texts: list[str] = []
        for block in content:
            if getattr(block, "type", None) == "text":
                text = getattr(block, "text", "")
                if text:
                    texts.append(text)
        return "\n".join(texts).strip()

    def _record_failure(self, exc: Exception) -> None:
        self._failure_count += 1
        self._last_failure_at = time.time()
        self._last_error = f"{exc.__class__.__name__}: {exc}"
        safe_error = self._redact_text(self._last_error) or exc.__class__.__name__
        logger.warning("Official MCP call failed (%s): %s", exc.__class__.__name__, safe_error)

    def _record_success(self, *, clear_shared_cooldown: bool = False) -> None:
        self._failure_count = 0
        self._last_error = None
        self._auth_retry_not_before = None
        self._last_auth_failure_reason = None
        if clear_shared_cooldown:
            self._clear_shared_auth_retry_state()

    @staticmethod
    def _is_likely_auth_connect_error(exc: Exception) -> bool:
        # Timeout during initial connect is usually OAuth flow waiting/expiry.
        if isinstance(exc, (TimeoutError, concurrent.futures.TimeoutError)):
            return True

        text = f"{exc.__class__.__name__}: {exc}".lower()
        auth_signals = (
            "oauth",
            "unauthorized",
            "forbidden",
            "401",
            "403",
            "consent",
            "login",
            "browser",
            "authorize",
            "expired",
        )
        return any(signal in text for signal in auth_signals)

    def _mark_auth_retry_cooldown(self, exc: Exception) -> None:
        if self._auth_retry_cooldown_seconds <= 0:
            return
        now = time.time()
        self._auth_retry_not_before = now + self._auth_retry_cooldown_seconds
        self._last_auth_failure_reason = f"{exc.__class__.__name__}: {exc}"
        self._write_shared_auth_retry_state(
            self._auth_retry_not_before,
            self._last_auth_failure_reason,
        )
        safe_reason = self._redact_text(self._last_auth_failure_reason) or exc.__class__.__name__
        logger.warning(
            "Suppressing official MCP reconnect attempts for %.0fs after auth-related failure: %s",
            self._auth_retry_cooldown_seconds,
            safe_reason,
        )

    def _auth_retry_cooldown_error(self) -> OfficialToolError | None:
        shared_not_before, shared_reason = self._read_shared_auth_retry_state()
        if shared_not_before is not None and (
            self._auth_retry_not_before is None or shared_not_before > self._auth_retry_not_before
        ):
            self._auth_retry_not_before = shared_not_before
            self._last_auth_failure_reason = shared_reason

        if self._auth_retry_not_before is None:
            return None
        now = time.time()
        if now >= self._auth_retry_not_before:
            self._auth_retry_not_before = None
            self._last_auth_failure_reason = None
            self._clear_shared_auth_retry_state()
            return None
        remaining = int(self._auth_retry_not_before - now)
        reason = self._last_auth_failure_reason or "recent authentication failure"
        return OfficialToolError(
            "official_auth_cooldown",
            (
                "official MCP auth retry is temporarily suppressed "
                f"for {remaining}s ({reason})"
            ),
        )

    def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
        args = arguments or {}
        with self._lock:
            for attempt in range(2):
                connected_this_attempt = False
                try:
                    if self._session is None:
                        cooldown_err = self._auth_retry_cooldown_error()
                        if cooldown_err is not None:
                            raise cooldown_err
                        self._last_connect_attempt_at = time.time()
                        try:
                            self._ensure_connected()
                            connected_this_attempt = True
                        except Exception as connect_exc:
                            self._record_failure(connect_exc)
                            try:
                                self._submit(self._disconnect_async())
                            except Exception as cleanup_exc:
                                self._log_cleanup_exception("Official MCP disconnect failed", cleanup_exc)

                            if self._is_likely_auth_connect_error(connect_exc):
                                self._mark_auth_retry_cooldown(connect_exc)
                                raise OfficialToolError(
                                    "official_auth_required",
                                    (
                                        "official MCP authentication failed and reconnect was paused. "
                                        "Complete OAuth login once, then retry after cooldown."
                                    ),
                                ) from connect_exc

                            if attempt == 1:
                                raise OfficialToolError(
                                    "official_unavailable",
                                    f"official MCP connect failed for tool '{name}': {connect_exc}",
                                ) from connect_exc
                            continue

                    if self._session is None:
                        raise RuntimeError("official MCP session unavailable")
                    result = self._submit(self._session.call_tool(name, arguments=args))
                    normalized = self._normalize_result(result)
                    self._record_success(clear_shared_cooldown=connected_this_attempt)
                    return normalized
                except OfficialToolError as exc:
                    if exc.code == "official_tool_error":
                        # Do not degrade semantic tool errors into transport failures.
                        raise
                    if exc.code == "official_auth_cooldown":
                        self._record_failure(exc)
                        raise
                    if exc.code == "official_auth_required":
                        raise
                    self._record_failure(exc)
                    try:
                        self._submit(self._disconnect_async())
                    except Exception as cleanup_exc:
                        self._log_cleanup_exception("Official MCP disconnect failed", cleanup_exc)
                    if attempt == 1:
                        raise
                except Exception as exc:
                    self._record_failure(exc)
                    try:
                        self._submit(self._disconnect_async())
                    except Exception as cleanup_exc:
                        self._log_cleanup_exception("Official MCP disconnect failed", cleanup_exc)

                    if attempt == 1:
                        raise OfficialToolError(
                            "official_unavailable",
                            f"official MCP call failed for tool '{name}': {exc}",
                        ) from exc

        raise OfficialToolError("official_unavailable", "official MCP unavailable")

    def list_tools(self) -> list[str]:
        """Return tool names from the official MCP session.

        Does NOT attempt to connect or reconnect.
        """
        with self._lock:
            if self._session is None:
                raise OfficialToolError(
                    "official_not_connected",
                    "official MCP is not connected; call reconnect_official first",
                )
            try:
                result = self._submit(self._session.list_tools())
                tools = getattr(result, "tools", []) or []
                self._record_success()
                return [t.name for t in tools if getattr(t, "name", None)]
            except Exception as exc:
                self._record_failure(exc)
                logger.warning("list_tools failed: %s", exc)
                raise OfficialToolError(
                    "official_unavailable",
                    f"failed to list official tools: {exc}",
                ) from exc

    def reconnect(self, ignore_cooldown: bool = False) -> dict[str, Any]:
        """Force disconnect+connect without clearing token cache."""
        with self._lock:
            if ignore_cooldown:
                self._auth_retry_not_before = None
                self._last_auth_failure_reason = None
                self._clear_shared_auth_retry_state()
            else:
                cooldown_err = self._auth_retry_cooldown_error()
                if cooldown_err is not None:
                    self._record_failure(cooldown_err)
                    raise cooldown_err
            try:
                self._submit(self._disconnect_async())
            except Exception as exc:
                self._log_cleanup_exception("Official MCP disconnect during reconnect failed", exc)
            self._session = None
            self._session_cm = None
            self._transport_cm = None
            self._last_connect_attempt_at = time.time()

            try:
                self._ensure_connected()
                self._record_success(clear_shared_cooldown=True)
            except Exception as exc:
                self._record_failure(exc)
                if self._is_likely_auth_connect_error(exc):
                    self._mark_auth_retry_cooldown(exc)
                    raise OfficialToolError(
                        "official_auth_required",
                        (
                            "official MCP authentication failed during reconnect. "
                            "Complete OAuth login once, then retry."
                        ),
                    ) from exc
                raise OfficialToolError(
                    "official_unavailable",
                    f"official reconnect failed: {exc}",
                ) from exc

            return {
                "connected": self._session is not None,
                "authRetryRemainingSeconds": (
                    max(0, int(self._auth_retry_not_before - time.time()))
                    if self._auth_retry_not_before is not None
                    else 0
                ),
            }

    def is_connected(self) -> bool:
        with self._lock:
            return self._session is not None

    def get_health(self) -> dict[str, Any]:
        with self._lock:
            health_url = self._url
            try:
                parsed = urlsplit(self._url)
                host = parsed.hostname or ""
                if parsed.port:
                    host = f"{host}:{parsed.port}"
                health_url = urlunsplit((parsed.scheme, host, parsed.path, "", ""))
            except Exception:
                # Keep original URL when parsing fails; it is still redacted in error fields.
                pass
            health: dict[str, Any] = {
                "transport": self._transport,
                "url": health_url,
                "connected": self._session is not None,
                "failureCount": self._failure_count,
                "lastError": self._redact_text(self._last_error),
                "lastFailureAt": self._last_failure_at,
                "lastConnectedAt": self._last_connected_at,
                "lastConnectAttemptAt": self._last_connect_attempt_at,
                "authRetryCooldownSeconds": self._auth_retry_cooldown_seconds,
                "authRetryNotBefore": self._auth_retry_not_before,
                "authRetryRemainingSeconds": (
                    max(0, int(self._auth_retry_not_before - time.time()))
                    if self._auth_retry_not_before is not None
                    else 0
                ),
                "lastAuthFailureReason": self._redact_text(self._last_auth_failure_reason),
                "authRetryStatePath": str(self._auth_retry_state_path),
            }
            if self._transport == "stdio":
                health["command"] = self._command
                health["args"] = self._redact_args(self._args)
            else:
                health["hasHeaders"] = self._headers is not None
            return health

    def close(self) -> None:
        with self._lock:
            loop = self._loop
            thread = self._thread

            if loop:
                try:
                    self._submit(self._disconnect_async())
                except Exception as exc:
                    self._log_cleanup_exception("Official MCP disconnect during close failed", exc)

                loop.call_soon_threadsafe(loop.stop)

            if thread and thread.is_alive():
                thread.join(timeout=DEFAULT_CLOSE_JOIN_TIMEOUT_SECONDS)

            if loop and (thread is None or not thread.is_alive()):
                loop.close()
                self._loop = None
                self._thread = None
            elif thread and thread.is_alive():
                logger.warning("Official MCP loop thread did not stop within timeout")
                # Keep references so callers can attempt close again.
                self._loop = loop
                self._thread = thread

            self._session = None
            self._session_cm = None
            self._transport_cm = None

    # ------------------------------------------------------------------
    # Explicit reauth: clear mcp-remote tokens + disconnect + reconnect
    # ------------------------------------------------------------------

    @staticmethod
    def _find_token_cache_dirs() -> list[Path]:
        """Find mcp-remote token cache directories."""
        mcp_auth = Path.home() / ".mcp-auth"
        if not mcp_auth.is_dir():
            return []
        return sorted(mcp_auth.glob("mcp-remote-*"))

    @staticmethod
    def clear_token_cache_for_url(url: str = DEFAULT_OFFICIAL_MCP_URL) -> int:
        """Delete cached OAuth tokens for a URL without needing an instance.

        Used by signal handlers where the manager may not be initialized yet.
        """
        url_hash = hashlib.md5(url.encode()).hexdigest()  # noqa: S324
        deleted = 0
        suffixes = ["_tokens.json", "_client_info.json", "_code_verifier.txt"]
        for cache_dir in OfficialMcpSessionManager._find_token_cache_dirs():
            for suffix in suffixes:
                cache_file = cache_dir / f"{url_hash}{suffix}"
                if cache_file.exists():
                    try:
                        cache_file.unlink()
                        deleted += 1
                    except OSError:
                        pass
        return deleted

    def _clear_token_cache(self) -> int:
        """Delete cached OAuth tokens for the configured MCP URL.

        Returns the number of deleted files.
        """
        url_hash = hashlib.md5(self._url.encode()).hexdigest()  # noqa: S324
        deleted = 0
        suffixes = ["_tokens.json", "_client_info.json", "_code_verifier.txt"]
        for cache_dir in self._find_token_cache_dirs():
            for suffix in suffixes:
                cache_file = cache_dir / f"{url_hash}{suffix}"
                if cache_file.exists():
                    try:
                        cache_file.unlink()
                        deleted += 1
                        logger.info("Deleted token cache file: %s", cache_file)
                    except OSError as exc:
                        logger.warning("Failed to delete %s: %s", cache_file, exc)
        return deleted

    def reauth(self) -> dict[str, Any]:
        """Clear OAuth tokens, disconnect, and reconnect (triggers browser auth).

        Call this explicitly when the user wants to switch Linear accounts.
        """
        with self._lock:
            deleted = self._clear_token_cache()
            try:
                self._submit(self._disconnect_async())
            except Exception as exc:
                self._log_cleanup_exception("Disconnect during reauth failed", exc)
            self._session = None
            self._session_cm = None
            self._transport_cm = None
            self._auth_retry_not_before = None
            self._last_auth_failure_reason = None
            self._clear_shared_auth_retry_state()

        # Reconnect — will trigger browser OAuth since tokens are cleared.
        self._ensure_connected()
        self._record_success(clear_shared_cooldown=True)

        return {
            "deletedTokenFiles": deleted,
            "connected": self._session is not None,
        }
