# Copyright 2017-2020 Palantir Technologies, Inc.
# Copyright 2021- Python Language Server Contributors.

import functools
import inspect
import logging
import os
import pathlib
import re
import subprocess
import sys
import threading
import time
from typing import Optional

import docstring_to_markdown
import jedi

from pylsp import IS_WIN

JEDI_VERSION = jedi.__version__

# Eol chars accepted by the LSP protocol
# the ordering affects performance
EOL_CHARS = ["\r\n", "\r", "\n"]
EOL_REGEX = re.compile(f"({'|'.join(EOL_CHARS)})")

log = logging.getLogger(__name__)


def debounce(interval_s, keyed_by=None):
    """Debounce calls to this function until interval_s seconds have passed."""

    def wrapper(func):
        timers = {}
        lock = threading.Lock()

        @functools.wraps(func)
        def debounced(*args, **kwargs):
            sig = inspect.signature(func)
            call_args = sig.bind(*args, **kwargs)
            key = call_args.arguments[keyed_by] if keyed_by else None

            def run():
                with lock:
                    del timers[key]
                return func(*args, **kwargs)

            with lock:
                old_timer = timers.get(key)
                if old_timer:
                    old_timer.cancel()

                timer = threading.Timer(interval_s, run)
                timers[key] = timer
                timer.start()

        return debounced

    return wrapper


def throttle(seconds=1):
    """Throttles calls to a function every `seconds` seconds."""

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            if not hasattr(wrapper, "last_call"):
                wrapper.last_call = 0
            if time.time() - wrapper.last_call >= seconds:
                wrapper.last_call = time.time()
                return func(*args, **kwargs)

        return wrapper

    return decorator


def find_parents(root, path, names):
    """Find files matching the given names relative to the given path.

    Args:
        path (str): The file path to start searching up from.
        names (list[str]): The file/directory names to look for.
        root (str): The directory at which to stop recursing upwards.

    Note:
        The path MUST be within the root.
    """
    if not root:
        return []

    if not os.path.commonprefix((root, path)):
        log.warning("Path %r not in %r", path, root)
        return []

    # Split the relative by directory, generate all the parent directories, then check each of them.
    # This avoids running a loop that has different base-cases for unix/windows
    # e.g. /a/b and /a/b/c/d/e.py -> ['/a/b', 'c', 'd']
    try:
        dirs = [root] + os.path.relpath(os.path.dirname(path), root).split(os.path.sep)
    except ValueError:
        # On Windows, relpath raises ValueError when path and root are on different mounts
        # (e.g. a UNC share root vs a drive-letter path). Nothing to find in this case.
        log.warning("Path %r not in %r", path, root)
        return []

    # Search each of /a/b/c, /a/b, /a
    while dirs:
        search_dir = os.path.join(*dirs)
        existing = list(
            filter(os.path.exists, [os.path.join(search_dir, n) for n in names])
        )
        if existing:
            return existing
        dirs.pop()

    # Otherwise nothing
    return []


def path_to_dot_name(path):
    """Given a path to a module, derive its dot-separated full name."""
    directory = os.path.dirname(path)
    module_name, _ = os.path.splitext(os.path.basename(path))
    full_name = [module_name]
    while os.path.exists(os.path.join(directory, "__init__.py")):
        this_directory = os.path.basename(directory)
        directory = os.path.dirname(directory)
        full_name = [this_directory] + full_name
    return ".".join(full_name)


