#!/usr/bin/env python3
"""
test_release_gate.py: the first test in your release gate. Copy this file next
to your graph with audit.py and check_owners.py, change the lines marked YOURS,
and run it in CI.

    python3 test_release_gate.py      # no pytest needed; exits 1 on any failure
    pytest test_release_gate.py       # or under pytest (pip install pytest)

As shipped it tests the demo's safe release gate, so you can watch it pass,
and it runs two negative controls that prove each check can fail: a trap graph
built inside this file (a last-write-wins reducer on `decision`) must FAIL both
checks. A check that cannot fail is no check at all. The controls need nothing
from your graph or from the demo.

Companion to "The Graph Looks Right. The Merge Is Where It Breaks."
https://durabilitycurve.com/tools/agent-graph/
"""
import sys
import warnings

# Two harmless import notices, silenced: urllib3's NotOpenSSLWarning on macOS system
# Python, and a langchain-core notice. langchain-core resets its own warning filters
# when it is imported, so it is imported first and quietened after.
warnings.filterwarnings("ignore", module="urllib3")
try:
    import langchain_core  # noqa: F401,E402
except ImportError:
    pass
warnings.filterwarnings("ignore", message=".*allowed_objects.*")

from typing import Annotated, TypedDict                              # noqa: E402

from langgraph.graph import StateGraph, START, END                   # noqa: E402

from audit import assert_schema_gathers                              # noqa: E402
from check_owners import assert_single_writer                        # noqa: E402

# --- YOURS: five lines. Your State schema and a factory for your compiled graph,
#     its channels, and the input of one run. -----------------------------------
from agent_graph_demo import State, build_safe_graph as build_graph  # noqa: E402  YOURS
GATHERS = {"findings"}      # YOURS: channels only ever read in aggregate
DECISIONS = ["decision"]    # YOURS: channels a reader branches on or acts on as one answer
RUN = {"findings": []}      # YOURS: the input of one run
CONFIG = {"configurable": {"thread_id": "release-gate-test"}}   # YOURS: the run's config
# RUN may also be a list of inputs run in turn on CONFIG. A graph that pauses at
# interrupt() and has a checkpointer can be resumed that way:
#     from langgraph.types import Command
#     RUN = [{"findings": []}, Command(resume=False)]
# Without a checkpointer, keep RUN a single input.


def test_schema_has_no_undeclared_merge():
    # Check one, the schema: every reducer channel is a declared gather.
    assert_schema_gathers(State, gather=GATHERS)


def test_every_decision_has_one_owner():
    # Check two, the run: each decision channel is written by one node, once per step.
    assert_single_writer(build_graph(), RUN, DECISIONS, config=CONFIG)


# --- negative controls: a trap built here must fail both checks ----------------
class _TrapState(TypedDict):
    decision: Annotated[str, lambda old, new: new]     # last-write-wins on a decision


def _build_trap():
    g = StateGraph(_TrapState)
    g.add_node("orchestrator", lambda s: {})
    for node, verdict in (("tests", "hold"), ("metrics", "ship it"), ("changelog", "roll back")):
        g.add_node(node, (lambda v=verdict: (lambda s: {"decision": v}))())
        g.add_edge("orchestrator", node)
        g.add_edge(node, END)
    g.add_edge(START, "orchestrator")
    return g.compile()


def test_control_schema_check_catches_the_trap():
    try:
        assert_schema_gathers(_TrapState, gather=set())
    except AssertionError:
        return
    raise AssertionError("the schema check passed the trap; it cannot fail, so it proves nothing")


def test_control_owner_check_catches_the_trap():
    try:
        assert_single_writer(_build_trap(), {"decision": ""}, ["decision"])
    except AssertionError:
        return
    raise AssertionError("the owner check passed the trap; it cannot fail, so it proves nothing")


if __name__ == "__main__":
    tests = [(n, f) for n, f in list(globals().items()) if n.startswith("test_") and callable(f)]
    failed = 0
    for name, fn in tests:
        try:
            fn()
            print(f"PASS  {name}")
        except AssertionError as e:
            failed += 1
            print(f"FAIL  {name}: {e}")
    print(f"\n{'FAIL' if failed else 'PASS'}: {len(tests) - failed} of {len(tests)} tests passed")
    sys.exit(1 if failed else 0)
