"""Offline agent-harness TDD lab. Python 3.11+; python -m pytest agent_tdd.py -q."""
from dataclasses import dataclass
from typing import Callable
import pytest

@dataclass(frozen=True)
class Decision:
    action: str
    order_id: str = ""

class ScriptedModel:
    def __init__(self, *decisions: Decision):
        self.decisions = iter(decisions)
        self.calls = 0

    def __call__(self, observation: str) -> Decision:
        self.calls += 1
        return next(self.decisions)

class RefundLedger:
    """In-memory test double; production needs an atomic unique idempotency key."""
    def __init__(self):
        self.writes = []
        self.results = {}

    def refund(self, tenant: str, order_id: str, request_id: str) -> str:
        key = (tenant, request_id)
        if key in self.results:
            old_order, result = self.results[key]
            if old_order != order_id:
                raise ValueError("idempotency conflict")
            return result
        result = "refunded"
        self.writes.append((tenant, order_id))
        self.results[key] = (order_id, result)
        return result

def run(model: Callable[[str], Decision], ledger: RefundLedger, *,
        tenant: str, allowed_orders: set[str], approved: bool,
        request_id: str, max_steps: int = 3) -> str:
    observation = "start"
    for _ in range(max_steps):
        decision = model(observation)
        if decision.action == "finish":
            return observation
        if decision.action != "refund":
            return "invalid_action"
        if decision.order_id not in allowed_orders:
            return "forbidden"
        if not approved:
            return "approval_required"
        try:
            observation = ledger.refund(tenant, decision.order_id, request_id)
        except TimeoutError:
            observation = "timeout"  # bounded by max_steps; retry uses same key
    return "step_limit"

def execute(model, ledger, **overrides):
    args = dict(tenant="acme", allowed_orders={"A1"}, approved=True,
                request_id="request-7", max_steps=3)
    args.update(overrides)
    return run(model, ledger, **args)

def test_requires_approval_before_side_effect():
    ledger = RefundLedger()
    result = execute(ScriptedModel(Decision("refund", "A1"), Decision("finish")),
                     ledger, approved=False)
    assert result == "approval_required"
    assert ledger.writes == []

def test_blocks_wrong_tenant_order():
    ledger = RefundLedger()
    assert execute(ScriptedModel(Decision("refund", "B9")), ledger) == "forbidden"
    assert ledger.writes == []

def test_success_records_one_effect():
    ledger = RefundLedger()
    model = ScriptedModel(Decision("refund", "A1"), Decision("finish"))
    assert execute(model, ledger) == "refunded"
    assert ledger.writes == [("acme", "A1")]
    assert model.calls == 2

def test_repeat_delivery_is_idempotent():
    ledger = RefundLedger()
    for _ in range(2):
        execute(ScriptedModel(Decision("refund", "A1"), Decision("finish")), ledger)
    assert ledger.writes == [("acme", "A1")]

def test_timeout_after_commit_does_not_duplicate_refund():
    class LostReplyLedger(RefundLedger):
        first = True
        def refund(self, *args):
            result = super().refund(*args)
            if self.first:
                self.first = False
                raise TimeoutError("response lost after commit")
            return result
    ledger = LostReplyLedger()
    model = ScriptedModel(Decision("refund", "A1"), Decision("refund", "A1"),
                          Decision("finish"))
    assert execute(model, ledger) == "refunded"
    assert len(ledger.writes) == 1

def test_loop_stops_at_budget():
    ledger = RefundLedger()
    model = ScriptedModel(*(Decision("refund", "A1") for _ in range(3)))
    assert execute(model, ledger) == "step_limit"
    assert model.calls == 3
    assert len(ledger.writes) == 1

def test_unknown_action_fails_closed():
    ledger = RefundLedger()
    assert execute(ScriptedModel(Decision("delete_all")), ledger) == "invalid_action"
    assert ledger.writes == []

def test_conflicting_idempotency_key_is_rejected():
    ledger = RefundLedger()
    ledger.refund("acme", "A1", "request-7")
    with pytest.raises(ValueError, match="idempotency conflict"):
        ledger.refund("acme", "A2", "request-7")
