__package__ = "abxpkg"

import inspect
import os
import shutil
import tempfile

from pathlib import Path
from typing import Any, Annotated
from collections.abc import Callable

from pydantic import TypeAdapter, AfterValidator, BeforeValidator, ValidationError

from .config import default_abxpkg_lib_dir, is_forbidden_convenience_lib_bin


# Read once at import time. When set, providers that opt into
# ``abxpkg_install_root_default("<provider>")`` default their
# ``install_root`` to ``ABXPKG_LIB_DIR / <provider name>`` (e.g.
# ``<lib>/npm``, ``<lib>/pip``, ``<lib>/gem``). Per-provider
# ``ABXPKG_<NAME>_ROOT`` env vars override this for their own
# provider; explicit constructor kwargs override both.
DEFAULT_ABXPKG_LIB_DIR: Path = default_abxpkg_lib_dir()


def abxpkg_install_root_default(provider_name: str) -> Path | None:
    """Resolve a provider's default install root from env vars.

    Precedence (most specific wins):
    1. ``ABXPKG_<PROVIDER>_ROOT`` (e.g. ``ABXPKG_NPM_ROOT``)
    2. ``ABXPKG_LIB_DIR / <provider_name>``
    3. ``None`` — caller's built-in default applies.

    Explicit constructor kwargs (``install_root=`` or the
    provider-specific alias) always override whatever this function
    returns.
    """
    specific = os.environ.get(f"ABXPKG_{provider_name.upper()}_ROOT", "").strip()
    if specific:
        return Path(specific).expanduser().resolve()
    # Read from os.environ directly (not the cached module-level
    # ABXPKG_LIB_DIR) because the CLI sets it at runtime via --lib.
    lib_dir = os.environ.get("ABXPKG_LIB_DIR", "").strip()
    if lib_dir:
        return Path(lib_dir).expanduser().resolve() / provider_name
    return None


def abxpkg_cache_dir_default(provider_name: str) -> Path | None:
    """Resolve a provider cache dir from env vars."""
    specific = os.environ.get(f"ABXPKG_{provider_name.upper()}_CACHE_DIR", "").strip()
    if specific:
        return Path(specific).expanduser().resolve()
    lib_dir = os.environ.get("ABXPKG_LIB_DIR", "").strip()
    if lib_dir:
        return Path(lib_dir).expanduser().resolve() / "cache" / provider_name
    return None


def abxpkg_ephemeral_cache_home_default() -> Path:
    """Resolve the stable cache root used for explicit no-cache operations."""
    base = os.environ.get("ABXPKG_TMP_CACHE_DIR", "").strip()
    if base:
        return Path(base).expanduser().resolve()
    return Path(tempfile.gettempdir()) / f"abxpkg-cache-{os.getuid()}"


def abxpkg_ephemeral_cache_dir_default(provider_name: str) -> Path:
    """Resolve a stable per-process cache dir outside ``ABXPKG_LIB_DIR``."""
    specific = os.environ.get(f"ABXPKG_{provider_name.upper()}_CACHE_DIR", "").strip()
    if specific:
        return Path(specific).expanduser().resolve()
    return abxpkg_ephemeral_cache_home_default() / provider_name


def validate_binprovider_name(name: str) -> str:
    assert 1 < len(name) < 16, (
        "BinProvider names must be between 1 and 16 characters long"
    )
    assert name.replace("_", "").isalnum(), (
        "BinProvider names can only contain a-Z0-9 and underscores"
    )
    assert name[0].isalpha(), "BinProvider names must start with a letter"
    return name


BinProviderName = Annotated[str, AfterValidator(validate_binprovider_name)]
# in practice this is essentially BinProviderName: Literal['env', 'pip', 'apt', 'brew', 'npm', 'vendor']
# but because users can create their own BinProviders we can't restrict it to a preset list of literal names


def validate_bin_dir(path: Path) -> Path:
    path = path.expanduser().absolute()
    assert path.resolve()
    assert os.path.isdir(path) and os.access(path, os.R_OK), (
        f"path entries to add to $PATH must be absolute paths to directories {dir}"
    )
    return path


BinDirPath = Annotated[Path, AfterValidator(validate_bin_dir)]


def validate_PATH(PATH: str | list[str]) -> str:
    paths = PATH.split(":") if isinstance(PATH, str) else list(PATH)
    assert all(Path(bin_dir) for bin_dir in paths)
    return ":".join(paths).strip(":")


