import filecmp
import logging
import os
import shlex
import shutil
import sys
import tempfile
import time
from pathlib import Path
from shutil import which
from tempfile import TemporaryDirectory
from typing import Final

import userpath  # type: ignore[import-not-found]
from packaging.utils import canonicalize_name

from pipx import paths
from pipx.colors import bold, red
from pipx.constants import MAN_SECTIONS, WINDOWS
from pipx.emojis import hazard, stars
from pipx.package_specifier import parse_specifier_for_install, valid_pypi_name
from pipx.pipx_metadata_file import PackageInfo
from pipx.util import PipxError, mkdir, pipx_wrap, rmdir, safe_unlink
from pipx.venv import Venv

_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)


class VenvProblems:
    def __init__(
        self,
        bad_venv_name: bool = False,
        invalid_interpreter: bool = False,
        missing_metadata: bool = False,
        not_installed: bool = False,
    ) -> None:
        self.bad_venv_name = bad_venv_name
        self.invalid_interpreter = invalid_interpreter
        self.missing_metadata = missing_metadata
        self.not_installed = not_installed

    def any_(self) -> bool:
        return any(self.__dict__.values())

    def or_(self, venv_problems: "VenvProblems") -> None:
        for attribute in self.__dict__:
            setattr(
                self,
                attribute,
                getattr(self, attribute) or getattr(venv_problems, attribute),
            )


def expose_resources_globally(
    resource_type: str,
    local_resource_dir: Path,
    paths: list[Path],
    *,
    force: bool,
    suffix: str = "",
) -> None:
    for path in paths:
        if resource_type == "app":
            _add_ignore_environment_to_python_shebang(path)
        src = path.resolve()
        if resource_type == "man":
            dest_dir = local_resource_dir / src.parent.name
        else:
            dest_dir = local_resource_dir
        if not dest_dir.is_dir():
            mkdir(dest_dir)
        if not can_symlink(dest_dir):
            _copy_package_resource(dest_dir, path, suffix=suffix)
        else:
            _symlink_package_resource(
                dest_dir,
                path,
                force=force,
                suffix=suffix,
                executable=(resource_type == "app"),
            )


_can_symlink_cache: dict[Path, bool] = {}


def can_symlink(local_resource_dir: Path) -> bool:
    if not WINDOWS:
        # Technically, even on Unix this depends on the filesystem
        return True

    if local_resource_dir not in _can_symlink_cache:
        with TemporaryDirectory(dir=local_resource_dir) as d:
            p = Path(d)
            target = p / "a"
            target.touch()
            lnk = p / "b"
            try:
                lnk.symlink_to(target)
                _can_symlink_cache[local_resource_dir] = True
            except (OSError, NotImplementedError):
                _can_symlink_cache[local_resource_dir] = False

    return _can_symlink_cache[local_resource_dir]


def _add_ignore_environment_to_python_shebang(path: Path) -> None:
    if WINDOWS or not path.is_file():
        return

    try:
        data = path.read_bytes()
    except OSError:
        return

    first_line, separator, rest = data.partition(b"\n")
    if not first_line.startswith(b"#!"):
        return

    interpreter = first_line[2:]
    if interpreter.endswith(b" -E"):
        return
    if b"python" not in interpreter.lower() or b" " in interpreter or b"\t" in interpreter:
        return

    path.write_bytes(first_line + b" -E" + separator + rest)


def _copy_package_resource(dest_dir: Path, path: Path, suffix: str = "") -> None:
    src = path.resolve()
    name = src.name
    dest = Path(dest_dir / add_suffix(name, suffix))
    if not dest.parent.is_dir():
        mkdir(dest.parent)
    if dest.exists():
        if filecmp.cmp(dest, src, shallow=False):
            return
        _LOGGER.warning(f"{hazard}  Overwriting file {dest!s} with {src!s}")
        safe_unlink(dest)
    if src.exists():
        shutil.copy(src, dest)


