import logging
import os
import platform
import re
import shutil as _sh
import subprocess
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any

import pytest
from _pytest.mark import Mark, MarkDecorator
from sensai.util.logging import configure

from serena.config.serena_config import SerenaConfig, SerenaPaths
from serena.constants import SERENA_MANAGED_DIR_NAME
from serena.project import Project
from serena.util.file_system import GitignoreParser
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.settings import SolidLSPSettings

from .solidlsp.clojure import is_clojure_cli_available
from .solidlsp.elixir import EXPERT_UNAVAILABLE
from .solidlsp.erlang import ERLANG_LS_UNAVAILABLE

configure(level=logging.INFO)

log = logging.getLogger(__name__)


@pytest.fixture(scope="session")
def resources_dir() -> Path:
    """Path to the test resources directory."""
    current_dir = Path(__file__).parent
    return current_dir / "resources"


class LanguageParamRequest:
    param: Language


_LANGUAGE_REPO_ALIASES: dict[Language, Language] = {
    Language.CPP_CCLS: Language.CPP,
    Language.PHP_PHPACTOR: Language.PHP,
    Language.PHP_PHPANTOM: Language.PHP,
    Language.PYTHON_JEDI: Language.PYTHON,
    Language.PYTHON_TY: Language.PYTHON,
    Language.RUBY_SOLARGRAPH: Language.RUBY,
    Language.PYTHON_TY: Language.PYTHON,
    Language.PYTHON_PYREFLY: Language.PYTHON,
}

PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY]


def get_repo_path(language: Language) -> Path:
    repo_language = _LANGUAGE_REPO_ALIASES.get(language, language)
    return Path(__file__).parent / "resources" / "repos" / repo_language / "test_repo"


def _create_ls(
    language: Language,
    repo_path: str | None = None,
    ignored_paths: list[str] | None = None,
    trace_lsp_communication: bool = False,
    ls_specific_settings: dict[Language, dict[str, Any]] | None = None,
    workspace_folders: list[str] | None = None,
    additional_workspace_folders: list[str] | None = None,
    solidlsp_dir: Path | None = None,
) -> SolidLanguageServer:
    ignored_paths = ignored_paths or []
    if repo_path is None:
        repo_path = str(get_repo_path(language))
    gitignore_parser = GitignoreParser(str(repo_path))
    for spec in gitignore_parser.get_ignore_specs():
        ignored_paths.extend(spec.patterns)
    config = LanguageServerConfig(
        code_language=language,
        ignored_paths=ignored_paths,
        trace_lsp_communication=trace_lsp_communication,
        workspace_folders=workspace_folders or ["."],
        additional_workspace_folders=additional_workspace_folders or [],
    )
    effective_solidlsp_dir = solidlsp_dir if solidlsp_dir is not None else SerenaPaths().serena_user_home_dir
    project_data_path = os.path.join(repo_path, SERENA_MANAGED_DIR_NAME)
    return SolidLanguageServer.create(
        config,
        repo_path,
        solidlsp_settings=SolidLSPSettings(
            solidlsp_dir=effective_solidlsp_dir,
            project_data_path=project_data_path,
            ls_specific_settings=ls_specific_settings or {},
        ),
    )


@contextmanager
def start_ls_context(
    language: Language,
    repo_path: str | None = None,
    ignored_paths: list[str] | None = None,
    trace_lsp_communication: bool = False,
    ls_specific_settings: dict[Language, dict[str, Any]] | None = None,
    workspace_folders: list[str] | None = None,
    additional_workspace_folders: list[str] | None = None,
    solidlsp_dir: Path | None = None,
) -> Iterator[SolidLanguageServer]:
    ls = _create_ls(
        language,
        repo_path,
        ignored_paths,
        trace_lsp_communication,
        ls_specific_settings,
        workspace_folders,
        additional_workspace_folders,
        solidlsp_dir,
    )
    log.info(f"Starting language server for {language} {repo_path}")
    with ls.start_server_context():
        yield ls


