#!/usr/bin/env python3
"""refund-agent.py: an agent that changes data, but only behind your yes.

A customer writes about an order. The agent looks the order up with a read
tool (get_order) and may ask for a refund with a write tool (issue_refund).
The write never runs on the model's word: your code checks the request,
shows you the exact change and writes only if you type yes.

Companion extension to the Zoaftware Logbook article A-02,
"A chef, not a recipe: how an agent really works". Same hand-written loop as
first-agent.py, copied here so this file runs on its own.

    python3 -m venv .venv && . .venv/bin/activate
    pip install anthropic
    export ANTHROPIC_API_KEY=...      # set it in your shell, never in code
    python refund-agent.py --selftest  # offline: no key, no API call
    python refund-agent.py "Order A-1001 arrived cold. Can I get 12.50 back?"

The first real run writes a sample orders.json (fake orders) next to this file;
every approved refund changes it and adds one line to refunds.log beside it.

Python 3.10+. Only dependency: the official `anthropic` package.
"""

from __future__ import annotations

import argparse
import concurrent.futures
import json
import os
import re
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace

# Model: Claude Opus 5.5, the default the Claude API docs recommend for agentic
# work ($4 / $20 per million input / output tokens, $0.20 for cache reads, 1M
# context). Thinking is adaptive and always on: the model decides how much to
# think before each step (effort defaults to "medium"). For a cheaper agent,
# set AGENT_MODEL=claude-sonnet-5-5 ($2 / $10) or claude-haiku-5-5.
MODEL = os.environ.get("AGENT_MODEL", "claude-opus-5-5")

MAX_TURNS = 8  # closing time: the loop never runs more model calls than this
MAX_TOKENS = 16000  # per response; under the SDK's non-streaming time limit
MAX_RESULT_CHARS = 20_000  # every tool result is capped so one call can't flood the context
RUN_CAP = 50.00  # dollars: the most this run may refund in total, approvals included

DEFAULT_DATA = Path(__file__).resolve().with_name("orders.json")
SECRET_FILE = re.compile(r"^(\.env(\..*)?|.*\.(pem|key)|id_rsa.*|id_ed25519.*)$")

# Fake orders for the first run. Each one exercises a different house rule.
SAMPLE_ORDERS = {
    "A-1001": {"customer": "Ana", "total": 42.00, "refunded": 0.00, "status": "delivered"},
    "A-1002": {"customer": "Bruno", "total": 18.50, "refunded": 18.50, "status": "refunded"},
    "A-1003": {"customer": "Carla", "total": 120.00, "refunded": 0.00, "status": "delivered"},
    "A-1004": {"customer": "Diego", "total": 27.00, "refunded": 10.00, "status": "delivered"},
    "A-1005": {"customer": "Elena", "total": 9.90, "refunded": 0.00, "status": "delivered"},
}

SYSTEM = """You are the refunds assistant of a small food-delivery restaurant. A customer \
writes about an order; you look it up and, when a refund is fair, you request it.

House rules:
- Look before you act: call get_order first and use only what it returns. Never guess an \
order id or an amount.
- Request with issue_refund only what the customer asked for, never more than the order's \
refundable amount.
- A human manager approves every refund. If issue_refund returns an error, nothing was \
written: do not retry it, read the message and do what it says.
- Final answer: a short, kind reply to the customer that says what happened (refunded, \
declined, or sent to a manager)."""

# The tool cards. The model only ever sees these; your code below is what really runs.
TOOLS = [
    {
        "name": "get_order",
        "description": (
            "Look up one order by its id, e.g. 'A-1001'. Returns the customer's first name, "
            "the total, what was already refunded, what can still be refunded ('refundable') "
            "and the status, in dollars. Read-only: it changes nothing."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string", "description": "The order id the customer gave, e.g. 'A-1001'."},
            },
            "required": ["order_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "issue_refund",
        "description": (
            "Request a refund on one order. This tool WRITES: it changes the order book. "
            "It never runs on its own: the code checks the request, shows it to a human "
            "manager and writes only if they type yes. If it returns an error, nothing was "
            "written; do not retry, do what the error says."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string", "description": "The order to refund, e.g. 'A-1001'."},
                "amount": {"type": "number", "description": "Dollars to refund, above 0, at most 2 decimals, e.g. 12.5."},
                "reason": {"type": "string", "description": "A few words for the audit log, e.g. 'cold food'."},
            },
            "required": ["order_id", "amount", "reason"],
            "additionalProperties": False,
        },
    },
]

WRITE_TOOLS = {"issue_refund"}  # these never run without a human yes
DECLINED = "A human declined this refund. Do not retry it; tell the customer a manager will review the request."


class ToolError(Exception):
    """A failure the model can recover from. The message says what to do instead."""