def _symlink_package_resource(
    dest_dir: Path,
    path: Path,
    *,
    force: bool,
    suffix: str = "",
    executable: bool = False,
) -> None:
    name_suffixed = add_suffix(path.name, suffix)
    symlink_path = Path(dest_dir / name_suffixed)

    if not symlink_path.parent.is_dir():
        mkdir(symlink_path.parent)

    if force:
        _LOGGER.info(f"Force is true. Removing {symlink_path!s}.")
        try:
            symlink_path.unlink()
        except (FileNotFoundError, RuntimeError):
            pass
        except IsADirectoryError:
            rmdir(symlink_path)

    exists = symlink_path.exists()
    is_symlink = symlink_path.is_symlink()
    if exists:
        if symlink_path.samefile(path):
            _LOGGER.info(f"Same path {symlink_path!s} and {path!s}")
        else:
            _LOGGER.warning(
                pipx_wrap(
                    f"""
                    {hazard}  File exists at {symlink_path!s} and points
                    to {symlink_path.resolve()}, not {path!s}. Not
                    modifying.
                    """,
                    subsequent_indent=" " * 4,
                )
            )
        return
    if is_symlink and not exists:
        _LOGGER.info(f"Removing existing symlink {symlink_path!s} since it pointed non-existent location")
        symlink_path.unlink()

    if executable:
        existing_executable_on_path = which(name_suffixed)
    else:
        existing_executable_on_path = None
    symlink_path.symlink_to(path)

    if executable and existing_executable_on_path:
        _LOGGER.warning(
            pipx_wrap(
                f"""
                {hazard}  Note: {name_suffixed} was already on your
                PATH at {existing_executable_on_path}
                """,
                subsequent_indent=" " * 4,
            )
        )


def venv_health_check(venv: Venv, package_name: str | None = None) -> tuple[VenvProblems, str]:
    venv_dir = venv.root
    python_path = venv.python_path.resolve()

    if package_name is None:
        package_name = venv.main_package_name

    if not python_path.is_file():
        return (
            VenvProblems(invalid_interpreter=True),
            f"   package {red(bold(venv_dir.name))} has invalid interpreter {python_path!s}\r{hazard}",
        )
    if not venv.package_metadata:
        return (
            VenvProblems(missing_metadata=True),
            f"   package {red(bold(venv_dir.name))} has missing internal pipx metadata.\r{hazard}",
        )
    if venv_dir.name != canonicalize_name(venv_dir.name):
        return (
            VenvProblems(bad_venv_name=True),
            f"   package {red(bold(venv_dir.name))} needs its internal data updated.\r{hazard}",
        )
    if venv.package_metadata[package_name].package_version == "":
        return (
            VenvProblems(not_installed=True),
            f"   package {red(bold(package_name))} {red('is not installed')} in the venv {venv_dir.name}\r{hazard}",
        )
    return (VenvProblems(), "")


def get_venv_summary(
    venv_dir: Path,
    *,
    package_name: str | None = None,
    new_install: bool = False,
    include_injected: bool = False,
) -> tuple[str, VenvProblems]:
    venv = Venv(venv_dir)

    if package_name is None:
        package_name = venv.main_package_name

    (venv_problems, warning_message) = venv_health_check(venv, package_name)
    if venv_problems.any_():
        return (warning_message, venv_problems)

    package_metadata = venv.package_metadata[package_name]
    apps = package_metadata.apps
    man_pages = package_metadata.man_pages
    if package_metadata.include_dependencies:
        apps += package_metadata.apps_of_dependencies
        man_pages += package_metadata.man_pages_of_dependencies

    exposed_app_paths = get_exposed_paths_for_package(
        venv.bin_path,
        paths.ctx.bin_dir,
        [add_suffix(app, package_metadata.suffix) for app in apps],
    )
    exposed_binary_names = sorted(p.name for p in exposed_app_paths)
    unavailable_binary_names = sorted(
        {add_suffix(name, package_metadata.suffix) for name in package_metadata.apps} - set(exposed_binary_names)
    )
    exposed_man_paths = set()
    for man_section in MAN_SECTIONS:
        exposed_man_paths |= get_exposed_man_paths_for_package(
            venv.man_path / man_section,
            paths.ctx.man_dir / man_section,
            man_pages,
        )
    exposed_man_pages = sorted(str(Path(p.parent.name) / p.name) for p in exposed_man_paths)
    unavailable_man_pages = sorted(set(package_metadata.man_pages) - set(exposed_man_pages))
    # The following is to satisfy mypy that python_version is str and not
    #   Optional[str]
    python_version = venv.pipx_metadata.python_version if venv.pipx_metadata.python_version is not None else ""
    source_interpreter = venv.pipx_metadata.source_interpreter
    is_standalone = (
        str(source_interpreter).startswith(str(paths.ctx.standalone_python_cachedir.resolve()))
        if source_interpreter
        else False
    )
    return (
        _get_list_output(
            python_version,
            is_standalone,
            package_metadata.package_version,
            package_name,
            new_install,
            exposed_binary_names,
            unavailable_binary_names,
            exposed_man_pages,
            unavailable_man_pages,
            venv.pipx_metadata.injected_packages if include_injected else None,
            suffix=package_metadata.suffix,
        ),
        venv_problems,
    )


