"""
A governed LangChain agent — the flight-booking case, end to end.

The developer pages name LangGraph, the OpenAI Agents SDK, and CrewAI with runnable
examples beside them. LangChain itself — the framework whose name comes up first in
almost every search — had none, despite the /developers/python page's own "Step 2"
prose saying the tool-picking framework does not matter. Naming a framework's cousin
(LangGraph) is not the same integration: LangChain 1.x removed `AgentExecutor` and
`create_tool_calling_agent` from `langchain.agents` entirely — `create_agent` (built on
LangGraph internally) is the only agent constructor exported today. A LangChain
developer copying a `create_tool_calling_agent` snippet from an older tutorial gets an
`ImportError`, not a running agent, which makes this file's shape a genuine, current fact
about the framework rather than a stylistic choice.

The point is the same one the LangGraph file makes: governance is NOT a step the model
chooses to take. A step the model chooses is a step the model can be talked out of
choosing, which is exactly the property that makes a prompt useless as a control. It
wraps the tool itself, so the only path to the real function goes through the gate,
regardless of which tool `create_agent`'s loop decides to call:

    ┌───────┐      ┌────────────────┐      ┌──────────────────┐      ┌─────────────┐
    │ model │ ───▶ │ create_agent's │ ───▶ │ guarded callable │ ───▶ │ your airline│
    └───────┘      │  tool-call node│      └────────┬─────────┘      └─────────────┘
                   └────────────────┘              │ asks the gate first;
                                                    │ a refusal raises and the
                                                    ▼ real function is never called
                                             MetaMynd authorize

Requires: langchain, langchain-core, cryptography. An LLM is needed only to actually run
the agent's own reasoning — the demo below uses `GenericFakeChatModel` precisely so it
needs no API key to prove the wiring; swap it for `init_chat_model("openai:gpt-4o-mini")`
(or any `BaseChatModel`) to let a real model pick the arguments.

    pip install langchain langchain-core cryptography
    export METAMYND_API=https://metamynd.ai/api/v1
    export AGENT_DID=did:hedera:testnet:...
    export AGENT_KEY=302e020100...            # as issued
    python langchain_agent.py

Provision an agent in about four seconds, no account:

    npx create-metamynd-agent --sandbox

Verified against langchain 1.4.1 / langchain-core 1.6.1 — the current API surface, not
the pre-1.0 `AgentExecutor` one most tutorials still show.
"""

from __future__ import annotations

import os
from typing import Any

from metamynd_client import GovernanceBlocked, MetaMyndClient, guard_tool

# ---------------------------------------------------------------------------------------
# 1. Your tools, exactly as you already have them. Nothing governance-aware here.
# ---------------------------------------------------------------------------------------


def search_flights(route: str) -> list[dict[str, Any]]:
    """Search available flights for a route. Read-only, and deliberately ungoverned —
    see the note at the bottom."""
    return [
        {"airline": "skyward-air", "route": route, "amount": 420.0},
        {"airline": "northwind-rail", "route": route, "amount": 180.0},
    ]


def book_flight(airline: str, amount: float, route: str) -> dict[str, Any]:
    """Book a flight and charge the corporate card. Replace the body with your real call."""
    return {"pnr": "QK7T2M", "airline": airline, "amount": amount, "route": route}


# ---------------------------------------------------------------------------------------
# 2. Wrap the consequential one, THEN build the LangChain tool. This is the whole
# integration.
#
# `langchain_core.tools.tool()` builds the tool's Pydantic args schema from
# inspect.signature() and the function's type hints — the same mechanism the OpenAI
# Agents SDK and CrewAI examples warn about. guard_tool's functools.wraps is what keeps
# this from breaking; verified directly against this langchain-core version before
# writing this file:
#
#     governed = guard_tool(client, "flight-purchase", book_flight, ...)
#     tool(governed).args_schema.model_fields.keys()   # -> {'airline', 'amount', 'route'}
#     # identical to tool(book_flight) itself
#
# So: guard on the INSIDE, because the gate must be inside the callable LangChain
# invokes; `tool()` on the OUTSIDE, because it describes the tool to the model.
# ---------------------------------------------------------------------------------------

# Built only when the credentials are present, so importing this file never fails: `main()` says what is
# missing instead of dying at import with a stack trace.
client = MetaMyndClient.from_env() if os.getenv("AGENT_DID") and os.getenv("AGENT_KEY") else None  # type: ignore[assignment]

governed_book_flight = guard_tool(
    client,
    "flight-purchase",  # = your mandate scope
    book_flight,
    # Map the tool's arguments onto what the gate decides against. Get this wrong and the
    # gate authorizes an amount of zero against an empty merchant, which passes a cap and
    # an allow-list both — so the mapping is part of the control, not plumbing around it.
    lambda airline, amount, route: {
        "amount": amount,
        "merchant": airline,
        # `riskLevel` is REQUIRED by the starter rules: a call that carries none is escalated, not allowed (a call
        # that hides its risk is indistinguishable from one lying about it). 'low' here is a PLACEHOLDER, not an
        # assessment - a real integration states an honest one, or lets the mandate's owner set a `riskTier`
        # the agent cannot lower.
        "context": {"tool": "flight-purchase", "route": route, "riskLevel": "low"},
    },
)


