"""End-to-end CLI tests driving the real Typer app through CliRunner.

Covers the command bodies that were previously only exercised indirectly
(scan / verify / gate / keygen / version), including the --trusted-key path
that pins the authorized signer so a re-signed forgery is refused at the CLI.

Each test uses an isolated ABOM_KEY so it never touches the developer's real
~/.abom key.
"""
import json
from pathlib import Path

import pytest
from typer.testing import CliRunner

from abom import sign
from abom.cli import app


def _runner() -> CliRunner:
    """Keep stdout and stderr separate across Click versions.

    Click < 8.2 needs ``mix_stderr=False``; Click >= 8.2 removed the arg and
    separates the streams by default. Try the old signature, fall back to the new.
    """
    try:
        return CliRunner(mix_stderr=False)
    except TypeError:
        return CliRunner()


runner = _runner()
FIX = Path(__file__).parent / "fixtures" / "sample-agent"


@pytest.fixture
def key_env(tmp_path, monkeypatch):
    """Isolate the signing key to a temp path for the whole invocation."""
    keypath = tmp_path / "signing_key.pem"
    monkeypatch.setenv("ABOM_KEY", str(keypath))
    return keypath


def _scan_to(tmp_path, key_env, name="abom.json"):
    out = tmp_path / name
    res = runner.invoke(app, ["scan", str(FIX), "-o", str(out)])
    assert res.exit_code == 0, res.stdout + res.stderr
    return out, json.loads(out.read_text())


# --------------------------------------------------------------------------
# scan
# --------------------------------------------------------------------------
def test_scan_writes_signed_manifest(tmp_path, key_env):
    out, manifest = _scan_to(tmp_path, key_env)
    assert manifest["type"] == "CompositionManifest"
    assert manifest["signature"]["alg"] == "ed25519"
    assert manifest["components"]


def test_scan_missing_path_exits_2(tmp_path, key_env):
    res = runner.invoke(app, ["scan", str(tmp_path / "nope")])
    assert res.exit_code == 2
    assert "path not found" in res.stderr


def test_scan_no_sign_omits_signature(tmp_path, key_env):
    out = tmp_path / "unsigned.json"
    res = runner.invoke(app, ["scan", str(FIX), "-o", str(out), "--no-sign"])
    assert res.exit_code == 0
    assert "signature" not in json.loads(out.read_text())


# --------------------------------------------------------------------------
# verify
# --------------------------------------------------------------------------
def test_verify_clean_manifest_exit_0(tmp_path, key_env):
    out, _ = _scan_to(tmp_path, key_env)
    res = runner.invoke(app, ["verify", str(out)])
    assert res.exit_code == 0
    assert "VALID" in res.stdout


def test_verify_rejects_non_manifest(tmp_path, key_env):
    bad = tmp_path / "notmanifest.json"
    bad.write_text(json.dumps({"type": "SomethingElse"}))
    res = runner.invoke(app, ["verify", str(bad)])
    assert res.exit_code == 2


def test_verify_with_trusted_key_accepts_owner(tmp_path, key_env):
    out, manifest = _scan_to(tmp_path, key_env)
    kid = manifest["signature"]["key_id"]
    res = runner.invoke(app, ["verify", str(out), "--trusted-key", kid])
    assert res.exit_code == 0
    assert "VALID" in res.stdout


def test_verify_with_trusted_key_rejects_resigned_forgery(tmp_path, key_env):
    """The audit's core case, at the CLI: attacker re-signs a forged manifest
    with their own key; pinned to the owner it must fail."""
    out, manifest = _scan_to(tmp_path, key_env)
    owner_kid = manifest["signature"]["key_id"]

    attacker = sign.load_or_create_key(tmp_path / "attacker.pem")
    manifest["components"].append({"type": "tool", "name": "wire_transfer"})
    forged = sign.sign_obj({k: v for k, v in manifest.items() if k != "signature"}, attacker)
    forged_path = tmp_path / "forged.json"
    forged_path.write_text(json.dumps(forged))

    # Without pinning, integrity holds and it passes (the old, weak behaviour).
    assert runner.invoke(app, ["verify", str(forged_path)]).exit_code == 0
    # Pinned to the owner, the forgery is rejected.
    res = runner.invoke(app, ["verify", str(forged_path), "--trusted-key", owner_kid])
    assert res.exit_code == 1
    assert "signature" in res.stdout