PATHStr = Annotated[str, BeforeValidator(validate_PATH)]


def func_takes_args_or_kwargs(lambda_func: Callable[..., Any]) -> bool:
    """returns True if a lambda func takes args/kwargs of any kind, otherwise false if it's pure/argless"""
    signature = inspect.signature(lambda_func)
    return any(
        parameter.kind
        in (
            inspect.Parameter.POSITIONAL_ONLY,
            inspect.Parameter.POSITIONAL_OR_KEYWORD,
            inspect.Parameter.VAR_POSITIONAL,
            inspect.Parameter.VAR_KEYWORD,
        )
        for parameter in signature.parameters.values()
    )


def bin_name(bin_path_or_name: str | Path) -> str:
    """
    - wget -> wget
    - /usr/bin/wget -> wget
    - ~/bin/wget -> wget
    - ~/.local/bin/wget -> wget
    - @postlight/parser -> @postlight/parser
    - @postlight/parser@2.2.3 -> @postlight/parser
    - yt-dlp==2024.05.09 -> yt-dlp
    - postlight/parser^2.2.3 -> postlight/parser
    - @postlight/parser@^2.2.3 -> @postlight/parser
    """
    str_bin_name = (
        str(bin_path_or_name)
        .split("^", 1)[0]
        .split("=", 1)[0]
        .split(">", 1)[0]
        .split("<", 1)[0]
    )
    if str_bin_name.startswith("@"):
        # @postlight/parser@^2.2.3 -> @postlight/parser
        str_bin_name = "@" + str_bin_name[1:].split("@", 1)[0]
    else:
        str_bin_name = str_bin_name.split("@", 1)[0]

    assert len(str_bin_name) > 0, "Binary names must be non-empty"
    name = (
        Path(str_bin_name).name if str_bin_name[0] in (".", "/", "~") else str_bin_name
    )
    assert 1 <= len(name) < 64, "Binary names must be between 1 and 63 characters long"
    assert (
        name.replace("-", "")
        .replace("_", "")
        .replace(".", "")
        .replace(" ", "")
        .replace("@", "")
        .replace("/", "")
        .isalnum()
    ), f"Binary name can only contain a-Z0-9-_.@/ and spaces: {name}"
    assert name.replace("@", "")[0].isalpha(), (
        "Binary names must start with a letter or @"
    )
    return name


BinName = Annotated[str, AfterValidator(bin_name)]


def path_is_file(path: Path | str) -> Path:
    path = Path(path) if isinstance(path, str) else path
    assert os.path.isfile(path) and os.access(path, os.R_OK), (
        f"Path is not a file or we dont have permission to read it: {path}"
    )
    return path


HostExistsPath = Annotated[Path, AfterValidator(path_is_file)]


def path_is_executable(path: Path | str) -> Path:
    path = Path(path) if isinstance(path, str) else path
    assert os.path.isfile(path) and os.access(path, os.X_OK), (
        f"Path is not executable (fix by running chmod +x {path})"
    )
    return path


def path_is_script(path: HostExistsPath) -> HostExistsPath:
    SCRIPT_EXTENSIONS = (".py", ".js", ".sh")
    assert path.suffix.lower() in SCRIPT_EXTENSIONS, (
        "Path is not a script (does not end in {})".format(", ".join(SCRIPT_EXTENSIONS))
    )
    return path


HostExecutablePath = Annotated[Path, AfterValidator(path_is_executable)]


def path_is_abspath(path: Path) -> Path:
    path = path.expanduser().absolute()  # resolve ~/ -> /home/<username/ and ../../
    assert (
        path.resolve()
    )  # make sure symlinks can be resolved, but dont return resolved link
    return path


HostAbsPath = Annotated[HostExistsPath, AfterValidator(path_is_abspath)]


def path_is_binary(path: Path | str) -> Path:
    path = Path(path) if isinstance(path, str) else path
    assert os.path.isfile(path) and (
        os.access(path, os.R_OK) or os.access(path, os.X_OK)
    ), f"Path is not a readable binary artifact or executable: {path}"
    return path


HostBinPath = Annotated[
    Path,
    AfterValidator(path_is_binary),
    AfterValidator(path_is_abspath),
]
# Package providers may expose readable artifacts (for example a Python module
# or extension manifest) as their loaded path. Host command discovery still
# uses executable-only lookup, and execute-only system commands are valid even
# when their contents cannot be read for hashing.