def get_exposed_paths_for_package(
    venv_resource_path: Path,
    local_resource_dir: Path,
    package_resource_names: list[str] | None = None,
) -> set[Path]:
    # package_binary_names is used only if local_bin_path cannot use symlinks.
    # It is necessary for non-symlink systems to return valid app_paths.
    if package_resource_names is None:
        package_resource_names = []

    if not local_resource_dir.exists():
        return set()

    symlinks = set()
    for b in local_resource_dir.iterdir():
        try:
            # sometimes symlinks can resolve to a file of a different name
            # (in the case of ansible for example) so checking the resolved paths
            # is not a reliable way to determine if the symlink exists.
            # We always use the stricter check on non-Windows systems. On
            # Windows, we use a less strict check if we don't have a symlink.
            is_same_file = False
            if can_symlink(local_resource_dir) and b.is_symlink():
                is_same_file = b.resolve().parent.samefile(venv_resource_path)
            elif not can_symlink(local_resource_dir):
                is_same_file = b.name in package_resource_names

            if is_same_file:
                symlinks.add(b)

        except (FileNotFoundError, RuntimeError):
            pass
    return symlinks


def get_exposed_man_paths_for_package(
    venv_man_path: Path,
    local_man_dir: Path,
    package_man_pages: list[str] | None = None,
) -> set[Path]:
    man_section = venv_man_path.name
    prefix = man_section + os.sep
    return get_exposed_paths_for_package(
        venv_man_path,
        local_man_dir,
        [name.removeprefix(prefix) for name in package_man_pages or [] if name.startswith(prefix)],
    )


def _get_list_output(
    python_version: str,
    python_is_standalone: bool,
    package_version: str,
    package_name: str,
    new_install: bool,
    exposed_binary_names: list[str],
    unavailable_binary_names: list[str],
    exposed_man_pages: list[str],
    unavailable_man_pages: list[str],
    injected_packages: dict[str, PackageInfo] | None = None,
    suffix: str = "",
) -> str:
    output = []
    suffix = f" ({bold(shlex.quote(package_name + suffix))})" if suffix else ""
    output.append(
        f"  {'installed' if new_install else ''} package {bold(shlex.quote(package_name))}"
        f" {bold(package_version)}{suffix}, installed using {python_version}"
        + (" (standalone)" if python_is_standalone else "")
    )

    if new_install and (exposed_binary_names or unavailable_binary_names):
        output.append("  These apps are now available")
    output.extend(f"    - {name}" for name in exposed_binary_names)
    output.extend(
        f"    - {red(name)} (symlink missing or pointing to unexpected location)" for name in unavailable_binary_names
    )
    if new_install and (exposed_man_pages or unavailable_man_pages):
        output.append("  These manual pages are now available")
    output.extend(f"    - {name}" for name in exposed_man_pages)
    output.extend(
        f"    - {red(name)} (symlink missing or pointing to unexpected location)" for name in unavailable_man_pages
    )
    if injected_packages:
        output.append("    Injected Packages:")
        output.extend(f"      - {name} {injected_packages[name].package_version}" for name in injected_packages)
    return "\n".join(output)


