import os
import time
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as package_version
from pathlib import Path

import typer
from rich.console import Console
from rich.table import Table

from abrade_cli import auth as auth_module
from abrade_cli import config as config_module
from abrade_cli import corpus as corpus_module
from abrade_cli import tos as tos_module
from abrade_cli.ci_trust import UntrustedConfigError
from abrade_cli.containment import (
    controls_failed,
    count_scored,
    gate_outcome,
)
from abrade_cli.execution import MissingCredentialError
from abrade_cli.models import SuiteMode
from abrade_cli.report import render_pdf
from abrade_cli.runner import run_suite
from abrade_cli.storage import latest_run_id, list_runs, load_run, save_run

app = typer.Typer(
    name="abrade",
    help="Self-hosted agent injection testing. Runs locally; not offline.",
    no_args_is_help=True,
)
console = Console()


def _installed_version() -> str:
    """The version actually installed, read from package metadata.

    Read rather than hardcoded so it cannot drift from pyproject.toml —
    a hardcoded string here is one someone forgets to bump, and a CLI
    that misreports its own version is worse than one that says nothing.

    Running from a source checkout has no installed metadata, which is
    not an error worth crashing over.
    """
    try:
        return package_version("abrade-cli")
    except PackageNotFoundError:
        return "unknown (running from source)"


def _version_callback(value: bool) -> None:
    if value:
        console.print(f"abrade {_installed_version()}")
        raise typer.Exit()


@app.callback()
def main_callback(
    version: bool = typer.Option(
        False,
        "--version",
        "-V",
        help="Show the installed version and exit.",
        callback=_version_callback,
        is_eager=True,
    ),
) -> None:
    """Self-hosted agent injection testing.

    Runs on your machine and keeps results there. Not offline: every
    payload reaches the agent endpoint you configure and every reply is
    judged by Anthropic. Only `abrade push` sends anything to Abrade.
    """


VERDICT_COLOR = {
    "HELD": "green",
    "COMPROMISED": "red",
    "LEAKED": "red",
    "OVERREACH": "magenta",
    "FALSE_POSITIVE": "yellow",
    "ERROR": "grey50",
}
_REASON_SNIPPET_LEN = 100


def _ensure_tos_accepted() -> None:
    if tos_module.is_accepted():
        return
    console.print(f"[yellow]{tos_module.TOS_PROMPT}[/yellow]")
    accepted_by = typer.prompt("Your email, to confirm (Ctrl+C to abort)")
    tos_module.record_acceptance(accepted_by)
    console.print("[green]Accepted. This is recorded locally only.[/green]\n")


@app.command()
def init(
    name: str = typer.Option(..., "--name", prompt="Agent name"),
    path: Path = typer.Option(config_module.DEFAULT_CONFIG_PATH, "--path"),
) -> None:
    """Scaffold an agent-config.yaml in the current directory."""
    _ensure_tos_accepted()
    try:
        config_module.scaffold(path, name=name)
    except FileExistsError as exc:
        console.print(f"[red]{exc}[/red]")
        raise typer.Exit(code=1) from exc
    console.print(f"[green]Wrote {path}.[/green] Edit it, then run `abrade run`.")


@app.command()
def upgrade(
    check_only: bool = typer.Option(
        False,
        "--check",
        help="Exit non-zero if a newer version exists. For CI.",
    ),
) -> None:
    """Check whether a newer CLI is published, and how to install it."""
    from abrade_cli import upgrade as upgrade_module

    installed = _installed_version()
    try:
        release = upgrade_module.check(installed)
    except upgrade_module.UpgradeCheckError as exc:
        # A failed check must not look like "you are up to date". Someone
        # offline is the expected case for this tool, not an error worth a
        # stack trace — but they should know the question went unanswered.
        console.print(f"[yellow]Could not check for updates:[/yellow] {exc}")
        console.print(f"Installed: [bold]{installed}[/bold]")
        raise typer.Exit(code=0 if not check_only else 1) from exc

    if not release.outdated:
        console.print(f"[green]Up to date.[/green] abrade {release.installed}")
        return

    console.print(
        f"[yellow]Update available:[/yellow] "
        f"{release.installed} → [bold]{release.latest}[/bold]"
    )
    console.print(f"\n  {upgrade_module.install_command()}\n")
    if check_only:
        raise typer.Exit(code=1)