def match_uri_to_workspace(uri, workspaces):
    if uri is None:
        return None
    max_len, chosen_workspace = -1, None
    path = pathlib.Path(uri).parts
    for workspace in workspaces:
        workspace_parts = pathlib.Path(workspace).parts
        if len(workspace_parts) > len(path):
            continue

        match_len = 0
        is_parent = True
        for workspace_part, path_part in zip(workspace_parts, path):
            # filename match is case insensitive on windows
            # also, uris._normalize_win_path() lowercases the drive letter
            if IS_WIN:
                workspace_part = workspace_part.lower()
                path_part = path_part.lower()

            if workspace_part == path_part:
                match_len += 1
            else:
                # give up, any subsequent match is no longer relevant
                is_parent = False
                break

        # prefer a match that is actually a parent of uri
        # otherwise fall back to longest matching non-parent
        if is_parent and match_len > 0:
            match_len += 1000

        if match_len > 0:
            if match_len > max_len:
                max_len = match_len
                chosen_workspace = workspace

    return chosen_workspace


def list_to_string(value):
    return ",".join(value) if isinstance(value, list) else value


def merge_dicts(dict_a, dict_b):
    """Recursively merge dictionary b into dictionary a.

    If override_nones is True, then
    """

    def _merge_dicts_(a, b):
        for key in set(a.keys()).union(b.keys()):
            if key in a and key in b:
                if isinstance(a[key], dict) and isinstance(b[key], dict):
                    yield (key, dict(_merge_dicts_(a[key], b[key])))
                elif isinstance(a[key], list) and isinstance(b[key], list):
                    yield (key, list(set(a[key] + b[key])))
                elif b[key] is not None:
                    yield (key, b[key])
                else:
                    yield (key, a[key])
            elif key in a:
                yield (key, a[key])
            elif b[key] is not None:
                yield (key, b[key])

    return dict(_merge_dicts_(dict_a, dict_b))


def escape_plain_text(contents: str) -> str:
    """
    Format plain text to display nicely in environments which do not respect whitespaces.
    """
    contents = contents.replace("\t", "\u00a0" * 4)
    contents = contents.replace("  ", "\u00a0" * 2)
    return contents


def escape_markdown(contents: str) -> str:
    """
    Format plain text to display nicely in Markdown environment.
    """
    # escape markdown syntax
    contents = re.sub(r"([\\*_#[\]])", r"\\\1", contents)
    # preserve white space characters
    contents = escape_plain_text(contents)
    return contents


def wrap_signature(signature):
    return "```python\n" + signature + "\n```\n"


SERVER_SUPPORTED_MARKUP_KINDS = {"markdown", "plaintext"}


def choose_markup_kind(client_supported_markup_kinds: list[str]):
    """Choose a markup kind supported by both client and the server.

    This gives priority to the markup kinds provided earlier on the client preference list.
    """
    for kind in client_supported_markup_kinds:
        if kind in SERVER_SUPPORTED_MARKUP_KINDS:
            return kind
    return "markdown"


class Formatter:
    command: list[str]

    @property
    def is_installed(self) -> bool:
        """Returns whether formatter is available"""
        if not hasattr(self, "_is_installed"):
            self._is_installed = self._is_available_via_cli()
        return self._is_installed

    def format(self, code: str, line_length: int) -> str:
        """Formats code"""
        return subprocess.check_output(
            [
                sys.executable,
                "-m",
                *self.command,
                "--line-length",
                str(line_length),
                "-",
            ],
            input=code,
            text=True,
        ).strip()

    def _is_available_via_cli(self) -> bool:
        try:
            subprocess.check_output(
                [
                    sys.executable,
                    "-m",
                    *self.command,
                    "--help",
                ],
            )
            return True
        except subprocess.CalledProcessError:
            return False


class RuffFormatter(Formatter):
    command = ["ruff", "format"]


class BlackFormatter(Formatter):
    command = ["black"]


formatters = {"ruff": RuffFormatter(), "black": BlackFormatter()}


def format_signature(signature: str, config: dict, signature_formatter: str) -> str:
    """Formats signature using ruff or black if either is available."""
    as_func = f"def {signature.strip()}:\n    pass"
    line_length = config.get("line_length", 88)
    formatter = formatters[signature_formatter]
    if formatter.is_installed:
        try:
            return (
                formatter.format(as_func, line_length=line_length)
                .removeprefix("def ")
                .removesuffix(":\n    pass")
            )
        except subprocess.CalledProcessError as e:
            log.warning("Signature formatter failed %s", e)
    else:
        log.warning(
            "Formatter %s was requested but it does not appear to be installed",
            signature_formatter,
        )
    return signature


