# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.


import itertools
import logging
import sys
from pathlib import Path
from typing import Callable, List, NamedTuple, Optional


CONFIGURATION_FILE: str = ".pyre_configuration"
LOCAL_CONFIGURATION_FILE: str = ".pyre_configuration.local"
BINARY_NAME: str = "pyre.bin"
CLIENT_NAME: str = "pyre-client"
LOG_DIRECTORY: str = ".pyre"


LOG: logging.Logger = logging.getLogger(__name__)


def _find_parent_directory_containing(
    base: Path,
    target: str,
    predicate: Callable[[Path], bool],
    stop_search_after: Optional[int],
) -> Optional[Path]:
    resolved_base = base.resolve(strict=True)
    # Using `itertools.chain` to avoid expanding `resolve_base.parents` eagerly
    for i, candidate_directory in enumerate(
        itertools.chain([resolved_base], resolved_base.parents)
    ):
        candidate_path = candidate_directory / target
        try:
            if predicate(candidate_path):
                return candidate_directory
        except PermissionError:
            # We might not have sufficient permission to read the file/directory.
            # In that case, pretend the file doesn't exist.
            pass
        if stop_search_after is not None:
            if i >= stop_search_after:
                return None
    return None


def find_parent_directory_containing_file(
    base: Path,
    target: str,
    stop_search_after: Optional[int] = None,
) -> Optional[Path]:
    """
    Walk directories upwards from `base`, until the root directory is
    reached. At each step, check if the `target` file exist, and return
    the closest such directory if found. Return None if the search is
    unsuccessful.

    We stop searching after checking `stop_search_after` parent
    directories of `base` if provided; this is mainly for testing.
    """

    def is_file(path: Path) -> bool:
        return path.is_file()

    return _find_parent_directory_containing(
        base,
        target,
        predicate=is_file,
        stop_search_after=stop_search_after,
    )


def find_outermost_directory_containing_file(
    base: Path,
    target: str,
    stop_search_after: Optional[int],
) -> Optional[Path]:
    """
    Walk directories upwards from `base`, until the root directory is
    reached. At each step, check if the `target` file exist, and return
    the farthest such directory if found. Return None if the search is
    unsuccessful.

    We stop searching after checking `stop_search_after` parent
    directories of `base` if provided; this is mainly for testing.
    """
    result: Optional[Path] = None
    resolved_base = base.resolve(strict=True)
    # Using `itertools.chain` to avoid expanding `resolve_base.parents` eagerly
    for i, candidate_directory in enumerate(
        itertools.chain([resolved_base], resolved_base.parents)
    ):
        candidate_path = candidate_directory / target
        try:
            if candidate_path.is_file():
                result = candidate_directory
        except PermissionError:
            # We might not have sufficient permission to read the file/directory.
            # In that case, pretend the file doesn't exist.
            pass
        if stop_search_after is not None:
            if i >= stop_search_after:
                break
    return result


def find_global_root(base: Path) -> Optional[Path]:
    """Pyre always runs from the directory containing the nearest .pyre_configuration,
    if one exists."""
    return find_parent_directory_containing_file(base, CONFIGURATION_FILE)


def get_relative_local_root(
    global_root: Path, local_root: Optional[Path]
) -> Optional[str]:
    if local_root is None:
        return None
    else:
        try:
            return str(local_root.relative_to(global_root))
        except ValueError:
            # This happens when `local_root` is not prefixed by `global_root`
            return None


class FoundRoot(NamedTuple):
    global_root: Path
    local_root: Optional[Path] = None


def find_global_and_local_root(base: Path) -> Optional[FoundRoot]:
    """
    Walk directories upwards from `base` and try to find both the global and local
    pyre configurations.
    Return `None` if no global configuration is found.
    If a global configuration exists but no local configuration is found below it,
    return the path to the global configuration.
    If both global and local exist, return them as a pair.
    """
    found_global_root = find_parent_directory_containing_file(base, CONFIGURATION_FILE)
    if found_global_root is None:
        return None

    found_local_root = find_parent_directory_containing_file(
        base, LOCAL_CONFIGURATION_FILE
    )
    if found_local_root is None:
        return FoundRoot(found_global_root)

    # If the global configuration root is deeper than local configuration, ignore local.
    if found_local_root in found_global_root.parents:
        return FoundRoot(found_global_root)
    else:
        return FoundRoot(found_global_root, found_local_root)


def find_parent_directory_containing_directory(
    base: Path,
    target: str,
    stop_search_after: Optional[int] = None,
) -> Optional[Path]:
    """
    Walk directories upwards from base, until the root directory is
    reached. At each step, check if the target directory exist, and return
    it if found. Return None if the search is unsuccessful.

    We stop searching after checking `stop_search_after` parent
    directories of `base` if provided; this is mainly for testing.
    """

    def is_directory(path: Path) -> bool:
        return path.is_dir()

    return _find_parent_directory_containing(
        base,
        target,
        predicate=is_directory,
        stop_search_after=stop_search_after,
    )


def find_typeshed() -> Optional[Path]:
    # Prefer the typeshed we bundled ourselves (if any) to the one
    # from the environment.
    install_root = Path(sys.prefix)
    bundled_typeshed = install_root / "lib/pyre_check/typeshed/"
    if bundled_typeshed.is_dir():
        return bundled_typeshed

    LOG.debug("Could not find bundled typeshed. Try importing typeshed directly...")
    try:
        import typeshed  # pyre-fixme: Can't find module import typeshed

        return Path(typeshed.typeshed)
    except ImportError:
        LOG.debug("`import typeshed` failed.")

    return None


def find_typeshed_search_paths(typeshed_root: Path) -> List[Path]:
    """
    Given the root of typeshed, find all subdirectories in it that can be used
    as search paths for Pyre.
    """
    search_path = []
    third_party_root = typeshed_root / "stubs"
    third_party_subdirectories = (
        sorted(third_party_root.iterdir()) if third_party_root.is_dir() else []
    )
    for typeshed_subdirectory in itertools.chain(
        [typeshed_root / "stdlib"], third_party_subdirectories
    ):
        if typeshed_subdirectory.is_dir():
            search_path.append(typeshed_subdirectory)
    return search_path


def find_taint_models_directory() -> Optional[Path]:
    install_root = Path(sys.prefix)
    bundled_taint_models = install_root / "lib/pyre_check/taint/"
    if bundled_taint_models.is_dir():
        return bundled_taint_models
    return None