class AgentStopped(RuntimeError):
    """The loop ended without a final answer (turn cap, cut-off turn, refusal)."""


def cap(text: str, limit: int = MAX_RESULT_CHARS) -> str:
    if len(text) <= limit:
        return text
    return text[:limit] + f"\n... [truncated: {len(text) - limit} more characters.]"


def cents(dollars: float) -> int:
    return round(dollars * 100)  # money is compared in whole cents, never as floats


def money(c: int) -> str:
    return f"${c / 100:.2f}"


def write_json_atomic(path: Path, data) -> None:
    """Write to a temp file in the same folder, then rename it over the old one.
    A crash mid-write leaves the old file whole; nobody ever reads half a file."""
    fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.stem}-", suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
            f.write("\n")
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, path)  # atomic on the same filesystem
    except BaseException:
        Path(tmp).unlink(missing_ok=True)
        raise


def is_order(o) -> bool:
    def number(x):
        return isinstance(x, (int, float)) and not isinstance(x, bool) and x >= 0

    return (
        isinstance(o, dict)
        and isinstance(o.get("customer"), str)
        and number(o.get("total"))
        and number(o.get("refunded"))
        and isinstance(o.get("status"), str)
    )


class Orders:
    """The restaurant's order book: one JSON file, read fresh on every call."""

    def __init__(self, path: str | Path = DEFAULT_DATA, run_cap: float = RUN_CAP):
        self.path = Path(path).resolve()
        if SECRET_FILE.match(self.path.name):
            raise ValueError(f"'{self.path.name}' looks like a secrets file; this agent never reads secrets")
        if not self.path.exists():
            write_json_atomic(self.path, SAMPLE_ORDERS)  # first run: fake orders to play with
        self.log_path = self.path.with_name("refunds.log")
        self.cap_cents = cents(run_cap)
        self.approved_cents = 0  # refunds approved in this run, for the per-run cap
        try:
            self.load()
        except ToolError as e:
            raise ValueError(f"{self.path}: {e}") from None

    def load(self) -> dict:
        try:
            data = json.loads(self.path.read_text(encoding="utf-8"))
        except (OSError, ValueError) as e:
            raise ToolError(f"The order book could not be read ({e}). Tell the customer a person will look into it.")
        if not isinstance(data, dict) or not all(is_order(o) for o in data.values()):
            raise ToolError("The order book is not in the expected shape. Tell the customer a person will look into it.")
        return data

    def find(self, order_id) -> tuple[dict, dict]:
        if not isinstance(order_id, str) or not order_id.strip():
            raise ToolError("'order_id' must be a non-empty string, e.g. 'A-1001'.")
        orders = self.load()
        if order_id not in orders:
            raise ToolError(f"There is no order {order_id!r}. Ask the customer to check the order number; do not guess one.")
        return orders, orders[order_id]

    def get_order(self, order_id: str) -> str:
        _, o = self.find(order_id)
        refundable = cents(o["total"]) - cents(o["refunded"])
        return json.dumps({"order_id": order_id, **o, "refundable": refundable / 100})

    def check_refund(self, order_id: str, amount: float, reason: str) -> str:
        """Every house rule, checked in code. Raises ToolError; returns the exact change."""
        _, o = self.find(order_id)
        if isinstance(amount, bool) or not isinstance(amount, (int, float)) or amount <= 0:
            raise ToolError("'amount' must be a number above 0, in dollars, e.g. 12.5.")
        if abs(amount * 100 - cents(amount)) > 1e-6:
            raise ToolError("'amount' can have at most 2 decimals, e.g. 12.5 or 12.55.")
        if not isinstance(reason, str) or not reason.strip():
            raise ToolError("'reason' is required: a few words for the audit log, e.g. 'cold food'.")
        total, done, want = cents(o["total"]), cents(o["refunded"]), cents(amount)
        left = total - done
        if left <= 0:
            raise ToolError(
                f"Order {order_id} was already refunded in full ({money(done)}). "
                "Do not refund it again; tell the customer the refund was already issued."
            )
        if want > left:
            raise ToolError(
                f"{money(want)} is more than order {order_id} can take back: the most is {money(left)} "
                f"(total {money(total)}, already refunded {money(done)}). Do not change the amount "
                f"yourself; tell the customer the most that can be refunded is {money(left)}."
            )
        if self.approved_cents + want > self.cap_cents:
            raise ToolError(
                f"{money(want)} is above what this run may still refund under its cap of "
                f"{money(self.cap_cents)}. Do not retry it; tell the customer a manager will review the request."
            )
        return (
            f"Refund {money(want)} on {order_id} ({o['customer']}): refunded {money(done)} -> "
            f"{money(done + want)} of {money(total)}. Reason: {reason.strip()}."
        )

    def issue_refund(self, order_id: str, amount: float, reason: str, turn: int = 0) -> str:
        change = self.check_refund(order_id, amount, reason)  # again: the file may have changed while you read
        orders, o = self.find(order_id)
        new = cents(o["refunded"]) + cents(amount)
        o["refunded"] = new / 100
        if new >= cents(o["total"]):
            o["status"] = "refunded"
        write_json_atomic(self.path, orders)
        note = " ".join(reason.split()).replace("|", "/")[:200]
        stamp = datetime.now().astimezone().isoformat(timespec="seconds")
        with open(self.log_path, "a", encoding="utf-8") as f:
            f.write(f"{stamp} | {order_id} | {cents(amount) / 100:.2f} | {note} | approved by a human at turn {turn}\n")
        self.approved_cents += cents(amount)
        return f"Done. {change} Logged in {self.log_path.name}. Tell the customer it is refunded."


