#!/usr/bin/env python3
__package__ = "abxpkg"

import os

from pathlib import Path

from pydantic import Field, TypeAdapter, model_validator, computed_field
from typing import ClassVar, Self

from .base_types import (
    BinProviderName,
    PATHStr,
    BinName,
    InstallArgs,
    HostBinPath,
    abxpkg_install_root_default,
    bin_abspath,
)
from .semver import SemVer
from .binprovider import (
    BinProvider,
    BinProviderOverrides,
    DEFAULT_ENV_PATH,
    log_method_call,
    remap_kwargs,
)
from .logging import format_subprocess_output


DEFAULT_GOPATH = Path(os.environ.get("GOPATH", "~/go")).expanduser()


class GoGetProvider(BinProvider):
    name: BinProviderName = "goget"
    _log_emoji = "🐹"
    INSTALLER_BIN: BinName = "go"
    INSTALLER_BINPROVIDERS: ClassVar[tuple[BinProviderName, ...] | None] = (
        "env",
        "brew",
        "apt",
    )
    INSTALLER_VERSION_ARGS: ClassVar[tuple[str, ...] | None] = ("version",)

    PATH: PATHStr = DEFAULT_ENV_PATH  # Starts with ambient system PATH; setup_PATH() prepends the active GOBIN/bin_dir lazily.

    install_root: Path | None = Field(
        default_factory=lambda: abxpkg_install_root_default("goget"),
        validation_alias="gopath",
    )
    # detect_euid_to_use() fills this from GOPATH/bin (or explicit GOBIN) and setup()
    # creates it before ``go install`` starts writing binaries into the provider root.
    bin_dir: Path | None = Field(default=None, validation_alias="gobin")

    overrides: "BinProviderOverrides" = {
        "*": {
            "version": "self.default_version_handler",
            "abspath": "self.default_abspath_handler",
            "install_args": "self.default_install_args_handler",
            "install": "self.default_install_handler",
            "update": "self.default_update_handler",
            "uninstall": "self.default_uninstall_handler",
            "docs_url": "self.default_docs_url_handler",
            "search": "self.default_search_handler",
        },
        "go": {
            "version": ["go", "version"],
        },
    }

    @computed_field
    @property
    def ENV(self) -> "dict[str, str]":
        if not self.install_root:
            return {}
        env: dict[str, str] = {"GOPATH": str(self.install_root)}
        if self.bin_dir:
            env["GOBIN"] = str(self.bin_dir)
        return env

    @computed_field
    @property
    def is_valid(self) -> bool:
        return super().is_valid

    @model_validator(mode="after")
    def detect_euid_to_use(self) -> Self:
        """Resolve GOPATH/GOBIN defaults and expand the managed Go bin_dir."""
        if self.install_root is None:
            self.install_root = DEFAULT_GOPATH
        if self.bin_dir is None:
            self.bin_dir = (self.install_root / "bin").expanduser()
        else:
            self.bin_dir = self.bin_dir.expanduser()

        return self

    def setup_PATH(self, no_cache: bool = False) -> None:
        """Populate PATH on first use by prepending the active Go bin_dir to ambient PATH."""
        bin_dir = self.bin_dir
        assert bin_dir is not None
        self.PATH = self._merge_PATH(bin_dir, PATH=self.PATH)
        super().setup_PATH(no_cache=no_cache)

    @log_method_call()
    def setup(
        self,
        *,
        postinstall_scripts: bool | None = None,
        min_release_age: float | None = None,
        min_version: SemVer | None = None,
        no_cache: bool = False,
    ) -> None:
        install_root = self.install_root
        bin_dir = self.bin_dir
        assert install_root is not None
        assert bin_dir is not None
        if self.euid is None:
            self.euid = self.detect_euid(
                owner_paths=(bin_dir, install_root),
                preserve_root=True,
            )
        install_root.mkdir(parents=True, exist_ok=True)
        bin_dir.mkdir(parents=True, exist_ok=True)

    def default_install_args_handler(self, bin_name: BinName, **context) -> InstallArgs:
        bin_name_str = str(bin_name)
        if not (
            bin_name_str.startswith(("./", "../"))
            or ("/" in bin_name_str and "." in bin_name_str.split("/", 1)[0])
        ):
            raise ValueError(
                f"{self.__class__.__name__} requires install_args with a full Go module path for {bin_name!r}, e.g. overrides={{'{bin_name}': {{'install_args': ['example.com/module/cmd/{bin_name}@latest']}}}}",
            )
        return [f"{bin_name}@latest"]

    def default_docs_url_handler(
        self,
        bin_name: BinName,
        **context,
    ) -> str | None:
        try:
            install_args = self.get_install_args(str(bin_name), quiet=True)
        except Exception:
            return None
        for arg in install_args or []:
            if not arg or arg.startswith("-"):
                continue
            if arg.startswith(("./", "../", "/")):
                continue  # local path target — pkg.go.dev can't resolve it
            module_path = str(arg).split("@", 1)[0]
            # Go module paths must look like a domain followed by a path,
            # e.g. ``example.com/foo`` — both a ``/`` and a ``.`` in the host.
            host = module_path.split("/", 1)[0]
            if "/" not in module_path or "." not in host:
                continue
            return f"https://pkg.go.dev/{module_path}"
        return None

    def default_search_handler(
        self,
        bin_name: str,
        min_version: SemVer | None = None,
        min_release_age: float | None = None,
        timeout: int | None = None,
        **context,
    ) -> list:
        """Resolve the latest tagged version for an exact Go module path.

        Go has no native package-name search; ``go list -m -versions <path>``
        is the closest underlying API and only works for exact module paths
        like ``github.com/foo/bar``.
        """
        from .binary import Binary

        # Use ``self.INSTALLER_BINARY`` so goget's auto-install logic
        # kicks in if env's go is missing/broken. (``go`` is special:
        # it doesn't accept ``--version`` — the override on this
        # provider's ``"go": {"version": ["go", "version"]}`` map is
        # what makes ``self.INSTALLER_BINARY`` resolve cleanly.)
        installer = self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False)))
        assert installer and installer.loaded_abspath
        proc = self.exec(
            bin_name=installer.loaded_abspath,
            cmd=["list", "-m", "-versions", str(bin_name)],
            quiet=True,
            timeout=timeout,
        )
        if proc.returncode != 0 or not proc.stdout.strip():
            return []
        # Output: "<module-path> v0.1 v0.2 v1.0 ..."
        parts = proc.stdout.split()
        module_path = parts[0]
        latest_version = parts[-1] if len(parts) > 1 else ""
        install_arg = (
            f"{module_path}@{latest_version}" if latest_version else module_path
        )
        # ``go install`` writes the binary under its leaf name (e.g.
        # ``github.com/spf13/cobra-cli`` → ``cobra-cli``). Use the leaf as
        # the Binary name so a follow-up ``.install()`` loads the actual
        # produced binary, but keep the full module path in install_args.
        binary_name = module_path.rsplit("/", 1)[-1]
        return [
            Binary(
                name=binary_name,
                description=f"{latest_version} - {module_path}".strip(" -"),
                binproviders=[self],
                overrides={self.name: {"install_args": [install_arg]}},
            ),
        ]

    @remap_kwargs({"packages": "install_args"})
    def default_install_handler(
        self,
        bin_name: str,
        install_args: InstallArgs | None = None,
        postinstall_scripts: bool | None = None,
        min_release_age: float | None = None,
        min_version: SemVer | None = None,
        no_cache: bool = False,
        timeout: int | None = None,
    ) -> str:
        install_args = install_args or self.get_install_args(bin_name)
        installer_bin = self.INSTALLER_BINARY(no_cache=no_cache).loaded_abspath
        assert installer_bin

        proc = self.exec(
            bin_name=installer_bin,
            cmd=["install", *install_args],
            timeout=timeout,
        )
        if proc.returncode != 0:
            self._raise_proc_error("install", install_args, proc)

        return format_subprocess_output(proc.stdout, proc.stderr)

    @remap_kwargs({"packages": "install_args"})
    def default_update_handler(
        self,
        bin_name: str,
        install_args: InstallArgs | None = None,
        postinstall_scripts: bool | None = None,
        min_release_age: float | None = None,
        min_version: SemVer | None = None,
        timeout: int | None = None,
    ) -> str:
        return self.default_install_handler(
            bin_name=bin_name,
            install_args=install_args,
            postinstall_scripts=postinstall_scripts,
            min_release_age=min_release_age,
            min_version=min_version,
            timeout=timeout,
        )

    @remap_kwargs({"packages": "install_args"})
    def default_uninstall_handler(
        self,
        bin_name: str,
        install_args: InstallArgs | None = None,
        postinstall_scripts: bool | None = None,
        min_release_age: float | None = None,
        min_version: SemVer | None = None,
        timeout: int | None = None,
    ) -> bool:
        abspath = self.get_abspath(bin_name, quiet=True, no_cache=True)
        if not abspath:
            return True

        Path(abspath).unlink(missing_ok=True)
        # Also remove the short binary name (e.g. "shfmt" for
        # "mvdan.cc/sh/v3/cmd/shfmt") from bin_dir.
        if self.bin_dir:
            install_args = self.get_install_args(str(bin_name))
            install_target = install_args[0] if install_args else str(bin_name)
            short_name = Path(str(install_target).split("@", 1)[0].rstrip("/")).name
            if short_name and short_name != str(bin_name):
                (self.bin_dir / short_name).unlink(missing_ok=True)
        return True

    def default_abspath_handler(
        self,
        bin_name: BinName | HostBinPath,
        no_cache: bool = False,
        **context,
    ) -> HostBinPath | None:
        bin_name_str = str(bin_name)
        abspath = super().default_abspath_handler(bin_name, **context)
        if abspath:
            return TypeAdapter(HostBinPath).validate_python(abspath)

        install_args = list(
            context.get("install_args") or self.get_install_args(bin_name_str),
        )
        install_target = install_args[0] if install_args else bin_name_str
        candidate_name = (
            Path(
                str(install_target).split("@", 1)[0].rstrip("/"),
            ).name
            or bin_name_str
        )
        if candidate_name == bin_name_str:
            return None
        candidate_abspath = bin_abspath(candidate_name, PATH=str(self.bin_dir))
        if candidate_abspath is None:
            return None
        direct_abspath = TypeAdapter(HostBinPath).validate_python(candidate_abspath)
        bin_dir = self.bin_dir
        assert bin_dir is not None
        link_path = bin_dir / str(bin_name)
        link_path.parent.mkdir(parents=True, exist_ok=True)
        # Idempotent refresh: skip when shim already points at target.
        # Rewriting on every load() bumps mtime and churns the inode,
        # which invalidates fingerprint caches unnecessarily.
        if link_path.is_symlink():
            try:
                if link_path.readlink() == Path(direct_abspath):
                    return TypeAdapter(HostBinPath).validate_python(link_path)
            except OSError:
                pass
        if link_path.exists() or link_path.is_symlink():
            link_path.unlink(missing_ok=True)
        link_path.symlink_to(direct_abspath)
        return TypeAdapter(HostBinPath).validate_python(link_path)

    def default_version_handler(
        self,
        bin_name: BinName,
        abspath: HostBinPath | None = None,
        timeout: int | None = None,
        no_cache: bool = False,
        **context,
    ) -> SemVer | None:
        abspath = abspath or self.get_abspath(bin_name, quiet=True)
        if not abspath:
            return None
        if str(bin_name) == self.INSTALLER_BIN:
            version_provider = (
                self.get_provider_with_overrides(dry_run=False)
                if self.dry_run
                else self
            )
            proc = version_provider.exec(
                bin_name=abspath,
                cmd=["version"],
                timeout=timeout,
                quiet=True,
            )
            if proc.returncode != 0:
                return None
            return SemVer.parse(proc.stdout.strip() or proc.stderr.strip())
        try:
            installer_abspath = self.INSTALLER_BINARY(no_cache=no_cache).loaded_abspath
            assert installer_abspath
        except Exception:
            return None

        proc = self.exec(
            bin_name=installer_abspath,
            cmd=["version", "-m", abspath],
            timeout=timeout,
            quiet=True,
        )
        if proc.returncode == 0:
            for line in proc.stdout.strip().splitlines():
                if line.startswith("mod\t"):
                    parts = line.split("\t")
                    if len(parts) >= 3:
                        return parts[2].lstrip("v")

        version = self._version_from_exec(
            bin_name,
            abspath=abspath,
            timeout=timeout,
        )
        return version