@contextmanager
def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer]:
    with start_ls_context(language) as ls:
        yield ls


def create_default_serena_config():
    return SerenaConfig(gui_log_window=False, web_dashboard=False)


def _create_default_project(language: Language, repo_root_override: str | None = None) -> Project:
    repo_path = str(get_repo_path(language)) if repo_root_override is None else repo_root_override
    return Project.load(repo_path, serena_config=create_default_serena_config())


@pytest.fixture(scope="session")
def repo_path(request: LanguageParamRequest) -> Path:
    """Get the repository path for a specific language.

    This fixture requires a language parameter via pytest.mark.parametrize:

    Example:
    ```
    @pytest.mark.parametrize("repo_path", [Language.PYTHON], indirect=True)
    def test_python_repo(repo_path):
        assert (repo_path / "src").exists()
    ```

    """
    if not hasattr(request, "param"):
        raise ValueError("Language parameter must be provided via pytest.mark.parametrize")

    language = request.param
    return get_repo_path(language)


# Note: using module scope here to avoid restarting LS for each test function but still terminate between test modules
@pytest.fixture(scope="module")
def language_server(request: LanguageParamRequest):
    """Create a language server instance configured for the specified language.

    This fixture requires a language parameter via pytest.mark.parametrize:

    Example:
    ```
    @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
    def test_python_server(language_server: SyncLanguageServer) -> None:
        # Use the Python language server
        pass
    ```

    You can also test multiple languages in a single test:
    ```
    @pytest.mark.parametrize("language_server", [Language.PYTHON, Language.TYPESCRIPT], indirect=True)
    def test_multiple_languages(language_server: SyncLanguageServer) -> None:
        # This test will run once for each language
        pass
    ```

    """
    if not hasattr(request, "param"):
        raise ValueError("Language parameter must be provided via pytest.mark.parametrize")

    language = request.param
    with start_default_ls_context(language) as ls:
        yield ls


@contextmanager
def project_context(language: Language, repo_root_override: str | None = None) -> Iterator[Project]:
    """Context manager that creates a Project for the specified language and ensures proper cleanup."""
    project = _create_default_project(language, repo_root_override)
    try:
        yield project
    finally:
        project.shutdown(timeout=5)


@pytest.fixture(scope="module")
def project(request: LanguageParamRequest, repo_root_override: str | None = None) -> Iterator[Project]:
    """Create a Project for the specified language.

    This fixture requires a language parameter via pytest.mark.parametrize:

    Example:
    ```
    @pytest.mark.parametrize("project", [Language.PYTHON], indirect=True)
    def test_python_project(project: Project) -> None:
        # Use the Python project to test something
        pass
    ```

    You can also test multiple languages in a single test:
    ```
    @pytest.mark.parametrize("project", [Language.PYTHON, Language.TYPESCRIPT], indirect=True)
    def test_multiple_languages(project: SyncLanguageServer) -> None:
        # This test will run once for each language
        pass
    ```

    """
    if not hasattr(request, "param"):
        raise ValueError("Language parameter must be provided via pytest.mark.parametrize")
    language = request.param
    with project_context(language, repo_root_override) as project:
        yield project


@contextmanager
def project_with_ls_context(language: Language, repo_root_override: str | None = None) -> Iterator[Project]:
    """Context manager that creates a Project with an active language server for the specified language."""
    with project_context(language, repo_root_override) as project:
        project.create_language_server_manager()
        yield project


@pytest.fixture(scope="module")
def project_with_ls(request: LanguageParamRequest) -> Iterator[Project]:
    if not hasattr(request, "param"):
        raise ValueError("Language parameter must be provided via pytest.mark.parametrize")
    language = request.param
    with project_with_ls_context(language) as project:
        yield project


