#!/usr/bin/env python3
"""
Full protocol UAT agent driver — reusable test script.

Runs F-01 through F-12 full-protocol test cases against a running Server+Computer.
Covers: connection, office join, tool_call, get_config, get_tools, get_desktop,
list_room, version handshake, disconnect guard, leave_office, tool_call_cancel.

Usage:
    uv run python agent_protocol_driver.py \
        [--port-file /tmp/a2c-uat-port] \
        [--office-id proto-uat-office] \
        [--computer-name proto-comp-001]

Protocol: events.md / data-structures.md / error-handling.md
"""
from __future__ import annotations

import argparse
import asyncio
import sys
import threading
import uuid

import socketio
from a2c_smcp.smcp import (
    SMCP_NAMESPACE,
    TOOL_CALL_EVENT,
    GET_CONFIG_EVENT,
    GET_TOOLS_EVENT,
    GET_DESKTOP_EVENT,
    JOIN_OFFICE_EVENT,
    LEAVE_OFFICE_EVENT,
    LIST_ROOM_EVENT,
    CANCEL_TOOL_CALL_EVENT,
    ENTER_OFFICE_NOTIFICATION,
    LEAVE_OFFICE_NOTIFICATION,
)

results: dict[str, dict] = {}
_pending_notifications: dict[str, list[dict]] = {}


def log(msg: str) -> None:
    print(msg, flush=True)


def req_id() -> str:
    return uuid.uuid4().hex[:12]


def parse_args() -> argparse.Namespace:
    ap = argparse.ArgumentParser(description="Full protocol UAT agent driver")
    ap.add_argument("--port-file", default="/tmp/a2c-uat-port")
    ap.add_argument("--office-id", default="proto-uat-office")
    ap.add_argument("--computer-name", default=None,
                    help="Computer name (auto-detected via list_room if omitted)")
    return ap.parse_args()


def _check_success(case_id: str, resp, extra_checks=None) -> None:
    """Verify a success response (no error code)."""
    ok = True
    notes: list[str] = []

    if not isinstance(resp, dict):
        ok = False
        notes.append(f"FAIL: unexpected type {type(resp)}: {resp}")
    elif resp.get("code") and resp.get("code") != 0:
        ok = False
        notes.append(f"FAIL: error code={resp.get('code')} msg={resp.get('message')}")
    else:
        notes.append("PASS: no error code in response")

    if extra_checks:
        for label, check_fn in (extra_checks or []):
            try:
                detail_ok, detail_note = check_fn(resp if isinstance(resp, dict) else {})
                if detail_ok:
                    notes.append(f"PASS: {label} — {detail_note}")
                else:
                    ok = False
                    notes.append(f"FAIL: {label} — {detail_note}")
            except Exception as e:
                ok = False
                notes.append(f"FAIL: {label} — exception: {e}")

    results[case_id] = {"pass": ok, "notes": notes}
    log(f"{case_id}: {'PASS' if ok else 'FAIL'}")
    for n in notes:
        log(f"  {n}")


def _check_error(case_id: str, resp, expected_code: int, extra_checks=None) -> None:
    """Verify an error response matches the expected error code."""
    ok = True
    notes: list[str] = []

    if not isinstance(resp, dict):
        ok = False
        notes.append(f"FAIL: unexpected type {type(resp)}: {resp}")
    else:
        code = resp.get("code")
        if code != expected_code:
            ok = False
            notes.append(f"FAIL: code={code}, expected={expected_code}")
        else:
            notes.append(f"PASS: code={code}")

    if extra_checks:
        for label, check_fn in (extra_checks or []):
            try:
                detail_ok, detail_note = check_fn(resp if isinstance(resp, dict) else {})
                if detail_ok:
                    notes.append(f"PASS: {label} — {detail_note}")
                else:
                    ok = False
                    notes.append(f"FAIL: {label} — {detail_note}")
            except Exception as e:
                ok = False
                notes.append(f"FAIL: {label} — exception: {e}")

    results[case_id] = {"pass": ok, "notes": notes}
    log(f"{case_id}: {'PASS' if ok else 'FAIL'}")
    for n in notes:
        log(f"  {n}")


