from __future__ import annotations

import json
import os
import re
import subprocess
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path

from ..errors import BuildError


@dataclass(frozen=True)
class ResolvedModule:
    module_path: str
    version: str
    module_dir: Path


_GO_TRANSIENT_NET_RE = re.compile(
    r"("
    r"proxy\.golang\.org"
    r"|sum\.golang\.org"
    r"|wsarecv"
    r"|connection (?:attempt failed|reset)"
    r"|i/o timeout"
    r"|tls handshake timeout"
    r"|unexpected eof"
    r"|temporary failure"
    r"|no such host"
    r"|502 bad gateway"
    r"|503 service unavailable"
    r"|504 gateway timeout"
    r")",
    re.IGNORECASE,
)


def _go_network_hint(out: str) -> str | None:
    if not _GO_TRANSIENT_NET_RE.search(out):
        return None
    return (
        "\n\nHint: Go module download failed due to a network/proxy error. "
        "Try re-running the command. If `proxy.golang.org` is blocked/unreliable "
        "in your environment, try setting `GOPROXY=direct` (or another reachable proxy) "
        "and retry."
    )


def _read_module_path(module_dir: Path) -> str:
    go_mod = module_dir / "go.mod"
    if not go_mod.exists():
        raise BuildError(f"go.mod not found in {module_dir}")
    for line in go_mod.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if line.startswith("module "):
            return line.split()[1]
    raise BuildError("failed to parse module path from go.mod")


def _find_module_root(start: Path) -> Path:
    p = start
    while True:
        if (p / "go.mod").exists():
            return p
        if p.parent == p:
            break
        p = p.parent
    raise BuildError(f"go.mod not found in {start} or any parent directory")


def resolve_module_target(
    *, target: str, version: str | None, env: dict[str, str] | None = None
) -> ResolvedModule:
    """Resolve a builder target to a local module directory and concrete version.

    - If `target` is a local directory, locate the nearest parent containing go.mod
      (allows passing a subdirectory of a module) and return version "local".
    - Otherwise treat `target` as a Go import path (module or package path) and use
      `go mod download -json` to resolve module root and version (defaults to @latest).

    `env` is passed through to `go` subprocesses (for example to set `GOMODCACHE`).
    """
    p = Path(target)
    if p.exists() and p.is_dir():
        module_dir = _find_module_root(p.resolve())
        module_path = _read_module_path(module_dir)
        return ResolvedModule(module_path=module_path, version="local", module_dir=module_dir)

    # Support `go get` style syntax: `module@version`.
    # We only parse this for remote targets (local directory paths are handled above).
    if "@" in target:
        base, inline = target.rsplit("@", 1)
        if base and inline:
            if version is not None:
                raise BuildError("conflicting version: use either target@version or version=, not both")
            target = base
            version = inline

    wanted = version
    if wanted is None or wanted == "latest":
        wanted = "@latest"
    mod_path, mod_version, mod_dir = _resolve_remote_module(
        import_path=target, wanted=wanted, env=env
    )
    return ResolvedModule(module_path=mod_path, version=mod_version, module_dir=mod_dir)


def _resolve_remote_module(
    *, import_path: str, wanted: str, env: dict[str, str] | None
) -> tuple[str, str, Path]:
    # For non-module package paths, trim segments until download succeeds.
    candidate = import_path
    while True:
        try:
            # `go mod download` uses special queries like `@latest`.
            # Allow callers to pass `wanted` with or without the leading "@".
            arg = f"{candidate}{wanted}" if wanted.startswith("@") else f"{candidate}@{wanted}"
            info = _go_mod_download_json(arg, env=env)
            mod_path = str(info["Path"])
            mod_version = str(info["Version"])
            mod_dir = Path(str(info["Dir"])).resolve()
            return mod_path, mod_version, mod_dir
        except BuildError as e:
            # If the failure looks like a transient network/proxy problem, do not
            # "walk up" the import path: it hides the real error and can produce
            # misleading messages like `github.com@vX.Y.Z`.
            if _GO_TRANSIENT_NET_RE.search(str(e)):
                raise
            # Trim one path segment and try again.
            if "/" not in candidate:
                raise
            candidate = candidate.rsplit("/", 1)[0]


def _go_mod_download_json(arg: str, *, env: dict[str, str] | None) -> dict:
    # `go mod download` does not require being inside a module, but to be robust
    # across environments, run in a temp directory.
    with tempfile.TemporaryDirectory(prefix="usegolib-moddl-") as td:
        max_attempts = 3
        backoff_s = 0.5
        base_env = env
        cur_env = env
        last_out = ""

        for attempt in range(max_attempts):
            try:
                proc = subprocess.run(
                    ["go", "mod", "download", "-json", arg],
                    cwd=td,
                    env=cur_env,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=False,
                    check=False,
                )
            except FileNotFoundError as e:
                raise BuildError(
                    "Go toolchain not found (`go` is missing from PATH). "
                    "Install Go and ensure `go` is available on PATH. "
                    "If you do not want auto-build on import, pass `build_if_missing=False` "
                    "(and use prebuilt artifacts/wheels)."
                ) from e

            out = (proc.stdout or b"").decode("utf-8", errors="replace")
            last_out = out
            if proc.returncode == 0:
                break

            if attempt < max_attempts - 1 and _GO_TRANSIENT_NET_RE.search(out):
                if "proxy.golang.org" in out.lower():
                    if base_env is None:
                        next_env = dict(os.environ)
                    else:
                        next_env = dict(base_env)
                    if "GOPROXY" not in next_env:
                        next_env["GOPROXY"] = "direct"
                        cur_env = next_env
                time.sleep(backoff_s)
                backoff_s *= 2.0
                continue

            hint = _go_network_hint(out)
            if hint:
                out = out.rstrip("\n") + hint + "\n"
            raise BuildError(f"go mod download failed for {arg}\n{out}")

        if proc.returncode != 0:
            hint = _go_network_hint(last_out)
            if hint:
                last_out = last_out.rstrip("\n") + hint + "\n"
            raise BuildError(f"go mod download failed for {arg}\n{last_out}")
        try:
            return json.loads(last_out)
        except Exception:
            # Go may print non-JSON lines (e.g. toolchain switching messages) to stderr,
            # which we merge into stdout for portability. Extract the first JSON object.
            out = last_out
            start = out.find("{")
            if start == -1:
                raise BuildError(f"failed to parse go mod download output for {arg}")
            depth = 0
            end = -1
            for i, ch in enumerate(out[start:], start=start):
                if ch == "{":
                    depth += 1
                elif ch == "}":
                    depth -= 1
                    if depth == 0:
                        end = i + 1
                        break
            if end == -1:
                raise BuildError(f"failed to parse go mod download output for {arg}")
            try:
                return json.loads(out[start:end])
            except Exception as e:  # noqa: BLE001
                raise BuildError(f"failed to parse go mod download output for {arg}: {e}") from e