is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true"
"""
Flag indicating whether the tests are running in the GitHub CI environment.
"""

is_windows = platform.system() == "Windows"
is_macos = platform.system() == "Darwin"
is_linux = platform.system() == "Linux"


_LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = {
    Language.ADA: [pytest.mark.ada],
    Language.CLOJURE: [pytest.mark.clojure],
    Language.CPP: [pytest.mark.cpp],
    Language.CPP_CCLS: [pytest.mark.cpp],
    Language.CUE: [pytest.mark.cue],
    Language.CSHARP: [pytest.mark.csharp],
    Language.FSHARP: [pytest.mark.fsharp],
    Language.GO: [pytest.mark.go],
    Language.HAXE: [pytest.mark.haxe],
    Language.JAVA: [pytest.mark.java],
    Language.KOTLIN: [pytest.mark.kotlin],
    Language.LEAN4: [pytest.mark.lean4],
    Language.LATEX: [pytest.mark.latex],
    Language.MSL: [pytest.mark.msl],
    Language.PHP: [pytest.mark.php],
    Language.PHP_PHPACTOR: [pytest.mark.php],
    Language.PHP_PHPANTOM: [pytest.mark.php],
    Language.POWERSHELL: [pytest.mark.powershell],
    Language.PYTHON: [pytest.mark.python],
    Language.PYTHON_JEDI: [pytest.mark.python],
    Language.PYTHON_TY: [pytest.mark.python],
    Language.PYTHON_PYREFLY: [pytest.mark.python],
    Language.RUST: [pytest.mark.rust],
    Language.TYPESCRIPT: [pytest.mark.typescript],
    Language.BSL: [pytest.mark.bsl],
    Language.SVELTE: [pytest.mark.svelte],
    Language.ANGULAR: [pytest.mark.angular],
    Language.HTML: [pytest.mark.html],
    Language.SCSS: [pytest.mark.scss],
}


def get_pytest_markers(language: Language) -> list[MarkDecorator | Mark]:
    """Pytest markers for a language.

    Returns the primary language marker plus the central enablement skip derived from
    ``language_tests_enabled()`` -- so per-language availability/reliability lives in exactly one
    place (``_determine_disabled_languages``) instead of being duplicated per marker or per test file.
    """
    return [
        *_LANGUAGE_PYTEST_MARKERS[language],
        pytest.mark.skipif(not language_tests_enabled(language), reason=f"{language.value} tests are disabled in this environment"),
    ]


def _is_perl_language_server_available() -> bool:
    """
    Whether Perl::LanguageServer is installed.

    Perl itself ships with most base systems, so checking for the ``perl`` binary is not enough;
    we verify that the ``Perl::LanguageServer`` module can be loaded -- which is exactly what the
    Perl language server launcher requires to start.
    """
    if _sh.which("perl") is None:
        return False
    try:
        return subprocess.run(["perl", "-MPerl::LanguageServer", "-e", "1"], capture_output=True, timeout=30, check=False).returncode == 0
    except (OSError, subprocess.SubprocessError):
        return False


def _is_matlab_available() -> bool:
    """Whether a MATLAB installation can be located (env var or a known install path)."""
    if os.environ.get("MATLAB_PATH") is not None:
        return True
    return any(
        os.path.exists(p)
        for p in (
            "/Applications/MATLAB_R2024b.app",
            "/Applications/MATLAB_R2025b.app",
            "/Volumes/S1/Applications/MATLAB_R2024b.app",
            "/Volumes/S1/Applications/MATLAB_R2025b.app",
        )
    )


def _is_r_language_server_available() -> bool:
    """Whether R *and* its ``languageserver`` package are installed.

    The R binary alone is not enough -- the language server runs as ``R -e "languageserver::run()"``,
    which fails (RuntimeError) if the package is missing -- so check the package, not just ``which("R")``.
    """
    if _sh.which("R") is None:
        return False
    try:
        return (
            subprocess.run(
                ["R", "--vanilla", "-e", 'quit(status = as.integer(!requireNamespace("languageserver", quietly = TRUE)))'],
                capture_output=True,
                timeout=60,
                check=False,
            ).returncode
            == 0
        )
    except (OSError, subprocess.SubprocessError):
        return False