def convert_signatures_to_markdown(signatures: list[str], config: dict) -> str:
    signature_formatter = config.get("formatter", "black")
    if signature_formatter:
        signatures = [
            format_signature(
                signature, signature_formatter=signature_formatter, config=config
            )
            for signature in signatures
        ]
    return wrap_signature("\n".join(signatures))


def format_docstring(
    contents: str,
    markup_kind: str,
    signatures: Optional[list[str]] = None,
    signature_config: Optional[dict] = None,
):
    """Transform the provided docstring into a MarkupContent object.

    If `markup_kind` is 'markdown' the docstring will get converted to
    markdown representation using `docstring-to-markdown`; if it is
    `plaintext`, it will be returned as plain text.
    Call signatures of functions (or equivalent code summaries)
    provided in optional `signatures` argument will be prepended
    to the provided contents of the docstring if given.
    """
    if not isinstance(contents, str):
        contents = ""

    if markup_kind == "markdown":
        wrapped_signatures = convert_signatures_to_markdown(
            signatures if signatures is not None else [], config=signature_config or {}
        )

        if contents != "":
            try:
                value = docstring_to_markdown.convert(contents)
            except docstring_to_markdown.UnknownFormatError:
                # try to escape the Markdown syntax instead:
                value = escape_markdown(contents)

            if signatures:
                value = wrapped_signatures + "\n\n" + value
        else:
            value = contents

            if signatures:
                value = wrapped_signatures

        return {"kind": "markdown", "value": value}
    value = contents
    if signatures:
        value = "\n".join(signatures) + "\n\n" + value
    return {"kind": "plaintext", "value": escape_plain_text(value)}


def clip_column(column, lines, line_number):
    """
    Normalise the position as per the LSP that accepts character positions > line length

    https://microsoft.github.io/language-server-protocol/specification#position
    """
    max_column = (
        len(lines[line_number].rstrip("\r\n")) if len(lines) > line_number else 0
    )
    return min(column, max_column)


def position_to_jedi_linecolumn(document, position):
    """
    Convert the LSP format 'line', 'character' to Jedi's 'line', 'column'

    https://microsoft.github.io/language-server-protocol/specification#position
    """
    code_position = {}
    if position:
        code_position = {
            "line": position["line"] + 1,
            "column": clip_column(
                position["character"], document.lines, position["line"]
            ),
        }
    return code_position


if os.name == "nt":
    import ctypes

    kernel32 = ctypes.windll.kernel32
    PROCESS_QUERY_INFROMATION = 0x1000

    def is_process_alive(pid):
        """Check whether the process with the given pid is still alive.

        Running `os.kill()` on Windows always exits the process, so it can't be used to check for an alive process.
        see: https://docs.python.org/3/library/os.html?highlight=os%20kill#os.kill

        Hence ctypes is used to check for the process directly via windows API avoiding any other 3rd-party dependency.

        Args:
            pid (int): process ID

        Returns:
            bool: False if the process is not alive or don't have permission to check, True otherwise.
        """
        process = kernel32.OpenProcess(PROCESS_QUERY_INFROMATION, 0, pid)
        if process != 0:
            kernel32.CloseHandle(process)
            return True
        return False

else:
    import errno

    def is_process_alive(pid):
        """Check whether the process with the given pid is still alive.

        Args:
            pid (int): process ID

        Returns:
            bool: False if the process is not alive or don't have permission to check, True otherwise.
        """
        if pid < 0:
            return False
        try:
            os.kill(pid, 0)
        except OSError as e:
            return e.errno == errno.EPERM
        return True


def get_eol_chars(text):
    """Get EOL chars used in text."""
    match = EOL_REGEX.search(text)
    if match:
        return match.group(0)
    return None