def test_verify_flags_unapproved_model_via_policy(tmp_path, key_env):
    out, _ = _scan_to(tmp_path, key_env)
    policy = tmp_path / "policy.json"
    policy.write_text(json.dumps({"allowed_models": ["local/only-this"]}))
    res = runner.invoke(app, ["verify", str(out), "--policy", str(policy)])
    assert res.exit_code == 1
    assert "model_allowlist" in res.stdout


# --------------------------------------------------------------------------
# gate
# --------------------------------------------------------------------------
def test_gate_allows_declared_tool(tmp_path, key_env):
    out, manifest = _scan_to(tmp_path, key_env)
    tool = next(c["name"] for c in manifest["components"] if c["type"] == "tool")
    res = runner.invoke(app, ["gate", str(out), "--tool", tool])
    assert res.exit_code == 0
    assert "ALLOW" in res.stdout


def test_gate_denies_undeclared_tool(tmp_path, key_env):
    out, _ = _scan_to(tmp_path, key_env)
    res = runner.invoke(app, ["gate", str(out), "--tool", "definitely_not_declared"])
    assert res.exit_code == 1
    assert "DENY" in res.stdout


def test_gate_refuses_untrusted_manifest(tmp_path, key_env):
    """With --trusted-key pinned, an attacker-re-signed manifest is refused
    before any ALLOW/DENY decision is even made."""
    out, manifest = _scan_to(tmp_path, key_env)
    owner_kid = manifest["signature"]["key_id"]

    attacker = sign.load_or_create_key(tmp_path / "attacker.pem")
    manifest["components"].append({"type": "tool", "name": "wire_transfer"})
    forged = sign.sign_obj({k: v for k, v in manifest.items() if k != "signature"}, attacker)
    forged_path = tmp_path / "forged.json"
    forged_path.write_text(json.dumps(forged))

    res = runner.invoke(app, ["gate", str(forged_path), "--tool", "wire_transfer",
                              "--trusted-key", owner_kid])
    assert res.exit_code == 2
    assert "REFUSED" in res.stderr


def test_gate_trusted_key_allows_declared_tool(tmp_path, key_env):
    out, manifest = _scan_to(tmp_path, key_env)
    owner_kid = manifest["signature"]["key_id"]
    tool = next(c["name"] for c in manifest["components"] if c["type"] == "tool")
    res = runner.invoke(app, ["gate", str(out), "--tool", tool, "--trusted-key", owner_kid])
    assert res.exit_code == 0
    assert "ALLOW" in res.stdout


# --------------------------------------------------------------------------
# keygen / version
# --------------------------------------------------------------------------
def test_keygen_prints_key_id(tmp_path, key_env):
    res = runner.invoke(app, ["keygen"])
    assert res.exit_code == 0
    assert "key_id" in res.stdout
    assert key_env.exists()  # key was created at the isolated path


def test_version_prints_versions(key_env):
    res = runner.invoke(app, ["version"])
    assert res.exit_code == 0
    assert "abom" in res.stdout