def _is_ocaml_lsp_available() -> bool:
    """Whether opam *and* the ``ocaml-lsp-server`` (``ocamllsp``) are installed.

    opam alone is not enough -- the language server is launched via ``opam exec -- ocamllsp`` and
    raises if the package is missing -- so verify ocamllsp resolves in the active switch.
    """
    if _sh.which("opam") is None:
        return False
    try:
        return (
            subprocess.run(
                ["opam", "exec", "--", "ocamllsp", "--version"],
                capture_output=True,
                timeout=60,
                check=False,
            ).returncode
            == 0
        )
    except (OSError, subprocess.SubprocessError):
        return False


def _determine_disabled_languages() -> list[Language]:
    """
    Determine which language tests are disabled in the current environment.

    Every language falls into exactly ONE of the categories below; a language that is not appended
    here is **category 4 (enabled everywhere)**, e.g. python, typescript, go, java, kotlin-locally.

    1. ALWAYS DISABLED -- flaky/broken; not worth running anywhere.
    2. DISABLED OFF-CI when a precondition (toolchain/LS) is missing, but EXPECTED ON CI -- guarded
       with ``and not is_ci`` so a missing tool *on CI* fails loudly (catches a CI setup regression)
       instead of silently skipping.
    3. DISABLED WHEREVER the precondition is missing, INCLUDING on CI -- the precondition may or may
       not be provided on CI (e.g. via the maximal container, see Dockerfile.maximal); if it isn't,
       the tests just skip gracefully rather than fail.
    4. ENABLED EVERYWHERE -- not listed in this function at all.
    5. DISABLED ONLY ON CI (resource/stability reasons) even though the precondition holds locally.
    """
    result: list[Language] = []

    # === 1. Always disabled (flaky / broken everywhere) ===
    result.append(Language.BSL)  # 1C:Enterprise; niche and the tests are slow and flaky
    result.append(Language.FSHARP)  # F# language server is currently unreliable

    # === 2. Disabled off-CI if the precondition is missing; expected to be present on CI ===
    if _sh.which("terraform") is None and not is_ci:
        result.append(Language.TERRAFORM)
    if _sh.which("regal") is None and not is_ci:
        result.append(Language.REGO)
    if _sh.which("elm") is None and not is_ci:
        result.append(Language.ELM)
    # qmlls is installed (standalone build; see pytest.yml) only on the Ubuntu other-langs CI batch. It is
    # expected there, so a missing binary on Linux CI is NOT skipped here -- the test runs and fails loudly,
    # catching a CI setup regression. On Windows/macOS CI (never installed) and off-CI without the binary it skips.
    if (_sh.which("qmlls6") is None and _sh.which("qmlls") is None) and not (is_ci and is_linux):
        result.append(Language.QML)

    # === 3. Disabled wherever the precondition is missing (including on CI) ===
    # 3a. Platform precondition: these language servers have no native Windows support.
    if is_windows:
        result.append(Language.ANSIBLE)  # ansible-language-server has no native Windows support
    if not is_macos:
        result.append(Language.SWIFT)  # swiftly toolchain is only set up on the macOS native batch
    # 3b. Toolchain / language-server availability (the LS/compiler must be on PATH or installed).
    if _sh.which("clangd") is None:
        result.append(Language.CPP)
    if _sh.which("ccls") is None or is_windows:  # no recent ccls binary is available for Windows
        result.append(Language.CPP_CCLS)
    if _sh.which("php") is None:
        result.append(Language.PHP_PHPACTOR)
        result.append(Language.PHP_PHPANTOM)
    if not is_clojure_cli_available():
        result.append(Language.CLOJURE)
    if _sh.which("verible-verilog-ls") is None:
        result.append(Language.SYSTEMVERILOG)
    if not _is_matlab_available():
        result.append(Language.MATLAB)
    if ERLANG_LS_UNAVAILABLE:  # no Erlang-OTP / no rebar3 / Windows -- see test/solidlsp/erlang
        result.append(Language.ERLANG)
    if EXPERT_UNAVAILABLE:  # Elixir not installed -- see test/solidlsp/elixir
        result.append(Language.ELIXIR)
    if _sh.which("lean") is None:
        result.append(Language.LEAN4)
    if _sh.which("crystalline") is None:
        result.append(Language.CRYSTAL)
    if _sh.which("julia") is None:  # LanguageServer.jl is auto-installed by the LS when julia is present
        result.append(Language.JULIA)
    if _sh.which("nixd") is None:
        result.append(Language.NIX)
    if _sh.which("haskell-language-server-wrapper") is None:
        result.append(Language.HASKELL)
    if not _is_r_language_server_available():  # `which("R")` isn't enough -- needs the languageserver package
        result.append(Language.R)
    if not _is_ocaml_lsp_available():  # opam alone isn't enough -- needs the ocaml-lsp-server package
        result.append(Language.OCAML)
    if not _is_perl_language_server_available():  # perl ships with the OS; the LS module is the real signal
        result.append(Language.PERL)

    # === 4. Enabled everywhere: every language NOT listed in this function (python, go, java, ...) ===

    # === 5. Disabled only on CI (works locally; too unstable/costly on the CI runners) ===
    if is_ci:
        result.append(Language.KOTLIN)  # IntelliJ-based Kotlin LSP crashes on JVM restart under CI memory limits

    return result