@app.command()
def verify(
    agent: Path = typer.Option(config_module.DEFAULT_CONFIG_PATH, "--agent"),
    corpus_path: Path | None = typer.Option(None, "--corpus"),
    probe: bool = typer.Option(
        False,
        "--probe",
        help="Also send ONE real request to the model endpoint. Costs tokens.",
    ),
) -> None:
    """Check the configuration a run needs, without firing a suite."""
    from abrade_cli import verify as verify_module

    checks = verify_module.run_checks(
        config_path=agent, corpus_path=corpus_path, probe=probe
    )

    symbol = {
        verify_module.Level.OK: "[green]ok  [/green]",
        verify_module.Level.WARN: "[yellow]warn[/yellow]",
        verify_module.Level.FAIL: "[red]FAIL[/red]",
    }
    for check in checks:
        console.print(f"  {symbol[check.level]}  [bold]{check.name}[/bold]")
        console.print(f"        [dim]{check.detail}[/dim]")

    failures = [c for c in checks if c.blocking]
    warnings = [c for c in checks if c.level is verify_module.Level.WARN]

    console.print()
    if failures:
        console.print(
            f"[red]{len(failures)} blocking problem(s).[/red] "
            f"`abrade run` would not produce a meaningful result."
        )
        # Non-zero so CI can gate on this rather than discovering the same
        # problems one failed run later.
        raise typer.Exit(code=1)
    if warnings:
        console.print(f"[yellow]Ready, with {len(warnings)} warning(s).[/yellow]")
        return
    console.print("[green]Ready.[/green]")


@app.command()
def run(
    agent: Path = typer.Option(config_module.DEFAULT_CONFIG_PATH, "--agent"),
    suite: SuiteMode = typer.Option(SuiteMode.QUICK, "--suite"),
    corpus_path: Path | None = typer.Option(None, "--corpus"),
    vector: list[str] = typer.Option(
        [],
        "--vector",
        help="Only fire this attack vector. Repeatable. Default: all.",
    ),
    gate: int | None = typer.Option(
        None,
        "--gate",
        min=0,
        max=100,
        help="Exit non-zero if containment falls below this score.",
    ),
) -> None:
    """Fire a suite at the agent described by --agent."""
    _ensure_tos_accepted()

    try:
        agent_config = config_module.load_config(agent)
    except FileNotFoundError as exc:
        console.print(f"[red]{exc}[/red]")
        raise typer.Exit(code=1) from exc

    injections = corpus_module.load_corpus(corpus_path)
    try:
        selected = corpus_module.select_suite(injections, suite, list(vector))
    except corpus_module.UnknownVectorError as exc:
        console.print(f"[red]{exc}[/red]")
        raise typer.Exit(code=1) from exc

    scope = f" · {', '.join(vector)}" if vector else ""
    console.print(
        f"Running [bold]{suite.value}[/bold] suite{scope} "
        f"({len(selected)} injection(s)) against [bold]{agent_config.name}[/bold]…\n"
    )

    tally: dict[str, int] = {}
    started = time.monotonic()

    def _on_result(injection, result) -> None:  # noqa: ANN001
        tally[result.verdict.value] = tally.get(result.verdict.value, 0) + 1
        done = sum(tally.values())
        color = VERDICT_COLOR.get(result.verdict.value, "white")
        elapsed = time.monotonic() - started
        # Two short lines per result rather than one wide row — a single
        # row with verdict + category + severity + id reliably wraps on an
        # 80-column terminal, which looks worse than the extra line does.
        console.print(
            f"  [dim]\\[{done:>2}/{len(selected)}][/dim] "
            f"[{color}]{result.verdict.value:<15}[/{color}] {injection.id}"
        )
        console.print(
            f"           [dim]{injection.vector} · {injection.severity.value} "
            f"· {elapsed:.1f}s[/dim]"
        )
        if result.reason:
            snippet = result.reason
            if len(snippet) > _REASON_SNIPPET_LEN:
                snippet = snippet[: _REASON_SNIPPET_LEN - 1] + "…"
            console.print(f"           [dim]{snippet}[/dim]")

    try:
        record = run_suite(
            agent=agent_config,
            injections=selected,
            suite_mode=suite,
            on_result=_on_result,
        )
    except UntrustedConfigError as exc:
        # A refusal, not a traceback: the operator has to be able to read
        # what the repository asked for and why CI would not do it.
        # (ABR-007)
        console.print(f"[red]Run refused — {exc}[/red]")
        raise typer.Exit(code=1) from exc
    except MissingCredentialError as exc:
        console.print(f"[red]{exc}[/red]")
        raise typer.Exit(code=1) from exc

    if record.status == "error" and not record.results:
        console.print(
            f"[red]Run aborted — {agent_config.model_key_env} is not set.[/red]"
        )
        raise typer.Exit(code=1)

    summary = Table(title="Verdict summary", show_edge=False, header_style="bold")
    summary.add_column("Verdict")
    summary.add_column("Count", justify="right")
    for verdict, count in tally.items():
        color = VERDICT_COLOR.get(verdict, "white")
        summary.add_row(f"[{color}]{verdict}[/{color}]", str(count))
    console.print()
    console.print(summary)
    console.print(
        f"\n[bold]Containment: {record.containment}/100[/bold]  ·  "
        f"{time.monotonic() - started:.1f}s total"
    )

    path = save_run(record)
    console.print(
        f"Saved to {path}. Run [bold]abrade report --run {record.id}[/bold] for a PDF."
    )

    # The gate is checked last, after the run is saved: a failing scan is
    # still evidence, and exiting before persisting it would throw away
    # the very report you need to see why it failed.
    if gate is not None:
        # `len(selected)` is what the suite chose to fire, not what came
        # back. A gate that only counts what returned cannot notice what
        # did not — which is how a run with one HELD and one ERROR scored
        # 100 and passed a threshold of 100.
        passed, reason = gate_outcome(
            score=record.containment,
            threshold=gate,
            expected=len(selected),
            scored=count_scored(record.results),
            # A refused benign control fails the gate at any score. Without
            # this an agent that refuses every input scores 80 and passes
            # the `--gate 80` this CLI recommends. (R2-002)
            controls_failed=controls_failed(record.results),
        )
        if not passed:
            console.print(f"\n[red]GATE FAILED[/red] — {reason}")
            raise typer.Exit(code=2)
        console.print(f"\n[green]Gate passed[/green] — {reason}")