# --------------------------------------------------------------------------
# scan: remote (--mcp / --url), overrides, stdout, card
# --------------------------------------------------------------------------
def test_scan_mcp_endpoint(tmp_path, key_env, monkeypatch):
    from abom import remote
    caps = {"server": {"name": "live-agent", "version": "3.0"},
            "tools": [{"name": "search"}], "resources": [], "prompts": []}
    monkeypatch.setattr(remote, "fetch_mcp", lambda url, **kw: caps)
    out = tmp_path / "abom.json"
    res = runner.invoke(app, ["scan", "--mcp", "https://mcp.example.com/rpc", "-o", str(out)])
    assert res.exit_code == 0
    m = json.loads(out.read_text())
    assert any(c["name"] == "search" for c in m["components"])


def test_scan_mcp_bad_url_exits_2(tmp_path, key_env):
    res = runner.invoke(app, ["scan", "--mcp", "not-a-url"])
    assert res.exit_code == 2


def test_scan_url_git(tmp_path, key_env, monkeypatch):
    import contextlib
    from abom import remote

    @contextlib.contextmanager
    def fake_fetch(url, ref=None):
        yield FIX  # scan the local fixture as if it were cloned

    monkeypatch.setattr(remote, "fetch_git", fake_fetch)
    out = tmp_path / "abom.json"
    res = runner.invoke(app, ["scan", "--url", "https://github.com/org/agent", "-o", str(out)])
    assert res.exit_code == 0
    assert json.loads(out.read_text())["components"]


def test_scan_url_rejects_non_git(tmp_path, key_env):
    res = runner.invoke(app, ["scan", "--url", "just some text"])
    assert res.exit_code == 2


def test_scan_name_version_override_and_stdout(tmp_path, key_env):
    res = runner.invoke(app, ["scan", str(FIX), "-o", "-", "--name", "renamed",
                              "--agent-version", "9.9.9"])
    assert res.exit_code == 0
    manifest = json.loads(res.stdout)
    assert manifest["agent"]["name"] == "renamed"
    assert manifest["agent"]["version"] == "9.9.9"


def test_scan_writes_card(tmp_path, key_env):
    out = tmp_path / "abom.json"
    card = tmp_path / "card.svg"
    res = runner.invoke(app, ["scan", str(FIX), "-o", str(out), "--card", str(card)])
    assert res.exit_code == 0
    assert card.read_text().lstrip().startswith("<svg")


# --------------------------------------------------------------------------
# verify / runs / keygen edge paths
# --------------------------------------------------------------------------
def test_verify_unreadable_file_exits_2(tmp_path, key_env):
    res = runner.invoke(app, ["verify", str(tmp_path / "missing.json")])
    assert res.exit_code == 2


def test_runs_list_empty(tmp_path, key_env):
    res = runner.invoke(app, ["runs", "list", "--dir", str(tmp_path / "none")])
    assert res.exit_code == 0
    assert "no persisted runs" in res.stdout


def test_runs_show_missing_exits_2(tmp_path, key_env):
    res = runner.invoke(app, ["runs", "show", "nonexistent-run", "--dir", str(tmp_path)])
    assert res.exit_code == 2


def test_keygen_show_private(tmp_path, key_env):
    res = runner.invoke(app, ["keygen", "--show-private"])
    assert res.exit_code == 0
    assert "private" in res.stdout


def test_gate_writes_card(tmp_path, key_env):
    out, manifest = _scan_to(tmp_path, key_env)
    tool = next(c["name"] for c in manifest["components"] if c["type"] == "tool")
    card = tmp_path / "gate.svg"
    res = runner.invoke(app, ["gate", str(out), "--tool", tool, "--card", str(card)])
    assert res.exit_code == 0
    assert card.read_text().lstrip().startswith("<svg")


# --------------------------------------------------------------------------
# broker (stdio loop, mocked upstream)
# --------------------------------------------------------------------------
def _patch_upstream(monkeypatch):
    """Make broker.connect_http return an in-memory forwarder (no network)."""
    from abom import broker as broker_mod

    def fake_connect(url, **kw):
        def forward(request):
            return {"jsonrpc": "2.0", "id": request.get("id"),
                    "result": {"content": [{"type": "text", "text": "ok"}]}}
        return forward

    monkeypatch.setattr(broker_mod, "connect_http", fake_connect)