def bin_abspath(
    bin_path_or_name: str | BinName | Path,
    PATH: PATHStr | None = None,
) -> HostBinPath | None:
    assert bin_path_or_name
    if PATH is None:
        PATH = os.environ.get("PATH", "/bin")
    if PATH:
        PATH = os.pathsep.join(
            entry
            for entry in str(PATH).split(os.pathsep)
            if not is_forbidden_convenience_lib_bin(entry)
        )
    else:
        return None

    if str(bin_path_or_name).startswith("/"):
        # already a path, get its absolute form
        abspath = Path(bin_path_or_name).expanduser().absolute()
    else:
        # not a path yet, get path using shutil.which
        binpath = shutil.which(bin_path_or_name, mode=os.X_OK, path=PATH)
        if not binpath:
            # some bins dont show up with shutil.which (e.g. django-admin.py)
            for path in PATH.split(os.pathsep):
                if is_forbidden_convenience_lib_bin(path):
                    continue
                bin_dir = Path(path)
                if not (os.path.isdir(bin_dir) and os.access(bin_dir, os.R_OK)):
                    continue
                bin_file = bin_dir / bin_path_or_name
                if os.path.isfile(bin_file) and os.access(bin_file, os.R_OK):
                    return bin_file

            return None
        if (
            is_forbidden_convenience_lib_bin(Path(binpath).parent)
            or str(Path(binpath).parent) not in PATH
        ):
            # found bin but it was outside our search $PATH
            return None
        abspath = Path(binpath).expanduser().absolute()

    try:
        return TypeAdapter(HostBinPath).validate_python(abspath)
    except ValidationError:
        return None


def bin_abspaths(
    bin_path_or_name: BinName | Path,
    PATH: PATHStr | None = None,
) -> list[HostBinPath]:
    assert bin_path_or_name

    PATH = os.pathsep.join(
        entry
        for entry in str(PATH or os.environ.get("PATH", "/bin")).split(os.pathsep)
        if not is_forbidden_convenience_lib_bin(entry)
    )
    abspaths = []

    if str(bin_path_or_name).startswith("/"):
        # already a path, get its absolute form
        abspaths.append(Path(bin_path_or_name).expanduser().absolute())
    else:
        # Return every executable match in PATH order. Calling shutil.which()
        # once per directory repeats its PATH parsing and candidate setup for
        # every provider cache validation.
        for path in PATH.split(os.pathsep):
            binpath = os.path.abspath(os.path.join(path, str(bin_path_or_name)))
            if os.path.isfile(binpath) and os.access(binpath, os.X_OK):
                abspaths.append(Path(binpath))

    try:
        return TypeAdapter(list[HostBinPath]).validate_python(abspaths)
    except ValidationError:
        return []


################## Types ##############################################

UNKNOWN_SHA256 = "unknown"
UNKNOWN_MTIME = 0
UNKNOWN_EUID = -1


def is_valid_sha256(sha256: str) -> str:
    if sha256 == UNKNOWN_SHA256:
        return sha256
    assert len(sha256) == 64
    assert sha256.isalnum()
    return sha256


Sha256 = Annotated[str, AfterValidator(is_valid_sha256)]


def is_valid_mtime(mtime: int) -> int:
    assert mtime >= 0
    return mtime


MTimeNs = Annotated[int, AfterValidator(is_valid_mtime)]


def is_valid_euid(euid: int) -> int:
    assert euid >= UNKNOWN_EUID
    return euid


EUID = Annotated[int, AfterValidator(is_valid_euid)]


def is_valid_install_args(
    install_args: list[str] | tuple[str, ...] | str,
) -> tuple[str, ...]:
    """Make sure a string is a valid install string for a package manager, e.g. ['yt-dlp', 'ffmpeg']"""
    if isinstance(install_args, str):
        install_args = [install_args]
    assert install_args
    assert all(len(arg) for arg in install_args)
    return tuple(install_args)


def is_name_of_method_on_self(method_name: str) -> str:
    assert (
        method_name.startswith("self.")
        and method_name.replace(".", "").replace("_", "").isalnum()
    )
    return method_name


InstallArgs = Annotated[
    tuple[str, ...] | list[str],
    AfterValidator(is_valid_install_args),
]

SelfMethodName = Annotated[str, AfterValidator(is_name_of_method_on_self)]