def run_tool(orders: Orders, name: str, args) -> tuple[str, bool]:
    """Run one READ tool call and return (content, is_error). Never raises."""
    if name in WRITE_TOOLS:
        return f"{name} only runs through the approval gate.", True
    handlers = {"get_order": orders.get_order}
    if name not in handlers:
        return f"Unknown tool '{name}'. Available tools: get_order, issue_refund.", True
    if not isinstance(args, dict):
        return f"Arguments for {name} must be a JSON object.", True
    try:
        return cap(handlers[name](**args)), False
    except ToolError as e:
        return str(e), True
    except TypeError as e:
        return f"Bad arguments for {name}: {e}. Check the tool's input_schema.", True


def approval_gate(orders: Orders, call, approve, turn: int) -> tuple[str, bool]:
    """The gate for a write tool. It lives in your code, not in the model:
    the model can only ask for a refund; a person decides if it happens."""
    try:
        change = orders.check_refund(**call.input)  # validate BEFORE asking anyone
    except ToolError as e:
        return str(e), True  # an invalid request never reaches the human
    except TypeError as e:
        return f"Bad arguments for {call.name}: {e}. Check the tool's input_schema.", True
    prompt = f"\nturn {turn} · {call.name} {json.dumps(call.input)}\n  {change}\nAPPROVE? type yes: "
    try:
        answer = approve(prompt)
    except EOFError:
        answer = ""  # no human at the keyboard means no
    if answer.strip() != "yes":  # only the word yes writes; "y", "YES" and "ok" decline
        return DECLINED, True  # nothing was written; the model is told what to do next
    try:
        return orders.issue_refund(**call.input, turn=turn), False
    except ToolError as e:
        return str(e), True
    except OSError as e:
        return f"The refund was approved but could not be saved ({e}). Do not retry; tell the customer a person will finish it.", True


def run_tools(orders: Orders, calls: list, approve, turn: int) -> list[tuple[str, bool]]:
    """Reads run at once. Writes wait for a human, one at a time, in the order asked."""
    outcomes: list = [None] * len(calls)
    reads = [i for i, c in enumerate(calls) if c.name not in WRITE_TOOLS]
    with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
        for i, outcome in zip(reads, pool.map(lambda i: run_tool(orders, calls[i].name, calls[i].input), reads)):
            outcomes[i] = outcome
    for i, call in enumerate(calls):
        if call.name in WRITE_TOOLS:
            outcomes[i] = approval_gate(orders, call, approve, turn)
    return outcomes


def make_client():
    import anthropic  # imported here so --selftest and the tests need no package and no key

    return anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment


def run_agent(request, data=DEFAULT_DATA, client=None, model=MODEL, max_turns=MAX_TURNS, log=None,
              approve=input, run_cap=RUN_CAP):
    log = log or (lambda line: print(line, file=sys.stderr))
    client = client or make_client()
    orders = Orders(data, run_cap)
    messages = [{"role": "user", "content": request}]
    for turn in range(1, max_turns + 1):
        response = client.messages.create(
            model=model,
            max_tokens=MAX_TOKENS,
            system=SYSTEM,
            tools=TOOLS,
            messages=messages,
            cache_control={"type": "ephemeral"},  # each turn re-sends the history: cache it
        )
        # Append the assistant turn exactly as it came back, thinking blocks included.
        messages.append({"role": "assistant", "content": response.content})
        u = response.usage
        usage = f"in={u.input_tokens} out={u.output_tokens} cache_read={getattr(u, 'cache_read_input_tokens', 0) or 0}"
        if response.stop_reason == "tool_use":
            calls = [b for b in response.content if b.type == "tool_use"]
            outcomes = run_tools(orders, calls, approve, turn)
            results = []
            for call, (content, is_error) in zip(calls, outcomes):
                result = {"type": "tool_result", "tool_use_id": call.id, "content": content}
                if is_error:
                    result["is_error"] = True
                results.append(result)
            messages.append({"role": "user", "content": results})  # all results, ONE message
            steps = ", ".join(f"{c.name}({json.dumps(c.input)[:60]}){' ERROR' if err else ''}" for c, (_, err) in zip(calls, outcomes))
            log(f"turn {turn} | tool_use | {usage} | {steps}")
            continue
        log(f"turn {turn} | {response.stop_reason} | {usage}")
        if response.stop_reason == "end_turn":
            return "".join(b.text for b in response.content if b.type == "text")
        if response.stop_reason == "pause_turn":
            continue  # only server tools pause; the fix is to send the turn back as-is
        # max_tokens, model_context_window_exceeded, refusal, stop_sequence:
        # never run tools from a turn that was cut off or declined.
        raise AgentStopped(f"stop_reason={response.stop_reason!r} at turn {turn}")
    raise AgentStopped(f"no final answer after max_turns={max_turns}; raise --max-turns or ask a narrower question")