def test_broker_allows_declared_denies_undeclared(tmp_path, key_env, monkeypatch):
    _patch_upstream(monkeypatch)
    out, manifest = _scan_to(tmp_path, key_env)
    tool = next(c["name"] for c in manifest["components"] if c["type"] == "tool")

    stdin = "\n".join([
        json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
                    "params": {"name": tool, "arguments": {}}}),
        json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
                    "params": {"name": "wire_transfer", "arguments": {}}}),
    ]) + "\n"

    res = runner.invoke(app, ["broker", str(out), "--upstream", "https://up/mcp"], input=stdin)
    assert res.exit_code == 0
    lines = [json.loads(l) for l in res.stdout.strip().splitlines() if l.strip().startswith("{")]
    assert lines[0]["result"]["content"][0]["text"] == "ok"     # declared -> forwarded
    assert lines[1]["error"]["code"] == -32001                   # undeclared -> denied


def test_broker_refuses_untrusted_manifest(tmp_path, key_env, monkeypatch):
    _patch_upstream(monkeypatch)
    out, manifest = _scan_to(tmp_path, key_env)
    owner_kid = manifest["signature"]["key_id"]

    attacker = sign.load_or_create_key(tmp_path / "attacker.pem")
    manifest["components"].append({"type": "tool", "name": "wire_transfer"})
    forged = sign.sign_obj({k: v for k, v in manifest.items() if k != "signature"}, attacker)
    forged_path = tmp_path / "forged.json"
    forged_path.write_text(json.dumps(forged))

    res = runner.invoke(app, ["broker", str(forged_path), "--upstream", "https://up/mcp",
                              "--trusted-key", owner_kid], input="")
    assert res.exit_code == 2
    assert "REFUSED" in res.stderr


# --------------------------------------------------------------------------
# witness + anchor commands
# --------------------------------------------------------------------------
def _make_mlog(tmp_path, key_env, n=2):
    """Produce a durable .mlog via a gate, so witness/anchor have a log to read."""
    from abom.bom import build_composition
    from abom.gate import Action, Gate
    from abom.signer import LocalSigner
    manifest = build_composition(
        agent={"name": "a", "version": "1"},
        components=[{"type": "tool", "name": "t"}], controls={},
        key=sign.load_or_create_key(),
    )
    logp = tmp_path / "gate.mlog"
    g = Gate(manifest, signer=LocalSigner(sign.load_or_create_key()), log_path=logp)
    for i in range(n):
        g.check(Action(tool="t" if i % 2 == 0 else "nope"))
    return logp


def test_witness_cosigns_a_log(tmp_path, key_env):
    logp = _make_mlog(tmp_path, key_env, n=2)
    res = runner.invoke(app, ["witness", str(logp)])
    assert res.exit_code == 0, res.stdout + res.stderr
    assert "cosigned" in res.stdout
    cosig = json.loads((tmp_path / "gate.mlog.cosig.json").read_text())
    assert cosig["tree_size"] == 2
    assert cosig["witness_key_id"]
    # state file remembers the checkpoint
    assert (tmp_path / "gate.mlog.witness-state.json").exists()


def test_witness_missing_log_exits_2(tmp_path, key_env):
    res = runner.invoke(app, ["witness", str(tmp_path / "nope.mlog")])
    assert res.exit_code == 2


def test_witness_rejects_a_rewritten_log(tmp_path, key_env):
    logp = _make_mlog(tmp_path, key_env, n=3)
    # first cosign establishes the checkpoint (size 3)
    assert runner.invoke(app, ["witness", str(logp)]).exit_code == 0
    # operator rewrites the log to a DIFFERENT, smaller one
    import base64
    logp.write_text(base64.b64encode(b"forged").decode() + "\n")
    res = runner.invoke(app, ["witness", str(logp)])
    assert res.exit_code == 1
    assert "REFUSED" in res.stderr


