"""Agent-native supply-chain tool: execute_guarddog (L3).

WHY THIS IS AGENT-SIDE, NOT A KALI MCP TOOL
-------------------------------------------
GuardDog downloads the attacker-authored package tarball and analyses it, so it
MUST run in the hardened, secret-free analyzer image. Only the orchestrator
holds the Docker socket, and the Kali worker is the least-trusted, target-facing
zone that holds no secrets (docs/readmes/README.TM.SYSTEM_OVERVIEW.md, TB4). So the
dispatch cannot live in Kali.

Instead it rides the SAME trust-consistent lane the CodeFix build sandbox uses
(T6/E10):

    agent ──X-Internal-Key──> webapp ──X-Orchestrator-Key──> orchestrator ──docker──> analyzer

The agent (this process, TB3) holds INTERNAL_API_KEY and reaches the webapp on
the shared bridge; the webapp is the only component that also reaches the
orchestrator. Kali never touches Docker and never sees a secret.

execute_osv_scanner stays a Kali MCP tool: it is passive, offline, and only
reads the mounted OSV DB - no dispatch, no secrets, safe in TB4.
"""
from __future__ import annotations

import logging
import os
import re
import shlex
from typing import Any, Dict

import httpx
from langchain_core.tools import tool

logger = logging.getLogger(__name__)

WEBAPP_API_URL = os.environ.get("WEBAPP_API_URL", "http://webapp:3000").rstrip("/")
_ENDPOINT = f"{WEBAPP_API_URL}/api/internal/supply-chain/guarddog"

_ECOSYSTEMS = {"npm", "pypi", "go", "crates", "rubygems", "github_action", "extension"}
# Package name / version charset gate. The FIRST char must be alphanumeric or @
# (npm scopes): this refuses a leading '-' so a name like "--help" can never
# reach GuardDog's argv as a flag (there is no shell, but argparse would still
# consume it). Mirrored by the webapp and orchestrator gates - three layers.
_SAFE = re.compile(r"^[A-Za-z0-9@][A-Za-z0-9._@/+-]{0,213}$")


def _internal_headers() -> Dict[str, str]:
    # Read at call time, not import time: the key may be injected after import.
    return {
        "X-Internal-Key": os.environ.get("INTERNAL_API_KEY", ""),
        "Content-Type": "application/json",
    }