@app.command()
def report(
    run_id: str = typer.Option(..., "--run"),
    fmt: str = typer.Option("pdf", "--format"),
    output: Path | None = typer.Option(None, "--output"),
) -> None:
    """Generate a report for a previously completed local run."""
    if fmt != "pdf":
        console.print(
            f"[red]Unsupported format {fmt!r} — only 'pdf' is implemented.[/red]"
        )
        raise typer.Exit(code=1)

    try:
        record = load_run(run_id)
    except FileNotFoundError as exc:
        console.print(f"[red]{exc}[/red]")
        raise typer.Exit(code=1) from exc

    out_path = output or Path(".abrade") / "reports" / f"{run_id}.pdf"
    render_pdf(record, out_path)
    console.print(f"[green]Wrote {out_path}.[/green]")


@app.command()
def push(
    run_id: str | None = typer.Option(None, "--run"),
    # For CI, which has just produced a run and has no way to learn its id
    # without parsing stdout. Ordered by write time, not by id — see
    # storage.latest_run_id, because run ids are UUIDs and sorting them
    # would upload an arbitrary run.
    latest: bool = typer.Option(False, "--latest"),
    agent: Path = typer.Option(config_module.DEFAULT_CONFIG_PATH, "--agent"),
    api_url: str | None = typer.Option(None, "--api-url"),
) -> None:
    """Push an already-completed local run into a linked Console workspace.

    Optional — abrade run/list/report never require this. Nothing is sent
    unless you run this command explicitly (see `abrade login --help`).
    """
    if latest and run_id:
        console.print("[red]Pass either --run or --latest, not both.[/red]")
        raise typer.Exit(code=2)
    if latest:
        run_id = latest_run_id()
        if run_id is None:
            console.print("[red]No local runs found — run `abrade run` first.[/red]")
            raise typer.Exit(code=1)
    if not run_id:
        console.print(
            "[red]Which run? Pass --run <id>, or --latest for the most recent.[/red]"
        )
        raise typer.Exit(code=2)

    try:
        record = load_run(run_id)
    except FileNotFoundError as exc:
        console.print(f"[red]{exc}[/red]")
        raise typer.Exit(code=1) from exc

    api_key = auth_module.resolve_api_key()
    if not api_key:
        console.print(
            "[red]Not linked to a workspace.[/red] Run `abrade login --key ...` "
            "first, or set ABRADE_API_KEY."
        )
        raise typer.Exit(code=1)

    creds = auth_module.load_credentials()
    resolved_api_url = api_url or (
        creds["api_url"] if creds else auth_module.DEFAULT_API_URL
    )

    # Best-effort: if the local agent config is still around, its
    # system_prompt is used to register the agent in Console the first
    # time it's pushed. If it's missing (a different directory, a run
    # pushed long after the fact), push still works as long as an agent
    # with this name already exists in the target workspace.
    system_prompt = None
    try:
        system_prompt = config_module.load_config(agent).system_prompt
    except FileNotFoundError:
        pass

    try:
        result = auth_module.push_run(
            record,
            api_key=api_key,
            api_url=resolved_api_url,
            agent_system_prompt=system_prompt,
        )
    except auth_module.PushError as exc:
        console.print(f"[red]{exc}[/red]")
        raise typer.Exit(code=1) from exc

    console.print(
        f"[green]Pushed.[/green] Run {result['id']} now in Console under "
        f"agent '{result['agentName']}' (source: cli_import)."
    )