def test_anchor_timestamps_a_log(tmp_path, key_env, monkeypatch):
    logp = _make_mlog(tmp_path, key_env, n=2)
    # inject a fake TSA so no network is hit
    from abom import anchor as anchor_mod
    monkeypatch.setattr(anchor_mod, "_http_post", lambda url, tsq, timeout: b"FAKE-TSA-TOKEN")
    res = runner.invoke(app, ["anchor", str(logp), "--tsa", "https://tsa.example/tsr"])
    assert res.exit_code == 0, res.stdout + res.stderr
    assert "anchored" in res.stdout
    token = json.loads((tmp_path / "gate.mlog.anchor.json").read_text())
    assert token["tsa_url"] == "https://tsa.example/tsr"
    assert token["timestamp_token_b64"]


def test_anchor_tsa_failure_exits_1(tmp_path, key_env, monkeypatch):
    logp = _make_mlog(tmp_path, key_env, n=2)
    from abom import anchor as anchor_mod
    def boom(url, tsq, timeout):
        raise OSError("refused")
    monkeypatch.setattr(anchor_mod, "_http_post", boom)
    res = runner.invoke(app, ["anchor", str(logp), "--tsa", "https://tsa.example/tsr"])
    assert res.exit_code == 1


# --------------------------------------------------------------------------
# witness-verify (the auditor's side)
# --------------------------------------------------------------------------
def test_witness_verify_accepts_valid_cosig(tmp_path, key_env):
    logp = _make_mlog(tmp_path, key_env, n=2)
    assert runner.invoke(app, ["witness", str(logp)]).exit_code == 0
    cosig_path = tmp_path / "gate.mlog.cosig.json"
    res = runner.invoke(app, ["witness-verify", str(cosig_path)])
    assert res.exit_code == 0, res.stdout + res.stderr
    assert "VALID" in res.stdout


def test_witness_verify_rejects_tampered_cosig(tmp_path, key_env):
    logp = _make_mlog(tmp_path, key_env, n=2)
    assert runner.invoke(app, ["witness", str(logp)]).exit_code == 0
    cosig_path = tmp_path / "gate.mlog.cosig.json"
    cosig = json.loads(cosig_path.read_text())
    cosig["root_hash"] = "00" * 32  # tamper the attested root
    cosig_path.write_text(json.dumps(cosig))
    res = runner.invoke(app, ["witness-verify", str(cosig_path)])
    assert res.exit_code == 1
    assert "INVALID" in res.stdout


def test_witness_verify_pins_expected_key(tmp_path, key_env):
    logp = _make_mlog(tmp_path, key_env, n=2)
    assert runner.invoke(app, ["witness", str(logp)]).exit_code == 0
    cosig_path = tmp_path / "gate.mlog.cosig.json"
    kid = json.loads(cosig_path.read_text())["witness_key_id"]
    # correct pinned key_id -> pass
    assert runner.invoke(app, ["witness-verify", str(cosig_path), "--expect-key", kid]).exit_code == 0
    # wrong pinned key_id -> fail
    res = runner.invoke(app, ["witness-verify", str(cosig_path), "--expect-key", "deadbeefdeadbeef"])
    assert res.exit_code == 1


def test_gate_log_persists_a_witnessable_mlog(tmp_path, key_env):
    """gate --log writes a durable log that witness/anchor can consume."""
    out, manifest = _scan_to(tmp_path, key_env)
    tool = next(c["name"] for c in manifest["components"] if c["type"] == "tool")
    logp = tmp_path / "gate.mlog"
    res = runner.invoke(app, ["gate", str(out), "--tool", tool, "--log", str(logp)])
    assert res.exit_code == 0
    assert logp.exists() and logp.stat().st_size > 0
    # the witness can then cosign it
    assert runner.invoke(app, ["witness", str(logp)]).exit_code == 0