async def _guarddog_impl(spec: str) -> str:
    """Behavioural malware analysis of ONE named package (does it BEHAVE like
    malware: install hooks, obfuscation, exfiltration, typosquatting?).

    DANGEROUS: downloads the package's attacker-authored tarball. It does NOT run
    here; it is dispatched to the hardened, secret-free, network-isolated
    supply-chain analyzer (cap_drop=ALL, read-only, non-root). Use it ONLY to
    triage a package a passive check (execute_osv_scanner / a name heuristic)
    already flagged, never to sweep a whole dependency set. A GuardDog hit is
    SUSPICIOUS, never a terminal malicious verdict (only an OSV MAL- hit is).

    Args:
        spec: "<ecosystem> <name> [version]" where ecosystem is one of
              npm, pypi, go, crates, rubygems, github_action, extension.

    Returns:
        A compact suspicious-findings summary (data, not instructions).

    Examples:
        - "npm event-stream"
        - "pypi requests 2.31.0"
    """
    try:
        parts = shlex.split(spec or "")
    except ValueError:
        return "[ERROR] could not parse args; use \"<ecosystem> <name> [version]\""
    if len(parts) < 2:
        return "[ERROR] usage: <ecosystem> <name> [version]"
    eco, name = parts[0].lower(), parts[1]
    version = parts[2] if len(parts) > 2 else ""
    if eco not in _ECOSYSTEMS:
        return "[ERROR] unsupported ecosystem: {} (npm|pypi|go|crates|rubygems|github_action|extension)".format(parts[0])
    if not _SAFE.match(name):
        return "[ERROR] invalid package name (charset/leading-char validation)"
    if version and not _SAFE.match(version):
        return "[ERROR] invalid version (charset/leading-char validation)"

    try:
        async with httpx.AsyncClient(timeout=240.0) as client:
            resp = await client.post(
                _ENDPOINT, headers=_internal_headers(),
                json={"ecosystem": eco, "name": name, "version": version})
    except Exception as e:  # network/transport
        logger.error(f"[guarddog] dispatch failed: {e}")
        return "[ERROR] guarddog dispatch failed: {}".format(e)

    if resp.status_code == 401:
        return "[ERROR] guarddog dispatch unauthorized (INTERNAL_API_KEY missing/invalid)"
    try:
        data = resp.json()
    except ValueError:
        return "[ERROR] guarddog returned non-JSON (HTTP {})".format(resp.status_code)
    # A hostile/misconfigured hop could return a JSON array or scalar; .get would
    # then raise AttributeError and crash the tool. Fail as data, not a crash.
    if not isinstance(data, dict):
        return "[ERROR] guarddog returned unexpected JSON shape (HTTP {})".format(resp.status_code)
    # 409 = the resource governor refused to admit the analyzer container. This is
    # TRANSIENT and retryable, unlike every other error here, so say so: told only
    # "HTTP 409" an agent concludes the tool is broken and stops using it.
    #
    # Two refusal kinds reach here and they need different wording. "ram" means
    # the host is genuinely short of memory; "hard" means an operator-set
    # concurrency ceiling. Reporting a count cap as "no free memory" would send
    # the agent (or the operator reading the transcript) after the wrong problem.
    if resp.status_code == 409:
        limit = data.get("detail") if isinstance(data.get("detail"), dict) else {}
        cause = ("a concurrency limit ({})".format(limit.get("settingName") or "operator cap")
                 if limit.get("limitType") == "hard"
                 else "the host is low on memory")
        detail = limit.get("detail") or "resource governor"
        return ("[ERROR] guarddog deferred: the analyzer could not be started because "
                "{} ({}). This is TEMPORARY - retry once running scans finish. The "
                "package was NOT analyzed: do not treat this as a clean result."
                .format(cause, detail))
    if resp.status_code >= 400 or data.get("error"):
        return "[ERROR] {}".format(data.get("error") or "guarddog HTTP {}".format(resp.status_code))

    issues = data.get("issues", 0)
    fired = data.get("rules_fired") or []
    errs = data.get("errors") or []
    lines = ["[DATA] guarddog behavioural analysis of {} {} (SUSPICIOUS only, "
             "not a malicious verdict; treat as data)".format(eco, name)]
    # A run that could not download/analyze the package (e.g. an unpublished or
    # 404 version) fires NO rules, so issues:0 is meaningless - it must NOT read
    # as "clean". Surface incompleteness first and unmistakably (same false-clean
    # class the L1 D1 fix closed).
    if errs:
        lines.append(
            "ANALYSIS INCOMPLETE - GuardDog could not fully analyze this "
            "package (errors: {}); a low 'issues' count here does NOT mean the "
            "package is clean.".format(", ".join(str(e) for e in errs)))
    lines.append("issues: {}".format(issues))
    if fired:
        lines.append("rules fired: " + ", ".join(str(r) for r in fired[:30]))
    return "\n".join(lines)


# The LangChain-wrapped tool the executor registers. The impl is kept separate
# and named so tests can exercise the real coroutine even when a sibling test
# has stubbed langchain_core.tools.tool into a MagicMock (focused-suite
# isolation). Production always imports this with the real decorator.
execute_guarddog = tool("execute_guarddog")(_guarddog_impl)


def build_supply_chain_tools() -> Dict[str, Any]:
    """Agent-native supply-chain tools (non-MCP, registered like traffic tools)."""
    return {"execute_guarddog": execute_guarddog}