def package_name_from_spec(
    package_spec: str,
    python: str,
    *,
    pip_args: list[str],
    verbose: bool,
    backend: str | None = None,
    env_backend: str | None = None,
) -> str:
    start_time = time.time()

    # shortcut if valid PyPI name
    pypi_name = valid_pypi_name(package_spec)
    if pypi_name is not None:
        # NOTE: if pypi name and installed package name differ, this means pipx
        #       will use the pypi name
        package_name = pypi_name
        _LOGGER.info(f"Determined package name: {package_name}")
        _LOGGER.info(f"Package name determined in {time.time() - start_time:.1f}s")
        return package_name

    # check syntax and clean up spec and pip_args
    (package_spec, pip_args) = parse_specifier_for_install(package_spec, pip_args)

    with tempfile.TemporaryDirectory() as temp_venv_dir:
        venv = Venv(Path(temp_venv_dir), python=python, verbose=verbose, backend=backend, env_backend=env_backend)
        venv.create_venv(venv_args=[], pip_args=[])
        package_name = venv.install_package_no_deps(package_or_url=package_spec, pip_args=pip_args)

    _LOGGER.info(f"Package name determined in {time.time() - start_time:.1f}s")
    return package_name


def run_post_install_actions(
    venv: Venv,
    package_name: str,
    local_bin_dir: Path,
    local_man_dir: Path,
    venv_dir: Path,
    include_dependencies: bool,
    *,
    force: bool,
) -> None:
    package_metadata = venv.package_metadata[package_name]

    display_name = f"{package_name}{package_metadata.suffix}"

    if (
        not venv.main_package_name == package_name
        and venv.package_metadata[venv.main_package_name].suffix == package_metadata.suffix
    ):
        package_name = display_name

    if not package_metadata.apps:
        if not package_metadata.apps_of_dependencies:
            if venv.safe_to_remove():
                venv.remove_venv()
            raise PipxError(
                f"""
                No apps associated with package {display_name} or its
                dependencies. If you are attempting to install a library, pipx
                should not be used. Consider using pip or a similar tool instead.
                """
            )
        if package_metadata.apps_of_dependencies and not include_dependencies:
            for (
                dep,
                dependent_apps,
            ) in package_metadata.app_paths_of_dependencies.items():
                print(f"Note: Dependent package '{dep}' contains {len(dependent_apps)} apps")
                for app in dependent_apps:
                    print(f"  - {app.name}")
            if venv.safe_to_remove():
                venv.remove_venv()
            raise PipxError(
                f"""
                No apps associated with package {display_name}. Try again
                with '--include-deps' to include apps of dependent packages,
                which are listed above. If you are attempting to install a
                library, pipx should not be used. Consider using pip or a
                similar tool instead.
                """
            )

    expose_resources_globally(
        "app",
        local_bin_dir,
        package_metadata.app_paths,
        force=force,
        suffix=package_metadata.suffix,
    )
    expose_resources_globally("man", local_man_dir, package_metadata.man_paths, force=force)

    if include_dependencies:
        for app_paths in package_metadata.app_paths_of_dependencies.values():
            expose_resources_globally(
                "app",
                local_bin_dir,
                app_paths,
                force=force,
                suffix=package_metadata.suffix,
            )
        for man_paths in package_metadata.man_paths_of_dependencies.values():
            expose_resources_globally("man", local_man_dir, man_paths, force=force)

    package_summary, _ = get_venv_summary(venv_dir, package_name=package_name, new_install=True)
    pipx_logger = logging.getLogger("pipx")
    if not pipx_logger.handlers or pipx_logger.handlers[0].level <= logging.WARNING:
        print(package_summary)
        warn_if_not_on_path(local_bin_dir)
        print(f"done! {stars}", file=sys.stderr)


def warn_if_not_on_path(local_bin_dir: Path) -> None:
    if not userpath.in_current_path(str(local_bin_dir)):
        _LOGGER.warning(
            pipx_wrap(
                f"""
                {hazard}  Note: '{local_bin_dir}' is not on your PATH
                environment variable. These apps will not be globally
                accessible until your PATH is updated. Run `pipx ensurepath` to
                automatically add it, or manually modify your PATH in your
                shell's config file (e.g. ~/.bashrc).
                """,
                subsequent_indent=" " * 4,
            )
        )


def add_suffix(name: str, suffix: str) -> str:
    """Add suffix to app."""

    app = Path(name)
    return f"{app.stem}{suffix}{app.suffix}"


__all__ = [
    "VenvProblems",
    "add_suffix",
    "can_symlink",
    "expose_resources_globally",
    "get_exposed_man_paths_for_package",
    "get_exposed_paths_for_package",
    "get_venv_summary",
    "package_name_from_spec",
    "run_post_install_actions",
    "venv_health_check",
]