def build_agent() -> Any:
    """An ordinary `create_agent` graph. The governance is already done.

    `GenericFakeChatModel` stands in for a real chat model so this function — and the
    whole file — needs no API key to prove the wiring. It never has to actually decide
    anything for the point to hold: whichever tool a real model picks, the governed
    callable is the only path to `book_flight`, so the gate runs regardless of what
    reasoning produced the call.
    """
    from langchain.agents import create_agent
    from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
    from langchain_core.tools import tool

    tools = [tool(search_flights), tool(governed_book_flight)]
    fake_model = GenericFakeChatModel(messages=iter([]))
    return create_agent(model=fake_model, tools=tools)


# ---------------------------------------------------------------------------------------
# 3. What it does when you run it
#
# Four refusal shapes, because "it blocks things" is not the interesting claim. Each of
# these fails for a structurally different reason, and only the second is a spend limit.
# The governed tool is called directly here rather than through the agent's own loop —
# see the note at the end for why, and what changes when a real model chooses the
# arguments.
# ---------------------------------------------------------------------------------------


def attempt(label: str, call: Any) -> None:
    """Run one case and report the outcome without letting a refusal end the program."""
    print(f"  {label}")
    try:
        result = call()
        print(f"     -> booked {result['pnr']}\n")
    except GovernanceBlocked as e:
        print(f"     -> {e.verdict.decision}/{e.verdict.reason_code} — book_flight was never called\n")


def main() -> None:
    if not os.getenv("AGENT_DID") or not os.getenv("AGENT_KEY"):
        raise SystemExit("Set AGENT_DID and AGENT_KEY — run `npx create-metamynd-agent --sandbox` for a free pair.")

    print("\n  A governed LangChain agent\n")

    # Inside the mandate: named airline, under the cap. The tool runs.
    attempt(
        "1. $420 on skyward-air — within the mandate",
        lambda: governed_book_flight(airline="skyward-air", amount=420, route="KUL-SIN"),
    )

    # Over the per-transaction cap. The airline call never happens.
    attempt(
        "2. $2,400 on skyward-air — over the per-transaction cap",
        lambda: governed_book_flight(airline="skyward-air", amount=2_400, route="KUL-LHR"),
    )

    # Under the cap, but the merchant was never named. A cap alone would have allowed this.
    attempt(
        "3. $95 on a merchant the mandate never listed",
        lambda: governed_book_flight(airline="unlisted-charter", amount=95, route="KUL-PEN"),
    )

    # The one a prompt could not have stopped.
    attempt(
        "4. the agent asks to raise its own limit",
        lambda: guard_tool(client, "permissions.update", lambda **_: {"pnr": "n/a"})(limit=100_000),
    )
    print("     (nothing forbids that action; the mandate simply never granted it)\n")

    # And the LangChain tool + agent graph, to show the wiring is unchanged and the
    # schema is preserved.
    try:
        from langchain_core.tools import tool as lc_tool

        book_tool = lc_tool(governed_book_flight)
        params = sorted(book_tool.args_schema.model_fields.keys())
        print(f"  the LangChain tool takes: book_flight({', '.join(params)})")
        print("  identical to the unguarded function, which is the point\n")

        agent = build_agent()
        print(f"  and it compiles into a real create_agent graph: {type(agent).__name__}\n")
    except ImportError:
        print("  (pip install langchain to build the agent; the guarding above is the integration)\n")


# ---------------------------------------------------------------------------------------
# A note on why this calls the tool directly instead of agent.invoke(...)
#
# A real agent run needs an LLM key and network time neither of which this file should
# require to demonstrate governance — `build_agent()` above is a real, compilable
# `create_agent` graph, and `agent.invoke({"messages": [...]})` runs it exactly as shown
# once you swap `GenericFakeChatModel` for a real one. What changes when you do: the
# MODEL, not this file, chooses the airline/amount/route arguments from the
# conversation and its own reasoning. The gate call inside governed_book_flight does not
# change at all — it does not know or care whether a human, a script, `create_agent`, a
# hand-rolled `model.bind_tools(tools)` loop, or LangGraph directly (see
# langgraph_agent.py) produced the arguments it is asked to authorize. That is the
# property this example exists to demonstrate: governance sits at the tool boundary, not
# in the agent's reasoning framework, so it holds regardless of which LangChain API
# surface — old or new — is doing the reasoning.
#
# A note on middleware, because you will reach for it
#
# `create_agent` accepts `middleware=[...]`, and LangChain ships a
# `HumanInTheLoopMiddleware` for pausing before a tool call. Genuinely useful, and not
# this. Middleware runs inside YOUR process, on YOUR say-so, with no signature, no
# mandate, and no owner-issued cap behind it — anyone with codebase access can remove or
# bypass it. The gate decides an action signed by the agent's own key against a mandate
# its owner issued, verified independently of whatever LangChain code is running that
# request. Cases 3 and 4 above were refused with no rule written against an unlisted
# charter or a self-granted limit; middleware you write yourself would need to
# anticipate both to catch them, and the gate needed neither because the mandate simply
# never granted them.
#
# A note on what is NOT governed here
#
# search_flights is unwrapped on purpose. Governing every call would add a network round
# trip to a read that cannot spend money or change anything, and a control that makes the
# ordinary path slow is a control teams remove. Wrap what is consequential.
#
# The corollary is worth stating plainly, because nothing will warn you: an unwrapped tool
# is an ungoverned tool. The gate can only refuse calls that reach it. Deciding which of
# your tools are consequential is a design step, not a configuration one — and
# `npx @metamynd/agentsafe-guard verify` will tell you what your mandate actually
# constrains, including the controls it does not set.
# ---------------------------------------------------------------------------------------

if __name__ == "__main__":
    main()
