"""Environment manager for detecting Python environments and kernels."""

import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
import logging

from .structured_log import get_logger as _get_slog

logger = logging.getLogger("skop-runner")
_log = _get_slog("env_manager")


def _sanitize_pip_error(error_text: str) -> str:
    """
    Sanitize pip error output to avoid leaking internal paths.

    Replaces home directory and common system paths with placeholders.
    """
    if not error_text:
        return error_text

    # Replace home directory with ~
    home = str(Path.home())
    error_text = error_text.replace(home, "~")

    # Replace common system paths (Unix)
    for prefix in ["/usr/local/", "/usr/", "/opt/"]:
        if prefix in error_text:
            error_text = error_text.replace(prefix, "<system>/")

    # Replace Windows-style paths
    if os.name == "nt":
        # Replace common Windows paths
        for env_var in ["LOCALAPPDATA", "APPDATA", "PROGRAMFILES"]:
            path = os.environ.get(env_var)
            if path and path in error_text:
                error_text = error_text.replace(path, f"<{env_var}>")

    return error_text

# Cache TTL for environment detection (60 seconds)
ENV_CACHE_TTL = 60.0


class EnvironmentManager:
    """Detects and manages Python environments."""

    def __init__(self, project_root: Optional[str] = None):
        self.project_root = Path(project_root).resolve() if project_root else None
        # Cache for environment detection results
        self._env_cache: Optional[List[Dict]] = None
        self._env_cache_time: float = 0.0
        self._env_cache_root: Optional[Path] = None

    def set_project_root(self, project_root: str):
        """Update the project root for environment scanning."""
        self.project_root = Path(project_root).resolve()
        # Invalidate cache when project root changes
        self._invalidate_cache()

    def _invalidate_cache(self):
        """Invalidate the environment detection cache."""
        self._env_cache = None
        self._env_cache_time = 0.0
        self._env_cache_root = None

    def detect_environments(self, force_refresh: bool = False) -> List[Dict]:
        """
        Detect all available Python environments.

        Searches for (in priority order):
        1. Poetry environments (if pyproject.toml exists)
        2. Pipenv environments (if Pipfile exists)
        3. Local venvs in project directory (.venv, venv, env)
        4. Conda environments
        5. Pyenv versions (~/.pyenv/versions)
        6. Virtualenvwrapper environments ($WORKON_HOME or ~/.virtualenvs)
        7. System Python (fallback)

        Note: Poetry/Pipenv are checked before local venvs so that in-project
        .venv directories get the correct type label ("poetry"/"pipenv").

        Args:
            force_refresh: If True, bypass cache and refresh environment list

        Returns:
            List of environment dicts with name, type, path, pythonVersion

        Note: Results are cached for ENV_CACHE_TTL seconds to avoid expensive
              subprocess calls on every request.
        """
        # Check if cache is valid
        now = time.monotonic()
        cache_valid = (
            not force_refresh
            and self._env_cache is not None
            and (now - self._env_cache_time) < ENV_CACHE_TTL
            and self._env_cache_root == self.project_root
        )

        if cache_valid:
            _log.debug("env.detect_complete", envs_found=len(self._env_cache), cache_hit=True, duration_ms=0)
            return self._env_cache

        _t0 = time.perf_counter()
        logger.debug("Refreshing environment detection (cache miss or expired)")

        environments = []
        seen_paths = set()

        def add_envs(new_envs: List[Dict]) -> None:
            """Helper to add environments, avoiding duplicates."""
            for env in new_envs:
                python_path = env.get("python_path", "")
                if python_path and python_path not in seen_paths:
                    environments.append(env)
                    seen_paths.add(python_path)

        # Detection order matters for correct type labeling:
        # Poetry/Pipenv run BEFORE local venvs so their in-project .venv
        # gets labeled as "poetry"/"pipenv" instead of generic "venv"

        # 1. Poetry environments (if pyproject.toml exists)
        add_envs(self._find_poetry_envs())

        # 2. Pipenv environments (if Pipfile exists)
        add_envs(self._find_pipenv_envs())

        # 3. Local virtual environments (if project root is set)
        if self.project_root:
            add_envs(self._find_local_venvs())

        # 4. Conda environments
        add_envs(self._find_conda_envs())

        # 5. Pyenv versions
        add_envs(self._find_pyenv_versions())

        # 6. Virtualenvwrapper environments
        add_envs(self._find_virtualenvwrapper_envs())

        # 7. System Python (always available as fallback)
        system = self._get_system_python()
        if system and system["python_path"] not in seen_paths:
            environments.append(system)

        # Cache the results
        self._env_cache = environments
        self._env_cache_time = time.monotonic()
        self._env_cache_root = self.project_root

        # Find the active environment name, if any
        _active = next((e.get("name") for e in environments if e.get("is_active")), None)
        _log.info(
            "env.detect_complete",
            envs_found=len(environments),
            active_env=_active,
            cache_hit=False,
            duration_ms=round((time.perf_counter() - _t0) * 1000),
        )

        return environments

    # Directories that never contain venvs — skip to avoid expensive iterdir()
    _SKIP_DIRS = frozenset({
        "node_modules", "__pycache__", "dist", "build", "target",
        ".git", ".hg", ".svn", ".next", ".cache", ".tox",
    })

    def _find_local_venvs(self) -> List[Dict]:
        """Find virtual environments in the project directory tree.

        Detects venvs by checking for the pyvenv.cfg marker file
        (created by python -m venv), rather than matching on hardcoded
        directory names. Scans project root and one level deep.
        """
        venvs = []

        if not self.project_root:
            return venvs

        def _try_add_venv(path: Path, display_name: str) -> None:
            if path.is_dir() and (path / "pyvenv.cfg").exists():
                env = self._check_venv(path, display_name)
                if env:
                    venvs.append(env)

        # Check all directories in project root
        try:
            for entry in self.project_root.iterdir():
                if entry.is_dir() and entry.name not in self._SKIP_DIRS:
                    _try_add_venv(entry, entry.name)
        except PermissionError:
            pass

        # Also scan subdirectories (one level deep for performance)
        try:
            for subdir in self.project_root.iterdir():
                if subdir.is_dir() and not subdir.name.startswith(".") and subdir.name not in self._SKIP_DIRS:
                    # Skip if subdir itself is a venv (already found above)
                    if (subdir / "pyvenv.cfg").exists():
                        continue
                    try:
                        for entry in subdir.iterdir():
                            if entry.is_dir() and entry.name not in self._SKIP_DIRS:
                                _try_add_venv(entry, f"{subdir.name}/{entry.name}")
                    except PermissionError:
                        pass
        except PermissionError:
            pass

        return venvs

    def _check_venv(self, venv_path: Path, display_name: str) -> Optional[Dict]:
        """Check if path is a valid venv and return env dict."""
        if not venv_path.exists() or not venv_path.is_dir():
            return None

        python_path = self._get_venv_python(venv_path)
        if not python_path or not python_path.exists():
            return None

        version = self._get_python_version(python_path)

        return {
            "name": display_name,
            "display_name": display_name,
            "type": "venv",
            "path": str(venv_path),
            "python_path": str(python_path),
            "python_version": version,
            "is_active": self._is_active_env(venv_path),
            "has_ipykernel": self._check_has_ipykernel(python_path),
        }

    def _find_poetry_envs(self) -> List[Dict]:
        """
        Find Poetry-managed virtual environments.

        Checks for:
        1. In-project .venv (when virtualenvs.in-project = true)
        2. External venv via `poetry env info -p`

        Returns empty list if:
        - No project root is set
        - No pyproject.toml exists
        - Poetry is not installed
        """
        envs = []

        if not self.project_root:
            return envs

        pyproject = self.project_root / "pyproject.toml"
        if not pyproject.exists():
            return envs

        # Check for in-project .venv first (poetry config virtualenvs.in-project true)
        # This is the most common setup for Poetry projects
        in_project_venv = self.project_root / ".venv"
        if in_project_venv.exists():
            python_path = self._get_venv_python(in_project_venv)
            if python_path and python_path.exists():
                version = self._get_python_version(python_path)
                envs.append({
                    "name": "poetry",
                    "display_name": "poetry",
                    "type": "poetry",
                    "path": str(in_project_venv),
                    "python_path": str(python_path),
                    "python_version": version,
                    "is_active": self._is_active_env(in_project_venv),
                    "has_ipykernel": self._check_has_ipykernel(python_path),
                })
                return envs  # In-project venv takes precedence

        # Try poetry env info to get external venv path
        try:
            # Clear VIRTUAL_ENV to avoid poetry using the runner's venv
            env = os.environ.copy()
            env.pop("VIRTUAL_ENV", None)

            result = subprocess.run(
                ["poetry", "env", "info", "-p"],
                capture_output=True,
                text=True,
                timeout=10,
                cwd=str(self.project_root),
                env=env,
            )
            if result.returncode == 0 and result.stdout.strip():
                venv_path = Path(result.stdout.strip())
                python_path = self._get_venv_python(venv_path)
                if python_path and python_path.exists():
                    version = self._get_python_version(python_path)
                    envs.append({
                        "name": "poetry",
                        "display_name": "poetry",
                        "type": "poetry",
                        "path": str(venv_path),
                        "python_path": str(python_path),
                        "python_version": version,
                        "is_active": self._is_active_env(venv_path),
                        "has_ipykernel": self._check_has_ipykernel(python_path),
                    })
        except FileNotFoundError:
            logger.debug("Poetry not found")
        except subprocess.TimeoutExpired:
            logger.warning("Poetry env info timed out")
        except Exception as e:
            logger.debug(f"Error detecting Poetry environment: {e}")

        return envs

    def _find_pipenv_envs(self) -> List[Dict]:
        """
        Find Pipenv-managed virtual environments.

        Checks for Pipfile and uses `pipenv --venv` to locate the virtualenv.

        Returns empty list if:
        - No project root is set
        - No Pipfile exists
        - Pipenv is not installed
        """
        envs = []

        if not self.project_root:
            return envs

        pipfile = self.project_root / "Pipfile"
        if not pipfile.exists():
            return envs

        try:
            # Clear VIRTUAL_ENV to avoid pipenv using the runner's venv
            # instead of the project's Pipfile
            env = os.environ.copy()
            env.pop("VIRTUAL_ENV", None)
            env.pop("PIPENV_ACTIVE", None)

            result = subprocess.run(
                ["pipenv", "--venv"],
                capture_output=True,
                text=True,
                timeout=10,
                cwd=str(self.project_root),
                env=env,
            )
            if result.returncode == 0 and result.stdout.strip():
                venv_path = Path(result.stdout.strip())
                python_path = self._get_venv_python(venv_path)
                if python_path and python_path.exists():
                    version = self._get_python_version(python_path)
                    envs.append({
                        "name": "pipenv",
                        "display_name": "pipenv",
                        "type": "pipenv",
                        "path": str(venv_path),
                        "python_path": str(python_path),
                        "python_version": version,
                        "is_active": self._is_active_env(venv_path),
                        "has_ipykernel": self._check_has_ipykernel(python_path),
                    })
        except FileNotFoundError:
            logger.debug("Pipenv not found")
        except subprocess.TimeoutExpired:
            logger.warning("Pipenv --venv timed out")
        except Exception as e:
            logger.debug(f"Error detecting Pipenv environment: {e}")

        return envs

    def _find_virtualenvwrapper_envs(self) -> List[Dict]:
        """
        Find virtualenvwrapper environments from WORKON_HOME.

        Scans $WORKON_HOME (default: ~/.virtualenvs) for virtual environments.
        """
        envs = []

        workon_home = Path(os.environ.get("WORKON_HOME", Path.home() / ".virtualenvs"))

        if not workon_home.exists():
            return envs

        try:
            for env_dir in workon_home.iterdir():
                if not env_dir.is_dir():
                    continue

                # Skip hidden directories
                if env_dir.name.startswith("."):
                    continue

                python_path = self._get_venv_python(env_dir)
                if python_path and python_path.exists():
                    version = self._get_python_version(python_path)
                    envs.append({
                        "name": env_dir.name,
                        "display_name": env_dir.name,
                        "type": "virtualenvwrapper",
                        "path": str(env_dir),
                        "python_path": str(python_path),
                        "python_version": version,
                        "is_active": self._is_active_env(env_dir),
                        "has_ipykernel": self._check_has_ipykernel(python_path),
                    })
        except PermissionError:
            logger.debug(f"Permission denied accessing {workon_home}")
        except Exception as e:
            logger.debug(f"Error detecting virtualenvwrapper environments: {e}")

        return envs

    def _find_pyenv_versions(self) -> List[Dict]:
        """
        Find Python versions and virtualenvs managed by pyenv.

        Scans $PYENV_ROOT/versions (default: ~/.pyenv/versions) for:
        1. Python versions (e.g., 3.11.0, 3.12.1)
        2. pyenv-virtualenv environments (in {version}/envs/{env_name}/)

        pyenv-virtualenv environments are labeled as "pyenv-virtualenv" type
        to distinguish them from base Python versions.
        """
        envs = []

        if sys.platform == "win32":
            pyenv_root = Path(os.environ.get("PYENV", os.environ.get(
                "PYENV_ROOT", Path.home() / ".pyenv" / "pyenv-win")))
        else:
            pyenv_root = Path(os.environ.get("PYENV_ROOT", Path.home() / ".pyenv"))
        versions_dir = pyenv_root / "versions"

        if not versions_dir.exists():
            return envs

        try:
            for version_dir in versions_dir.iterdir():
                if not version_dir.is_dir():
                    continue

                # Skip hidden directories
                if version_dir.name.startswith("."):
                    continue

                # Check for pyenv-virtualenv environments in {version}/envs/
                envs_subdir = version_dir / "envs"
                if envs_subdir.exists() and envs_subdir.is_dir():
                    for venv_dir in envs_subdir.iterdir():
                        if not venv_dir.is_dir():
                            continue
                        if venv_dir.name.startswith("."):
                            continue

                        python_path = venv_dir / "bin" / "python"
                        if not python_path.exists():
                            python_path = venv_dir / "Scripts" / "python.exe"

                        if python_path.exists():
                            version = self._get_python_version(python_path)
                            envs.append({
                                "name": venv_dir.name,
                                "display_name": venv_dir.name,
                                "type": "pyenv-virtualenv",
                                "path": str(venv_dir),
                                "python_path": str(python_path),
                                "python_version": version,
                                "is_active": self._is_active_env(venv_dir),
                                "has_ipykernel": self._check_has_ipykernel(python_path),
                            })

                # Detect the base Python version itself
                python_path = version_dir / "bin" / "python"
                if not python_path.exists():
                    # Try Windows path
                    python_path = version_dir / "Scripts" / "python.exe"

                if python_path.exists():
                    version = self._get_python_version(python_path)
                    envs.append({
                        "name": version_dir.name,
                        "display_name": version_dir.name,
                        "type": "pyenv",
                        "path": str(version_dir),
                        "python_path": str(python_path),
                        "python_version": version,
                        "is_active": self._is_active_env(version_dir),
                        "has_ipykernel": self._check_has_ipykernel(python_path),
                    })
        except PermissionError:
            logger.debug(f"Permission denied accessing {versions_dir}")
        except Exception as e:
            logger.debug(f"Error detecting pyenv versions: {e}")

        return envs

    def _find_conda_envs(self) -> List[Dict]:
        """
        Find conda environments.

        Only includes:
        - Global conda environments (base, named envs in conda's envs directory)
        - Local conda environments within the current project_root

        This matches VS Code's behavior of not showing conda envs from other projects.
        """
        envs = []

        try:
            # Use `conda info --json` (not --envs) to get both env list and prefix info
            result = subprocess.run(
                ["conda", "info", "--json"],
                capture_output=True,
                text=True,
                timeout=10,
            )

            if result.returncode != 0:
                logger.debug(f"Conda info returned non-zero exit code: {result.returncode}")
                return envs

            info = json.loads(result.stdout)
            conda_prefix = info.get("conda_prefix") or info.get("root_prefix", "")
            # Get the conda envs directories (where global envs are stored)
            envs_dirs = [Path(d) for d in info.get("envs_dirs", [])]

            for env_path_str in info.get("envs", []):
                env_path = Path(env_path_str)

                # Filter: only include global envs or envs in current project
                is_base = str(env_path) == conda_prefix
                is_global = any(
                    env_path.parent == envs_dir for envs_dir in envs_dirs
                )
                is_in_project = (
                    self.project_root is not None
                    and env_path.is_relative_to(self.project_root)
                )

                if not (is_base or is_global or is_in_project):
                    logger.debug(f"Skipping conda env from other project: {env_path}")
                    continue

                python_path = self._get_conda_python(env_path)

                if python_path and python_path.exists():
                    version = self._get_python_version(python_path)

                    # Determine environment name
                    if is_base:
                        name = "base (conda)"
                    else:
                        name = f"{env_path.name} (conda)"

                    envs.append({
                        "name": name,
                        "display_name": name,
                        "type": "conda",
                        "path": str(env_path),
                        "python_path": str(python_path),
                        "python_version": version,
                        "is_active": self._is_active_env(env_path),
                        "has_ipykernel": self._check_has_ipykernel(python_path),
                    })

        except FileNotFoundError:
            logger.debug("Conda not found")
        except subprocess.TimeoutExpired:
            logger.warning("Conda info timed out")
        except json.JSONDecodeError as e:
            logger.warning(f"Failed to parse conda info: {e}")
        except Exception as e:
            logger.warning(f"Error detecting conda environments: {e}")

        return envs

    def _get_system_python(self) -> Optional[Dict]:
        """Get system Python info (outside of any venv)."""
        # Don't use sys.executable - it's the runner's venv Python
        # Instead, find the actual system Python
        python_path = self._find_system_python()
        if not python_path:
            return None

        version = self._get_python_version(python_path)

        return {
            "name": "System Python",
            "display_name": "System Python",
            "type": "system",
            "path": str(python_path.parent),
            "python_path": str(python_path),
            "python_version": version,
            "is_active": True,
            "has_ipykernel": self._check_has_ipykernel(python_path),
        }

    def _find_system_python(self) -> Optional[Path]:
        """Find the system Python executable (not in a venv)."""
        import shutil

        # Windows: use py.exe launcher or python.exe from PATH
        if sys.platform == "win32":
            py_path = shutil.which("py")
            if py_path:
                return Path(py_path)
            python_path = shutil.which("python")
            if python_path:
                p = Path(python_path).resolve()
                # Exclude WindowsApps stub that redirects to Microsoft Store
                if "WindowsApps" not in str(p):
                    return p
            return None

        # Unix/macOS: common system Python locations
        candidates = [
            "/usr/bin/python3",
            "/usr/local/bin/python3",
            "/opt/homebrew/bin/python3",  # macOS Homebrew
        ]

        # Check explicit paths first
        for path_str in candidates:
            path = Path(path_str)
            if path.exists() and path.is_file():
                return path

        # Fall back to finding python3 in PATH, but exclude venv/conda paths
        python3_path = shutil.which("python3")
        if python3_path:
            path = Path(python3_path).resolve()
            # Skip if it's in a venv (has pyvenv.cfg) or conda env (has conda-meta)
            parent_parent = path.parent.parent
            if not (parent_parent / "pyvenv.cfg").exists() and \
               not (parent_parent / "conda-meta").exists():
                return path

        return None

    def _get_venv_python(self, venv_path: Path) -> Optional[Path]:
        """Get Python executable path for a venv."""
        # Unix
        unix_path = venv_path / "bin" / "python"
        if unix_path.exists():
            return unix_path

        # Windows
        win_path = venv_path / "Scripts" / "python.exe"
        if win_path.exists():
            return win_path

        return None

    def _get_conda_python(self, conda_path: Path) -> Optional[Path]:
        """Get Python executable path for a conda env."""
        # Unix
        unix_path = conda_path / "bin" / "python"
        if unix_path.exists():
            return unix_path

        # Windows
        win_path = conda_path / "python.exe"
        if win_path.exists():
            return win_path

        return None

    def _get_python_version(self, python_path: Path) -> str:
        """Get Python version from executable."""
        try:
            result = subprocess.run(
                [str(python_path), "--version"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode == 0:
                # Output: "Python 3.11.5"
                return result.stdout.strip().replace("Python ", "")
        except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
            pass
        return "unknown"

    def _check_has_ipykernel(self, python_path: Path) -> bool:
        """Check if ipykernel is installed in the environment."""
        try:
            result = subprocess.run(
                [str(python_path), "-c", "import ipykernel"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            return result.returncode == 0
        except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
            return False

    def _is_active_env(self, env_path: Path) -> bool:
        """Check if this environment is currently active."""
        try:
            current_prefix = Path(sys.prefix).resolve()
            return current_prefix == env_path.resolve()
        except Exception:
            return False

    def get_env_python(self, env_path: str) -> Optional[str]:
        """Get Python executable path for an environment."""
        path = Path(env_path)

        # Check if it's a venv
        python = self._get_venv_python(path)
        if python:
            return str(python)

        # Check if it's a conda env
        python = self._get_conda_python(path)
        if python:
            return str(python)

        return None

    async def install_ipykernel(self, python_path: str) -> Dict:
        """
        Install ipykernel in the specified Python environment.

        Args:
            python_path: Path to the Python executable

        Returns:
            Dict with success status and message

        Security:
            Validates that python_path is actually a Python executable
            before running pip install.
        """
        import asyncio

        # Security: Validate that python_path is actually a Python executable
        path = Path(python_path)
        if not path.exists():
            return {"success": False, "message": f"Python not found: {python_path}"}
        if not path.is_file():
            return {"success": False, "message": f"Not a file: {python_path}"}

        # Verify it's actually Python
        try:
            result = subprocess.run(
                [python_path, "--version"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            output = result.stdout + result.stderr
            if result.returncode != 0 or "Python" not in output:
                return {"success": False, "message": f"Not a valid Python executable: {python_path}"}
        except subprocess.TimeoutExpired:
            return {"success": False, "message": f"Python version check timed out"}
        except Exception as e:
            return {"success": False, "message": f"Cannot verify Python: {e}"}

        process = None
        try:
            # Use uv if available (10-100x faster than pip)
            use_uv = shutil.which("uv") is not None
            cmd = (
                ["uv", "pip", "install", "ipykernel", "--python", python_path]
                if use_uv
                else [python_path, "-m", "pip", "install", "ipykernel"]
            )
            process = await asyncio.create_subprocess_exec(
                *cmd,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            _, stderr = await asyncio.wait_for(
                process.communicate(),
                timeout=120,  # 2 minutes timeout for pip install
            )

            if process.returncode == 0:
                logger.info(f"Successfully installed ipykernel in {python_path}")
                return {
                    "success": True,
                    "message": "ipykernel installed successfully",
                }
            else:
                raw_error = stderr.decode() if stderr else "Unknown error"
                # Sanitize error to avoid leaking internal paths
                error_msg = _sanitize_pip_error(raw_error)
                logger.error(f"Failed to install ipykernel: {raw_error}")
                return {
                    "success": False,
                    "message": f"Failed to install ipykernel: {error_msg}",
                }

        except asyncio.TimeoutError:
            logger.error("ipykernel installation timed out")
            return {
                "success": False,
                "message": "Installation timed out after 2 minutes",
            }
        except asyncio.CancelledError:
            logger.warning("ipykernel installation was cancelled")
            raise  # Re-raise cancellation
        except Exception as e:
            logger.error(f"Error installing ipykernel: {e}")
            return {
                "success": False,
                "message": str(e),
            }
        finally:
            # Always clean up the subprocess if it exists and hasn't completed
            if process is not None and process.returncode is None:
                try:
                    process.kill()
                    await process.wait()
                except ProcessLookupError:
                    pass  # Process already exited
                except Exception as e:
                    logger.warning(f"Error killing subprocess: {e}")

    def _format_display_name(
        self, env_type: str, env_name: str, python_version: Optional[str]
    ) -> str:
        """
        Format display name like VS Code: 'Python 3.11.5 (.venv)'.

        Args:
            env_type: Environment type (venv, conda, pyenv, poetry, pipenv, system)
            env_name: Environment name or identifier
            python_version: Python version string or None

        Returns:
            Formatted display name
        """
        version = python_version or "unknown"

        if env_type == "venv":
            return f"Python {version} ({env_name})"
        elif env_type == "conda":
            # For conda, the env_name already includes "(conda)" suffix
            # Strip it if present and reformat
            name = env_name.replace(" (conda)", "")
            if name == "base":
                return f"Python {version} (conda: base)"
            return f"Python {version} (conda: {name})"
        elif env_type == "pyenv":
            return f"Python {version} (pyenv)"
        elif env_type == "pyenv-virtualenv":
            return f"Python {version} (pyenv: {env_name})"
        elif env_type == "poetry":
            return f"Python {version} (poetry)"
        elif env_type == "pipenv":
            return f"Python {version} (pipenv)"
        elif env_type == "virtualenvwrapper":
            return f"Python {version} ({env_name})"
        elif env_type == "system":
            return f"Python {version} (system)"
        else:
            return f"Python {version} ({env_name})"

    def get_available_environments(
        self,
        kernelspecs: Optional[Dict[str, Any]] = None,
        force_refresh: bool = False,
    ) -> List[Dict]:
        """
        Get available environments using VS Code-style environment-first approach.

        This method replaces merge_with_kernelspecs() with a cleaner approach:
        - Shows only detected Python environments (not raw kernelspecs)
        - Display names include Python version (e.g., "Python 3.11.5 (.venv)")
        - Deduplicates environments via symlink resolution
        - Excludes Python kernelspecs (they duplicate detected environments)
        - Includes non-Python kernelspecs (R, Julia, etc.)

        Args:
            kernelspecs: Jupyter Server kernelspecs response (from GET /api/kernelspecs).
                        Used only to include non-Python kernelspecs.
            force_refresh: If True, bypass cache for environment detection.

        Returns:
            List of environment dicts with:
            - name: Unique identifier
            - display_name: Human-readable name with Python version
            - type: "venv", "conda", "pyenv", "poetry", "pipenv", "system",
                    or "kernelspec:<language>" for non-Python
            - path: Path to environment directory
            - python_path: Path to Python executable
            - python_version: Python version string
            - is_active: Whether this env is currently active
            - has_ipykernel: Whether ipykernel is installed
            - kernel_name: Always None for Python envs (uses ephemeral registration),
                          or the kernelspec name for non-Python kernels
            - language: "python" or the kernel language
        """
        # Get detected Python environments
        environments = self.detect_environments(force_refresh=force_refresh)

        # Deduplicate by RESOLVED environment path (handles symlinked env directories)
        # We resolve the env directory path to handle cases like `venv -> .venv` symlink,
        # but we do NOT resolve the Python binary path - that would wrongly dedupe
        # separate venvs that happen to use the same base Python.
        seen_paths: set[str] = set()
        deduplicated: List[Dict] = []

        for env in environments:
            # Use the environment path as the dedup key
            env_path = env.get("path", "") or env.get("python_path", "")
            if not env_path:
                continue

            # Resolve symlinks on the env directory (not the Python binary)
            try:
                resolved_env_path = str(Path(env_path).resolve())
            except (OSError, ValueError):
                resolved_env_path = env_path

            if resolved_env_path in seen_paths:
                logger.debug(f"Skipping duplicate environment: {env['name']} -> {resolved_env_path}")
                continue

            seen_paths.add(resolved_env_path)

            # Copy the dict to avoid mutating the cached environment data
            env_copy = env.copy()

            # Update display name to include Python version (VS Code style)
            env_copy["display_name"] = self._format_display_name(
                env_copy["type"], env_copy["name"], env_copy.get("python_version")
            )
            # Python environments use ephemeral kernelspec registration
            env_copy["kernel_name"] = None
            env_copy["language"] = "python"

            deduplicated.append(env_copy)

        # Add non-Python kernelspecs (R, Julia, etc.) from Jupyter Server
        if kernelspecs:
            specs_dict = kernelspecs.get("kernelspecs", {})

            for spec_name, spec_data in specs_dict.items():
                spec = spec_data.get("spec", {})
                language = spec.get("language", "").lower()

                # Skip Python kernelspecs - they duplicate detected environments
                if language == "python":
                    continue

                # Skip our ephemeral specs (skop-*)
                if spec_name.startswith("skop-"):
                    continue

                display_name = spec.get("display_name", spec_name)

                deduplicated.append({
                    "name": spec_name,
                    "display_name": display_name,
                    "type": f"kernelspec:{language}" if language else "kernelspec",
                    "path": None,
                    "python_path": None,
                    "python_version": None,
                    "is_active": False,
                    "has_ipykernel": True,  # Non-Python kernels are ready to use
                    "kernel_name": spec_name,
                    "language": language or "unknown",
                })

        return deduplicated

    def get_installed_packages(
        self,
        env_path: Optional[str] = None,
        python_path: Optional[str] = None,
    ) -> List[Dict]:
        """
        List installed packages in an environment.

        Args:
            env_path: Path to the environment directory (venv/conda style)
            python_path: Direct path to Python executable (preferred if provided)

        At least one of env_path or python_path must be provided.
        If python_path is provided, it takes precedence.

        Returns:
            List of dicts with name and version for each package
        """
        # Resolve the Python executable
        resolved_python = None
        if python_path:
            # Use provided python_path directly
            resolved_python = python_path
        elif env_path:
            # Try to find Python in the environment directory
            resolved_python = self.get_env_python(env_path)

        if not resolved_python:
            logger.warning("No valid Python path could be resolved")
            return []

        try:
            result = subprocess.run(
                [resolved_python, "-m", "pip", "list", "--format=json"],
                capture_output=True,
                text=True,
                timeout=30,
            )
            if result.returncode == 0:
                return json.loads(result.stdout)
        except subprocess.TimeoutExpired:
            logger.warning(f"Pip list timed out for {resolved_python}")
        except FileNotFoundError:
            logger.warning(f"Python not found at {resolved_python}")
        except json.JSONDecodeError as e:
            logger.warning(f"Could not parse pip list output: {e}")

        return []
