"""Tests for the LangChain adapter (roadmap 1.2): tool + model capture and gating.

No ``langchain`` dependency: a tiny fake executor reproduces LangChain's
dispatch contract — callback events fire around the actual work, and a handler
exception from ``on_tool_start`` (raise_error=True) aborts the tool call. The
sentinel-file test proves the deny happens BEFORE the tool body runs.
"""
from __future__ import annotations

import uuid

import pytest

from abom import sign
from abom.adapters import load_chain
from abom.adapters.langchain import AbomLangChainHandler
from abom.audit import verify_chain
from abom.bom import build_composition, verify_abom
from abom.gate import ActionDenied

MODEL = "claude-3-5-sonnet-20241022"
TOOL = "search_kyc"


def _manifest(model=MODEL, tool=TOOL):
    key = sign.load_or_create_key()
    return build_composition(
        agent={"name": "lc-test-agent", "version": "1.0.0"},
        components=[
            {"type": "model", "name": model},
            {"type": "tool", "name": tool},
        ],
        controls={},
        key=key,
    )


# --- a minimal stand-in for LangChain's dispatch contract ---------------------
class _Gen:
    def __init__(self, text):
        self.text = text


class _LLMResult:
    def __init__(self, text, model=MODEL, total=42):
        self.generations = [[_Gen(text)]]
        self.llm_output = {"model_name": model, "token_usage": {"total_tokens": total}}


def fake_llm_call(handler, *, model=MODEL, prompt="hi"):
    """LangChain order: on_chat_model_start → model runs → on_llm_end."""
    rid = uuid.uuid4()
    handler.on_chat_model_start(
        {"kwargs": {"model": model}}, [[prompt]], run_id=rid,
        invocation_params={"model": model},
    )
    result = _LLMResult(f"reply to {prompt}", model=model)
    handler.on_llm_end(result, run_id=rid)
    return result


def fake_tool_call(handler, tool_fn, *, name=TOOL, input_str="query"):
    """LangChain order: on_tool_start → tool executes → on_tool_end.

    on_tool_start raising (raise_error=True) aborts before tool_fn runs —
    exactly LangChain's behavior for a handler with raise_error set.
    """
    rid = uuid.uuid4()
    handler.on_tool_start({"name": name}, input_str, run_id=rid)
    try:
        out = tool_fn(input_str)
    except Exception as exc:  # pragma: no cover - not exercised here
        handler.on_tool_error(exc, run_id=rid)
        raise
    handler.on_tool_end(out, run_id=rid)
    return out


# --- recording ----------------------------------------------------------------
def test_tool_call_recorded_as_tools_invoked():
    h = AbomLangChainHandler(manifest=_manifest())
    fake_tool_call(h, lambda q: f"results for {q}")

    chain = h.abom.chain
    assert len(chain) == 1
    ti = chain[0]["data"]["tools_invoked"][0]
    assert ti["name"] == TOOL and ti["args_sha256"] is not None
    assert chain[0]["data"]["decision"] == "tool_call"
    assert verify_chain(chain)["valid"]


def test_model_and_tool_records_interleave_in_one_chain():
    h = AbomLangChainHandler(manifest=_manifest())
    fake_llm_call(h)
    fake_tool_call(h, lambda q: "ok")
    fake_llm_call(h)

    chain = h.abom.chain
    assert [r["seq"] for r in chain] == [0, 1, 2]
    kinds = [
        "model" if r["data"]["model_calls"] else "tool" for r in chain
    ]
    assert kinds == ["model", "tool", "model"]
    mc = chain[0]["data"]["model_calls"][0]
    assert mc["model"] == MODEL and mc["tokens"] == 42 and mc["output_sha256"]
    assert verify_chain(chain)["valid"]


# --- gating -------------------------------------------------------------------
def test_undeclared_tool_denied_before_it_executes(tmp_path):
    sentinel = tmp_path / "wired-the-money.txt"

    def wire_transfer(amount):
        sentinel.write_text("$1,000,000 moved")  # must never run
        return "done"

    h = AbomLangChainHandler(manifest=_manifest(), gate=True)
    with pytest.raises(ActionDenied):
        fake_tool_call(h, wire_transfer, name="wire_transfer", input_str="1000000")

    assert not sentinel.exists()          # blocked BEFORE execution
    assert h.abom.chain == []             # a denied call leaves no success record


def test_declared_tool_allowed_through_the_gate():
    h = AbomLangChainHandler(manifest=_manifest(), gate=True)
    out = fake_tool_call(h, lambda q: "ok")
    assert out == "ok"
    assert len(h.abom.chain) == 1


def test_undeclared_model_denied_when_resolvable():
    h = AbomLangChainHandler(manifest=_manifest(model="some-other-model"), gate=True)
    with pytest.raises(ActionDenied):
        fake_llm_call(h)  # requests MODEL, manifest declares some-other-model
    assert h.abom.chain == []


def test_unresolvable_model_permissive_by_default_strict_on_request():
    rid = uuid.uuid4()
    # default: no name resolvable -> not denied, recorded as unknown
    h = AbomLangChainHandler(manifest=_manifest(), gate=True)
    h.on_chat_model_start({}, [["hi"]], run_id=rid)
    h.on_llm_end(_LLMResult("r", model=None), run_id=rid)
    assert h.abom.chain[0]["data"]["model_calls"][0]["model"] == "unknown"

    # strict_models: the same unresolvable start is denied
    strict = AbomLangChainHandler(manifest=_manifest(), gate=True, strict_models=True)
    with pytest.raises(ActionDenied):
        strict.on_chat_model_start({}, [["hi"]], run_id=uuid.uuid4())


def test_tool_error_recorded_as_evidence():
    h = AbomLangChainHandler(manifest=_manifest())
    with pytest.raises(RuntimeError):
        fake_tool_call(h, lambda q: (_ for _ in ()).throw(RuntimeError("boom")))
    assert h.abom.chain[0]["data"]["decision"] == "tool_error"


# --- persistence + verify -----------------------------------------------------
def test_langchain_run_persists_and_verifies(tmp_path):
    m = _manifest()
    h = AbomLangChainHandler(manifest=m, gate=True, sink=tmp_path)
    fake_llm_call(h)
    fake_tool_call(h, lambda q: "ok")

    persisted = load_chain(next(tmp_path.glob("*.jsonl")))
    assert persisted == h.abom.chain

    result = verify_abom(m, persisted, {"allowed_models": [MODEL]})
    assert result["ok"] and result["actions"] == 2


def test_raise_error_contract_is_set():
    # LangChain only propagates handler exceptions when raise_error is True —
    # without this attribute the gate would be advisory, not enforcing.
    assert AbomLangChainHandler.raise_error is True