_disabled_languages = _determine_disabled_languages()


def language_tests_enabled(language: Language) -> bool:
    """
    Check if tests for the given language are enabled in the current environment.

    :param language: the language to check
    :return: True if tests for the language are enabled, False otherwise
    """
    return language not in _disabled_languages


def language_supports_implementation(language: Language) -> bool:
    return language.supports_implementation_request()


def languages_supporting_implementation(*languages: Language) -> list[Language]:
    return [language for language in languages if language_supports_implementation(language)]


_VERIFIED_IMPLEMENTATION_LANGUAGES = {
    Language.ANGULAR,
    Language.CSHARP,
    Language.GO,
    Language.JAVA,
    Language.RUST,
    Language.TYPESCRIPT,
}


def language_has_verified_implementation_support(language: Language) -> bool:
    """
    True only for languages where the server advertises implementation support and
    the repo fixtures contain a verified working go-to-implementation scenario.
    """
    return language in _VERIFIED_IMPLEMENTATION_LANGUAGES and language_supports_implementation(language)


def find_identifier_position(file_path: Path, identifier: str) -> tuple[int, int] | None:
    pattern = re.compile(r"\b" + re.escape(identifier) + r"\b")
    with file_path.open(encoding="utf-8") as f:
        for line_idx, line in enumerate(f):
            match = pattern.search(line)
            if match:
                return line_idx, match.start()
    return None


def find_identifier_pos(
    file_path: Path,
    identifier: str,
    occurrence_index: int = 0,
    column_offset: int = 0,
) -> tuple[int, int] | None:
    if occurrence_index < 0:
        raise ValueError("occurrence_index must be non-negative")
    if column_offset < 0:
        raise ValueError("column_offset must be non-negative")

    pattern = re.compile(r"\b" + re.escape(identifier) + r"\b")
    current_index = 0
    with file_path.open(encoding="utf-8") as f:
        for line_idx, line in enumerate(f):
            for match in pattern.finditer(line):
                if current_index == occurrence_index:
                    return line_idx, match.start() + column_offset
                current_index += 1
    return None