def run(args: argparse.Namespace) -> int:
    port = open(args.port_file).read().strip()
    base_url = f"http://127.0.0.1:{port}"
    url = f"{base_url}?a2c_version=0.2.0"
    agent_name = f"proto-agent-{uuid.uuid4().hex[:6]}"

    log("=== FULL PROTOCOL UAT AGENT ===")
    log(f"URL: {url}  Office: {args.office_id}  Agent: {agent_name}")

    client = socketio.Client()

    # ── Notification handlers ──
    @client.on("connect", namespace=SMCP_NAMESPACE)
    def on_connect():
        log("AGENT_CONNECTED")

    @client.on(ENTER_OFFICE_NOTIFICATION, namespace=SMCP_NAMESPACE)
    def on_enter_office(data):
        log(f"NOTIFY: enter_office: {data}")
        _pending_notifications.setdefault("enter_office", []).append(data)

    @client.on(LEAVE_OFFICE_NOTIFICATION, namespace=SMCP_NAMESPACE)
    def on_leave_office(data):
        log(f"NOTIFY: leave_office: {data}")
        _pending_notifications.setdefault("leave_office", []).append(data)

    client.connect(
        url,
        socketio_path="/socket.io",
        namespaces=[SMCP_NAMESPACE],
        transports=["polling"],
        wait=True,
        wait_timeout=10,
    )
    log("Connected to server")

    # ═══════════════════════════════════════════════════════════
    # F-01: Agent joins office (Computer already joined)
    # ═══════════════════════════════════════════════════════════
    log(f"\n=== F-01: Agent joins office ===")
    join_resp = client.call(
        JOIN_OFFICE_EVENT,
        {"role": "agent", "name": agent_name, "office_id": args.office_id},
        namespace=SMCP_NAMESPACE,
        timeout=10,
    )
    if isinstance(join_resp, tuple):
        join_data = join_resp[0] if join_resp else None
    else:
        join_data = join_resp
    _check_success("F-01", join_data if isinstance(join_data, dict) else {"joined": bool(join_data)}, [
        ("join accepted", lambda r: (bool(join_data), f"join_resp={join_resp}")),
    ])

    # ═══════════════════════════════════════════════════════════
    # F-10: list_room — discover computer
    # ═══════════════════════════════════════════════════════════
    log(f"\n=== F-10: list_room ===")
    room = client.call(
        LIST_ROOM_EVENT,
        {"agent": agent_name, "req_id": req_id(), "office_id": args.office_id},
        namespace=SMCP_NAMESPACE,
        timeout=10,
    )
    computer_name = args.computer_name
    if isinstance(room, dict):
        sessions = room.get("sessions", [])
        for s in sessions:
            if s.get("role") == "computer" and not computer_name:
                computer_name = s.get("name")

    _check_success("F-10", room, [
        ("sessions count", lambda r: (
            len(r.get("sessions", [])) >= 2 if isinstance(r, dict) else False,
            f"sessions={len(r.get('sessions', [])) if isinstance(r, dict) else 'N/A'}"
        )),
    ])

    if not computer_name:
        log("WARN: No computer found — some tests will be skipped")
    else:
        log(f"Computer discovered: {computer_name}")

    # Helper builders
    def _call(event: str, data: dict, timeout: int = 15):
        return client.call(event, data, namespace=SMCP_NAMESPACE, timeout=timeout)

    # ═══════════════════════════════════════════════════════════
    # F-02: tool_call (if computer available)
    # ═══════════════════════════════════════════════════════════
    if computer_name:
        log(f"\n=== F-02: tool_call ===")
        # First get tools to find a callable one
        tools_resp = _call(
            GET_TOOLS_EVENT,
            {"computer": computer_name, "agent": agent_name, "req_id": req_id()},
        )
        tool_name = None
        if isinstance(tools_resp, dict) and not tools_resp.get("code"):
            tools_list = tools_resp.get("tools", [])
            if tools_list:
                raw_name = tools_list[0].get("name", "")
                tool_name = raw_name.split("/")[-1] if "/" in raw_name else raw_name
                log(f"  Found tool: {tool_name}")

        if tool_name:
            resp = _call(
                TOOL_CALL_EVENT,
                {"computer": computer_name, "tool_name": tool_name,
                 "agent": agent_name, "req_id": "F-02",
                 "params": {}, "timeout": 15000},
                timeout=20,
            )
            _check_success("F-02", resp, [
                ("has result", lambda r: (
                    "result" in r or "content" in r or "isError" in r or
                    (isinstance(r, dict) and not r.get("code")),
                    f"response keys={list(r.keys()) if isinstance(r, dict) else 'N/A'}"
                )),
            ])
        else:
            results["F-02"] = {"pass": None, "notes": ["SKIP: no tools available on computer"]}
            log("F-02: SKIPPED (no tools available)")
    else:
        results["F-02"] = {"pass": None, "notes": ["SKIP: no computer"]}
        log("F-02: SKIPPED (no computer)")

    # ═══════════════════════════════════════════════════════════
    # F-04: Version handshake (already connected with correct version)
    # ═══════════════════════════════════════════════════════════
    log(f"\n=== F-04: version handshake (implicit via connection) ===")
    # Connection succeeded = version compatible
    _check_success("F-04", {"connected": True}, [
        ("connection alive", lambda r: (client.connected, f"connected={client.connected}")),
        ("has sessions", lambda r: (
            isinstance(room, dict) and len(room.get("sessions", [])) >= 2,
            f"sessions from F-10"
        )),
    ])

    # ═══════════════════════════════════════════════════════════
    # F-05: Version incompatibility rejection (separate client)
    # ═══════════════════════════════════════════════════════════
    log(f"\n=== F-05: version incompatibility (a2c_version=99.0.0) ===")
    bad_client = socketio.Client()
    connect_error_data = {}

    @bad_client.on("connect_error", namespace=SMCP_NAMESPACE)
    def on_connect_error(data):
        connect_error_data["data"] = data
        log(f"  CONNECT_ERROR: {data}")

    try:
        bad_client.connect(
            f"{base_url}?a2c_version=99.0.0",
            socketio_path="/socket.io",
            namespaces=[SMCP_NAMESPACE],
            transports=["polling"],
            wait=True,
            wait_timeout=10,
        )
        # If connect succeeded, version check didn't reject
        _check_error("F-05", {"code": 0}, 4008)
        bad_client.disconnect()
    except Exception as e:
        # Connection rejected — expected
        error_info = connect_error_data.get("data", str(e))
        if isinstance(error_info, dict):
            _check_error("F-05", error_info, 4008, [
                ("version fields", lambda r: (
                    any(k in str(r) for k in ("server_version", "client_version")),
                    f"error payload: {r}"
                )),
            ])
        else:
            # Transport-level rejection (HTTP 400)
            results["F-05"] = {"pass": True, "notes": [
                f"PASS: connection rejected with version 99.0.0",
                f"  Error: {e}",
            ]}
            log("F-05: PASS (connection rejected)")

    # ═══════════════════════════════════════════════════════════
    # F-07: get_config
    # ═══════════════════════════════════════════════════════════
    if computer_name:
        log(f"\n=== F-07: get_config ===")
        resp = _call(
            GET_CONFIG_EVENT,
            {"computer": computer_name, "agent": agent_name, "req_id": "F-07"},
        )
        _check_success("F-07", resp, [
            ("servers field", lambda r: (
                "servers" in r and isinstance(r.get("servers"), dict),
                f"servers keys={list(r.get('servers', {}).keys()) if isinstance(r.get('servers'), dict) else 'N/A'}"
            )),
        ])
    else:
        results["F-07"] = {"pass": None, "notes": ["SKIP: no computer"]}
        log("F-07: SKIPPED")

    # ═══════════════════════════════════════════════════════════
    # F-08: get_tools
    # ═══════════════════════════════════════════════════════════
    if computer_name:
        log(f"\n=== F-08: get_tools ===")
        resp = _call(
            GET_TOOLS_EVENT,
            {"computer": computer_name, "agent": agent_name, "req_id": "F-08"},
        )
        _check_success("F-08", resp, [
            ("tools list", lambda r: (
                isinstance(r.get("tools"), list),
                f"tools count={len(r.get('tools', [])) if isinstance(r.get('tools'), list) else 'N/A'}"
            )),
        ])
    else:
        results["F-08"] = {"pass": None, "notes": ["SKIP: no computer"]}
        log("F-08: SKIPPED")

    # ═══════════════════════════════════════════════════════════
    # F-09: get_desktop
    # ═══════════════════════════════════════════════════════════
    if computer_name:
        log(f"\n=== F-09: get_desktop ===")
        resp = _call(
            GET_DESKTOP_EVENT,
            {"computer": computer_name, "agent": agent_name, "req_id": "F-09"},
        )
        # May or may not have desktop data depending on MCP server
        if isinstance(resp, dict) and resp.get("code"):
            # Error is acceptable if Computer has no window resources
            results["F-09"] = {"pass": None, "notes": [
                f"SKIP: no desktop data (code={resp.get('code')})"
            ]}
            log("F-09: SKIPPED (no desktop data)")
        else:
            _check_success("F-09", resp, [
                ("desktops list", lambda r: (
                    "desktops" in r or "desktop" in r or isinstance(r, dict),
                    f"has desktop data"
                )),
            ])
    else:
        results["F-09"] = {"pass": None, "notes": ["SKIP: no computer"]}
        log("F-09: SKIPPED")

    # ═══════════════════════════════════════════════════════════
    # F-11: leave_office
    # ═══════════════════════════════════════════════════════════
    log(f"\n=== F-11: leave_office ===")
    leave_resp = _call(
        LEAVE_OFFICE_EVENT,
        {"office_id": args.office_id, "agent": agent_name, "req_id": "F-11"},
    )
    _check_success("F-11", leave_resp if isinstance(leave_resp, dict) else {"left": bool(leave_resp)}, [
        ("leave accepted", lambda r: (bool(leave_resp), f"leave_resp={leave_resp}")),
    ])

    # ═══════════════════════════════════════════════════════════
    # F-03, F-06, F-12: These require specific triggers from
    # Computer side or runtime events — logged as needs-manual
    # ═══════════════════════════════════════════════════════════
    for case_id in ("F-03", "F-06", "F-12"):
        results[case_id] = {"pass": None, "notes": [
            "SKIP: requires Computer-side trigger or manual intervention"
        ]}
        log(f"{case_id}: SKIPPED (requires Computer-side trigger)")

    # ── Summary ──
    log("\n" + "=" * 50)
    log("FULL PROTOCOL UAT SUMMARY")
    passed = failed = skipped = 0
    for cid, r in results.items():
        if r["pass"] is None:
            skipped += 1
            log(f"  {cid}: SKIPPED")
        elif r["pass"]:
            passed += 1
            log(f"  {cid}: PASS")
        else:
            failed += 1
            log(f"  {cid}: FAIL")
            for n in r["notes"]:
                if "FAIL" in n:
                    log(f"    {n}")

    log(f"\nTotal: {passed + failed + skipped}  "
        f"Passed: {passed}  Failed: {failed}  Skipped: {skipped}")
    log("=== FULL PROTOCOL UAT COMPLETE ===")

    client.disconnect()
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(run(parse_args()))