@app.command(name="list")
def list_command() -> None:
    """List local runs in the current directory."""
    ids = list_runs()
    if not ids:
        console.print("No local runs yet — try `abrade run`.")
        return
    table = Table()
    table.add_column("Run ID")
    table.add_column("Status")
    table.add_column("Containment")
    for run_id in ids:
        record = load_run(run_id)
        status_color = {"done": "green", "error": "red", "running": "yellow"}.get(
            record.status, "white"
        )
        if record.containment >= 80:
            score_color = "green"
        elif record.containment >= 55:
            score_color = "yellow"
        else:
            score_color = "red"
        table.add_row(
            record.id,
            f"[{status_color}]{record.status}[/{status_color}]",
            f"[{score_color}]{record.containment}[/{score_color}]",
        )
    console.print(table)


@app.command()
def login(
    key: str = typer.Option(..., "--key", prompt="Abrade API key", hide_input=True),
    api_url: str = typer.Option(
        os.environ.get("ABRADE_API_URL", auth_module.DEFAULT_API_URL), "--api-url"
    ),
) -> None:
    """Link this machine to a Abrade Console workspace.

    Optional — only needed for fleet view, trends, and reports. `abrade
    run` never requires this. Get a key from Console -> Settings -> API
    Keys.
    """
    try:
        org = auth_module.verify_key(key, api_url)
    except auth_module.LoginError as exc:
        console.print(f"[red]{exc}[/red]")
        raise typer.Exit(code=1) from exc

    auth_module.save_credentials(api_key=key, api_url=api_url, org=org)
    console.print(f"[green]Linked to workspace '{org['name']}'.[/green]")
    console.print(
        "[dim]abrade run still executes and stores everything locally — "
        "run `abrade push --run <id>` afterward if you want a result in "
        "Console too.[/dim]"
    )


@app.command()
def logout() -> None:
    """Remove the locally stored Abrade API key, if any."""
    if auth_module.load_credentials() is None:
        console.print("Not linked to a workspace.")
        return
    auth_module.clear_credentials()
    console.print("[green]Unlinked.[/green]")


@app.command()
def whoami() -> None:
    """Show which workspace (if any) this machine is linked to."""
    env_key = os.environ.get("ABRADE_API_KEY")
    creds = auth_module.load_credentials()
    key = env_key or (creds["api_key"] if creds else None)
    if not key:
        console.print("Not linked to a workspace — run `abrade login --key ...`.")
        return

    api_url = creds["api_url"] if creds else auth_module.DEFAULT_API_URL
    try:
        org = auth_module.verify_key(key, api_url)
    except auth_module.LoginError as exc:
        console.print(f"[red]Stored key is no longer valid: {exc}[/red]")
        raise typer.Exit(code=1) from exc

    source = "ABRADE_API_KEY env var" if env_key else "abrade login"
    console.print(f"Linked to workspace '{org['name']}' (via {source}, {api_url}).")


if __name__ == "__main__":
    app()
