"""Pre-flight checks for everything a run needs, before it needs it.

A run fires a whole suite of payloads at a live agent and judges each one —
two LLM calls per injection, against two different credentials. Every one
of the things that can be missing is discovered *during* that, when the
cost has already been paid and a partly-finished run has to be explained.

The checks are ordered cheapest-first and none of them spend a token by
default. The endpoint probe is opt-in for exactly that reason: confirming
an endpoint answers means sending it a real request, and a pre-flight check
that quietly bills you is a worse trade than one that says "not checked".
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from urllib.parse import urlparse

from abrade_cli.ci_trust import (
    UntrustedConfigError,
    in_ci,
    resolve_trusted_endpoint,
    resolve_trusted_key_env,
)


class Level(StrEnum):
    OK = "ok"
    WARN = "warn"
    FAIL = "fail"


@dataclass(frozen=True)
class Check:
    name: str
    level: Level
    detail: str

    @property
    def blocking(self) -> bool:
        return self.level is Level.FAIL


def _config_checks(config_path: Path) -> tuple[list[Check], object | None]:
    from abrade_cli import config as config_module

    try:
        config = config_module.load_config(config_path)
    except FileNotFoundError as exc:
        return [Check("agent config", Level.FAIL, str(exc))], None
    except Exception as exc:  # noqa: BLE001 — yaml and pydantic both land here
        # Pydantic's own message names the offending field, which is more
        # useful than anything paraphrasing it would be.
        return [Check("agent config", Level.FAIL, f"{config_path}: {exc}")], None

    checks = [
        Check("agent config", Level.OK, f"{config_path} parsed — agent {config.name!r}")
    ]

    # A default system prompt means the scaffold was never edited, so the
    # run would measure the template rather than the customer's agent. Not
    # fatal — someone may genuinely be trying the tool out — but reporting
    # it as fine would make the resulting verdicts look meaningful.
    if "Replace this with your actual agent" in config.system_prompt:
        checks.append(
            Check(
                "system prompt",
                Level.WARN,
                "still the scaffold's placeholder — the run would test the "
                "template, not your agent",
            )
        )
    else:
        checks.append(Check("system prompt", Level.OK, "customised"))

    return checks, config


def _ci_trust_checks(config) -> list[Check]:  # noqa: ANN001
    """Whether CI would accept what this config asks for. (ABR-007)

    Worth naming here rather than only failing at run time: the point of
    `verify` is to say what will happen before it happens, and "the
    repository is asking to send a secret somewhere this job did not
    permit" is exactly what an operator wants told rather than discovered
    from a failed build.

    Outside CI this is one OK line, because there the config is
    authoritative by design — the person running it wrote it.
    """
    if not in_ci():
        return [
            Check(
                "config trust",
                Level.OK,
                "not in CI — agent-config.yaml is authoritative here",
            )
        ]

    checks: list[Check] = []
    for label, resolve in (
        ("credential variable", resolve_trusted_key_env),
        ("endpoint", resolve_trusted_endpoint),
    ):
        try:
            resolve(config)
        except UntrustedConfigError as exc:
            checks.append(Check(f"CI-trusted {label}", Level.FAIL, str(exc)))
        else:
            checks.append(
                Check(f"CI-trusted {label}", Level.OK, "permitted by this job")
            )
    return checks


def _credential_checks(config) -> list[Check]:  # noqa: ANN001
    checks: list[Check] = []

    aut_env = config.model_key_env
    if os.environ.get(aut_env, "").strip():
        checks.append(
            Check(f"${aut_env}", Level.OK, "set (agent-under-test credential)")
        )
    else:
        checks.append(
            Check(
                f"${aut_env}",
                Level.FAIL,
                "unset — this is the credential the run authenticates to your "
                "agent with. Every injection would fail before reaching it.",
            )
        )

    judge_env = "ABRADE_JUDGE_API_KEY"
    if os.environ.get(judge_env, "").strip():
        checks.append(Check(f"${judge_env}", Level.OK, "set (judge credential)"))
    else:
        checks.append(
            Check(
                f"${judge_env}",
                Level.FAIL,
                "unset — every result would score ERROR. A scan that cannot "
                "reach the judge is not a passing scan.",
            )
        )

    # The one configuration mistake that is silently expensive rather than
    # loudly broken: the same key on both sides still runs, still scores,
    # and quietly means a payload that talks the agent into disclosing its
    # credential has disclosed the judge's.
    aut_value = os.environ.get(aut_env, "").strip()
    judge_value = os.environ.get(judge_env, "").strip()
    if aut_value and judge_value and aut_value == judge_value:
        checks.append(
            Check(
                "credential separation",
                Level.FAIL,
                f"${aut_env} and ${judge_env} are the SAME value. The agent "
                f"under test is being handed the judge's credential, so any "
                f"payload that succeeds in extracting it has extracted the "
                f"one key that scores your results.",
            )
        )
    elif aut_value and judge_value:
        checks.append(Check("credential separation", Level.OK, "two distinct keys"))

    return checks


def _endpoint_checks(config, *, probe: bool) -> list[Check]:  # noqa: ANN001
    endpoint = config.model_endpoint
    parsed = urlparse(endpoint)

    if parsed.scheme not in ("http", "https") or not parsed.netloc:
        return [
            Check(
                "model endpoint",
                Level.FAIL,
                f"{endpoint!r} is not a valid http(s) URL",
            )
        ]

    checks = [Check("model endpoint", Level.OK, endpoint)]

    # Plaintext to anywhere but the local machine puts the agent's
    # credential on the wire in clear. Localhost is the lab case and is
    # fine, so it is not worth a warning that trains people to ignore
    # warnings.
    host = parsed.hostname or ""
    if parsed.scheme == "http" and host not in ("localhost", "127.0.0.1", "::1"):
        checks.append(
            Check(
                "endpoint transport",
                Level.WARN,
                f"plaintext http to {host} — the agent credential would cross "
                f"the network unencrypted",
            )
        )

    if not probe:
        checks.append(
            Check(
                "endpoint reachable",
                Level.WARN,
                "not checked — pass --probe to send one real request",
            )
        )
        return checks

    checks.append(_probe(config))
    return checks


def _probe(config) -> Check:  # noqa: ANN001
    """One real Messages call, minimal tokens.

    Deliberately the same client the run uses rather than a bare socket
    connect: what matters is not whether the port is open but whether the
    credential is accepted and the endpoint speaks the wire format. A TCP
    check would pass against something that 401s every request.
    """
    from anthropic import Anthropic, APIStatusError

    from abrade_cli.upstream_errors import describe

    # The probe is the one check that actually SENDS the credential, so it
    # is the one that most needs the CI trust boundary — and it was reading
    # the repository's choice directly, straight past it. The ABR-007 fix
    # covered `abrade run` and taught `verify` to *report* a refusal while
    # `--probe` went ahead and fired anyway.
    try:
        key_env = resolve_trusted_key_env(config)
        endpoint = resolve_trusted_endpoint(config)
    except UntrustedConfigError as exc:
        return Check("endpoint reachable", Level.FAIL, f"not probed — {exc}")

    key = os.environ.get(key_env, "").strip()
    if not key:
        return Check(
            "endpoint reachable", Level.FAIL, "cannot probe without a credential"
        )

    try:
        client = Anthropic(api_key=key, base_url=endpoint, timeout=30.0)
        client.messages.create(
            model=config.model,
            max_tokens=1,
            messages=[{"role": "user", "content": "ping"}],
        )
    except APIStatusError as exc:
        return Check(
            "endpoint reachable",
            Level.FAIL,
            f"HTTP {exc.status_code} from {endpoint} — "
            + (
                "credential rejected"
                if exc.status_code in (401, 403)
                else "endpoint error"
            ),
        )
    except Exception as exc:  # noqa: BLE001 — connection, timeout, protocol
        # Never `{exc}`. This is the same customer-supplied endpoint the
        # runner talks to, and the SDK puts the response body in the
        # exception string — so a verbose endpoint could put the key it was
        # called with into a preflight message that goes straight to a CI
        # log. `describe` keeps the class and status, which come from the
        # transport rather than the body. (ABR-001)
        return Check(
            "endpoint reachable",
            Level.FAIL,
            f"contacting {endpoint}: {describe(exc, secrets=(key,))}",
        )
    return Check("endpoint reachable", Level.OK, "answered a live request")


def _corpus_checks(corpus_path: Path | None) -> list[Check]:
    from abrade_cli import corpus as corpus_module

    try:
        injections = corpus_module.load_corpus(corpus_path)
    except Exception as exc:  # noqa: BLE001
        return [Check("corpus", Level.FAIL, f"could not load: {exc}")]

    if not injections:
        return [
            Check("corpus", Level.FAIL, "loaded but empty — nothing would be fired")
        ]
    return [Check("corpus", Level.OK, f"{len(injections)} injection(s) available")]


def run_checks(
    *,
    config_path: Path,
    corpus_path: Path | None = None,
    probe: bool = False,
) -> list[Check]:
    """Every check, in cheapest-first order. Never raises — a failed check
    is data, and the caller decides the exit code."""
    checks, config = _config_checks(config_path)
    if config is None:
        # Everything downstream needs a parsed config; reporting ten
        # cascading failures from one missing file is noise.
        return checks

    checks.extend(_ci_trust_checks(config))
    checks.extend(_credential_checks(config))
    checks.extend(_endpoint_checks(config, probe=probe))
    checks.extend(_corpus_checks(corpus_path))
    return checks