def selftest() -> int:
    """Offline check, no key and no API call: the rules and the gate behave."""
    with tempfile.TemporaryDirectory() as tmp:
        orders = Orders(Path(tmp, "orders.json"))  # writes the sample book
        cases = [
            # label, tool, input, the human's answer, want an error, want the human asked
            ("get_order A-1001", "get_order", {"order_id": "A-1001"}, None, False, False),
            ("get_order A-9999 (unknown)", "get_order", {"order_id": "A-9999"}, None, True, False),
            ("refund A-1001 12.50, human: yes", "issue_refund", {"order_id": "A-1001", "amount": 12.5, "reason": "cold food"}, "yes", False, True),
            ("refund A-1005 5.00, human: no", "issue_refund", {"order_id": "A-1005", "amount": 5, "reason": "late"}, "no", True, True),
            ("refund A-1002 (already refunded)", "issue_refund", {"order_id": "A-1002", "amount": 5, "reason": "late"}, "yes", True, False),
            ("refund A-1004 20.00 (only 17 left)", "issue_refund", {"order_id": "A-1004", "amount": 20, "reason": "wrong dish"}, "yes", True, False),
            ("refund A-1003 120.00 (over cap)", "issue_refund", {"order_id": "A-1003", "amount": 120, "reason": "never came"}, "yes", True, False),
        ]
        ok = True
        for turn, (label, name, args, answer, want_error, want_asked) in enumerate(cases, 1):
            asked = []
            approve = lambda prompt, a=answer: asked.append(prompt) or a
            call = SimpleNamespace(name=name, input=args)
            content, is_error = run_tools(orders, [call], approve, turn)[0]
            passed = is_error == want_error and bool(asked) == want_asked
            ok = ok and passed
            kind = "is_error" if is_error else "result"
            print(f"{'ok ' if passed else 'BAD'} {label:<36} {kind}: {content.splitlines()[0][:70]}")
        book = orders.load()
        log_lines = orders.log_path.read_text(encoding="utf-8").splitlines()
        wrote = book["A-1001"]["refunded"] == 12.5 and book["A-1005"]["refunded"] == 0 and len(log_lines) == 1
        ok = ok and wrote
        print(f"{'ok ' if wrote else 'BAD'} {'only the approved refund was written':<36} refunds.log lines: {len(log_lines)}")
        print("selftest passed" if ok else "selftest FAILED")
        return 0 if ok else 1


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(description="A refunds agent that writes only behind a human yes.")
    parser.add_argument("request", nargs="?", help="the customer's message")
    parser.add_argument("--data", default=str(DEFAULT_DATA), help="the order book (default: orders.json next to this file)")
    parser.add_argument("--max-turns", type=int, default=MAX_TURNS)
    parser.add_argument("--model", default=MODEL)
    parser.add_argument("--cap", type=float, default=RUN_CAP, help="dollars this run may refund in total (default: 50)")
    parser.add_argument("--selftest", action="store_true", help="offline check of the rules and the gate, no API call")
    args = parser.parse_args(argv)
    if args.selftest:
        return selftest()
    if not args.request:
        parser.error("write the customer's message, e.g. \"Order A-1001 arrived cold. Can I get 12.50 back?\"")
    try:
        answer = run_agent(args.request, args.data, model=args.model, max_turns=args.max_turns, run_cap=args.cap)
    except AgentStopped as e:
        print(f"Agent stopped: {e}", file=sys.stderr)
        return 2
    except ValueError as e:
        print(f"error: {e}", file=sys.stderr)
        return 1
    print(answer)
    return 0


if __name__ == "__main__":
    sys.exit(main())
