"""Sentinel MCP Server — Expose Sentinel prediction market API as MCP tools.

On first run, auto-generates an Ed25519 keypair and stores it in
~/.sentinel-agent/identity.json. Agents can self-register on the platform
using register_agent (no operator involvement needed at registration time).
After self-registration, the agent waits for operator approval before trading.

Tools:
    register_agent       — Self-register on Sentinel (no operator needed at registration)
    whoami               — Show agent identity and registration status
    set_agent_id         — Save an operator-assigned agent ID (operator-registration path)
    get_agent_status     — Check this agent's current lifecycle status
    wait_for_approval    — Check whether this agent has been approved and is ready to trade
    list_markets         — List prediction markets (optional status filter, paginated)
    get_market           — Get full details of a specific market
    get_market_trades    — Get recent trades on a market
    get_price_history    — Get historical YES/NO prices for a market
    place_trade          — Buy or sell YES/NO shares (runs 11-check policy gate)
    sell_yes_shares      — Sell YES shares to exit or reduce a YES position
    sell_no_shares       — Sell NO shares to exit or reduce a NO position
    get_position         — Check your position in a specific market
    get_all_positions    — List all your positions across every market
    get_kill_switch_status — Check whether the global trading halt is active
    my_status            — Check your active capabilities and trading limits
    get_my_stats         — Check your trading performance statistics


Usage:
    # Via MCP CLI (development)
    mcp dev src/sentinel_mcp/server.py

    # Via Claude Desktop (production)
    Add to claude_desktop_config.json mcpServers section.
"""

import asyncio
import atexit
import json
import logging
import os
import uuid

import httpx
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP

from sentinel_mcp.client import SentinelClient
from sentinel_mcp.identity import load_or_create_identity, save_agent_id

load_dotenv()

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# --- Initialize MCP server and Sentinel client ---

mcp = FastMCP(
    "sentinel-agent",
    instructions=(
        "You are a Sentinel prediction market trading agent. "
        "Your sole purpose is to research markets, form probability estimates, "
        "and trade YES/NO shares in prediction markets. "
        "\n\n"
        "STARTUP SEQUENCE (first use only):\n"
        "OPTION A — Self-registration (no operator needed at registration time):\n"
        "1. Call register_agent with your name, description, purpose, and risk_tier.\n"
        "   Your Ed25519 public key is taken from the local identity automatically.\n"
        "   The agent_id is saved locally on success — you do not need set_agent_id.\n"
        "2. Call wait_for_approval — it will tell you when an operator has approved you.\n"
        "   Call this at most once per 60 seconds. Do not attempt trades while PENDING.\n"
        "3. Once approved: call my_status to confirm TRADE capabilities are issued.\n"
        "OPTION B — Operator-registered (operator creates the agent in the console):\n"
        "1. Call whoami — if unregistered, give the public key to the operator.\n"
        "2. Once the operator assigns you an agent ID, call set_agent_id.\n"
        "3. Call my_status to confirm TRADE capabilities are issued and not expired.\n"
        "   NOTE: my_status cannot confirm ACTIVE/SUSPENDED/REVOKED state — that is "
        "only surfaced via an AGENT_NOT_ACTIVE denial when you attempt a trade.\n"
        "\n"
        "PRE-TRADE CHECKLIST (before every trade):\n"
        "1. Call get_kill_switch_status — if active=true, stop immediately.\n"
        "2. Call my_status — confirm you have an ACTIVE TRADE capability covering "
        "this market, it has not expired, and rate/position limits have headroom.\n"
        "3. Call get_market to read current prices and resolution criteria.\n"
        "4. Optionally call get_price_history and get_market_trades for context.\n"
        "5. Call get_position to see your existing exposure in this market.\n"
        "\n"
        "TRADING:\n"
        "Every trade passes through the backend 11-check policy gate in order:\n"
        "(0) kill switch inactive, (1) valid Ed25519 signature (auth layer), (2) agent ACTIVE,\n"
        "(3) has ACTIVE TRADE capability, (3b) capability not expired,\n"
        "(3c) capability covers this market,\n"
        "(4) hourly rate limit not exceeded, (4b) daily rate limit not exceeded,\n"
        "(5) position limit not exceeded — SKIPPED for sells (selling reduces exposure),\n"
        "(6) trade size within limit, (7) market is ACTIVE and before closeTime,\n"
        "(8) price impact within cap — SKIPPED for sells,\n"
        "(9) coordinated trading — SKIPPED for sells,\n"
        "(10) wash trading — SKIPPED for sells (selling is position management).\n"
        "Any failure blocks the trade and returns a structured denial code. "
        "Denied trades are still logged in the immutable audit trail.\n"
        "\n"
        "SELLING:\n"
        "Use sell_yes_shares or sell_no_shares to exit or reduce an existing position. "
        "The backend validates you hold sufficient shares before executing. "
        "A sell returns a negative cost (the market pays you at the current LMSR price). "
        "Call get_position first to confirm your current share holdings.\n"
        "\n"
        "ERROR RECOVERY BY DENIAL CODE:\n"
        "- AGENT_NOT_ACTIVE → agent is PENDING (awaiting approval), suspended, or revoked. "
        "If you just registered, call wait_for_approval — your agent has not been approved yet. "
        "If previously ACTIVE, you may have been suspended or revoked — alert the operator.\n"
        "- CAPABILITY_MISSING / CAPABILITY_EXPIRED → no valid TRADE capability; "
        "ask operator to issue or renew it.\n"
        "- MARKET_NOT_IN_SCOPE / MARKET_CLOSED / MARKET_NOT_ACTIVE → "
        "check get_market status and scope.\n"
        "- RATE_LIMIT_EXCEEDED / DAILY_RATE_LIMIT_EXCEEDED → wait for "
        "retryAfterSeconds in error before retrying.\n"
        "- POSITION_LIMIT_EXCEEDED → total exposure is at cap; do not increase "
        "position until existing positions reduce.\n"
        "- TRADE_SIZE_EXCEEDED → reduce shares to within max_single_trade limit.\n"
        "- TRADING_SUSPENDED → trading has been halted platform-wide by an operator. "
        "Stop all trade attempts immediately. Do not retry — this is not transient. "
        "Wait for the operator to deactivate the kill switch.\n"
        "- PRICE_IMPACT_EXCEEDED → trade is too large for this market's liquidity; "
        "split into smaller trades.\n"
        "- WASH_TRADING_DETECTED → you traded the opposite direction too recently; "
        "wait for the wash-trading window to expire.\n"
        "- COORDINATED_TRADING_DETECTED → a sibling agent (same operator) already "
        "traded the same direction; wait for the coordination window to expire.\n"
        "\n"
        "SCOPE: You manage trading decisions only. Operator governance tasks "
        "(market creation, agent approval, capability issuance, audit queries) are "
        "outside your scope and not available via these tools."
    ),
)

_client: SentinelClient | None = None
_identity: dict | None = None


def _get_identity() -> dict:
    """Load or create the agent identity (keypair + optional agent_id)."""
    global _identity
    if _identity is None:
        _identity = load_or_create_identity()
    return _identity


def _get_api_url() -> str:
    """Get the Sentinel API base URL from environment.

    Exits with a clear message if the variable is not set so the user
    never silently fires requests at the wrong host.
    """
    url = os.environ.get("SENTINEL_API_URL", "").strip()
    if not url:
        print(
            "\n"
            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            "  Sentinel MCP Server — configuration required\n"
            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            "\n"
            "  SENTINEL_API_URL is not set.\n"
            "\n"
            "  This tells the MCP server where your Sentinel backend is.\n"
            "  Without it every tool call will fail.\n"
            "\n"
            "  ── If you are using Claude Code ────────────────────────────\n"
            "\n"
            "  Re-add the server and include the -e flag:\n"
            "\n"
            "    claude mcp remove sentinel\n"
            "    claude mcp add sentinel python -- -m sentinel_mcp.server \\\n"
            "      -e SENTINEL_API_URL=https://your-sentinel-backend/api/v1\n"
            "\n"
            "  ── If you are using Claude Desktop ─────────────────────────\n"
            "\n"
            "  Add the env block to claude_desktop_config.json:\n"
            "\n"
            "    \"env\": { \"SENTINEL_API_URL\": \"https://your-sentinel-backend/api/v1\" }\n"
            "\n"
            "  ── If you are running directly ──────────────────────────────\n"
            "\n"
            "  Add this line to your .env file:\n"
            "\n"
            "    SENTINEL_API_URL=https://your-sentinel-backend/api/v1\n"
            "\n"
            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n",
            file=__import__("sys").stderr,
        )
        __import__("sys").exit(1)
    return url


def _get_client() -> SentinelClient:
    """Lazy-initialize the Sentinel client from auto-generated identity."""
    global _client
    if _client is None:
        identity = _get_identity()
        agent_id = identity.get("agent_id")

        if not agent_id:
            raise RuntimeError(
                "Agent is not registered yet. Call whoami to get your public key, "
                "then ask the operator to register you in the Sentinel console. "
                "Once they give you your agent ID, call set_agent_id to save it."
            )

        _client = SentinelClient(
            api_url=_get_api_url(),
            agent_id=agent_id,
            public_key=identity["public_key"],
            private_key=identity["private_key"],
        )
        logger.info("Sentinel client initialized for agent %s", agent_id)
    return _client


async def _close_client() -> None:
    """Close the cached client's HTTP connection if open."""
    global _client
    if _client is not None:
        try:
            await _client.close()
        except Exception:
            pass  # Best-effort cleanup


def _reset_client() -> None:
    """Reset the cached client (used after saving agent_id).

    Note: This does not close the httpx connection synchronously.
    The old client will be garbage-collected, and httpx will issue
    a ResourceWarning. For clean shutdown, use _close_client().
    """
    global _client, _identity
    _client = None
    _identity = None


def _format(data: object) -> str:
    """Format API response as readable JSON."""
    return json.dumps(data, indent=2, default=str)


def _validate_uuid(value: str, field_name: str) -> str | None:
    """Return a JSON error string if value is not a valid UUID, else None."""
    try:
        uuid.UUID(value)
        return None
    except ValueError:
        return _format({
            "error": "validation_error",
            "message": f"Invalid {field_name}: '{value}' is not a valid UUID.",
        })


def _validate_market_id(market_id: str) -> str | None:
    """Return a JSON error string if market_id is not a valid UUID, else None."""
    return _validate_uuid(market_id, "market_id")


async def _safe_call(coro_fn) -> str:
    """Invoke an async callable and return a structured JSON error on any failure.

    Catches all common failure modes so every tool always returns parseable JSON:
    - RuntimeError  → agent not registered yet
    - HTTPStatusError → backend returned 4xx/5xx (includes policy gate denials)
    - TimeoutException → backend unreachable or slow
    - ConnectError  → backend not running / wrong URL
    - Exception     → unexpected internal error
    """
    try:
        return _format(await coro_fn())
    except RuntimeError as e:
        return _format({
            "error": "not_registered",
            "message": str(e),
        })
    except httpx.HTTPStatusError as e:
        try:
            details = e.response.json()
        except Exception:
            details = e.response.text
        return _format({
            "error": f"HTTP {e.response.status_code}",
            "details": details,
        })
    except httpx.TimeoutException:
        return _format({
            "error": "timeout",
            "message": "Backend request timed out. Check if Sentinel backend is running.",
        })
    except httpx.ConnectError as e:
        return _format({
            "error": "connection_error",
            "message": f"Cannot reach backend at {_get_api_url()}: {e}",
        })
    except Exception as e:
        return _format({
            "error": "internal_error",
            "message": str(e),
        })


# --- Identity Tools ---


@mcp.tool()
async def whoami() -> str:
    """Show this agent's identity and registration status.

    On first call, an Ed25519 keypair is auto-generated. Use the returned
    public key in either of two registration paths:
    - Self-registration: call register_agent — no operator needed. The
      public key is used automatically from the local identity.
    - Operator registration: give the public key to an operator so they
      can register the agent in the Sentinel console, then call set_agent_id
      with the ID they provide.

    Returns:
        Public key, agent_id (if registered), and registration status.
    """
    identity = _get_identity()
    agent_id = identity.get("agent_id")

    if agent_id:
        return _format({
            "status": "registered",
            "agent_id": agent_id,
            "public_key": identity["public_key"],
            "message": (
                "Agent is registered. Use my_status to check capabilities. "
                "Note: agent approval state (ACTIVE/SUSPENDED/REVOKED) is only "
                "surfaced via AGENT_NOT_ACTIVE denial codes when you attempt a trade."
            ),
        })

    return _format({
        "status": "unregistered",
        "public_key": identity["public_key"],
        "message": (
            "Agent keypair generated but not yet registered. "
            "OPTION A (self-registration): Call register_agent with your name, description, "
            "purpose, and risk_tier. Your public key will be used automatically. "
            "OPTION B (operator-registration): Give the public key above to the operator so "
            "they can register this agent in the Sentinel console. Once registered, the "
            "operator will provide an agent ID — call set_agent_id with it."
        ),
    })


@mcp.tool()
async def set_agent_id(agent_id: str) -> str:
    """Save the agent ID assigned by the operator after registration.

    The operator registers this agent in the Sentinel console using the
    public key from whoami, then provides the assigned agent ID (UUID).
    Call this tool to save it locally so all future API calls are authenticated.

    Args:
        agent_id: UUID assigned by the Sentinel backend during registration.

    Returns:
        Confirmation with saved agent_id and next steps.
    """
    # Validate UUID format before persisting
    if err := _validate_uuid(agent_id, "agent_id"):
        return err

    identity = _get_identity()

    if identity.get("agent_id"):
        return _format({
            "status": "already_set",
            "agent_id": identity["agent_id"],
            "message": "Agent ID is already configured. Use my_status to check current state.",
        })

    save_agent_id(agent_id)
    _reset_client()

    return _format({
        "status": "saved",
        "agent_id": agent_id,
        "public_key": identity["public_key"],
        "message": (
            "Agent ID saved. The operator must still APPROVE this agent and "
            "issue TRADE capabilities before it can trade. "
            "Use my_status to check capabilities once issued."
        ),
    })


@mcp.tool()
async def register_agent(
    name: str,
    description: str,
    purpose: str,
    risk_tier: str = "LOW",
    source_code_hash: str | None = None,
) -> str:
    """Register this agent on the Sentinel platform.

    After registration, the agent will be in PENDING status and cannot trade
    until an operator approves it. The agent_id returned is saved automatically
    to the local identity file — you do not need to call set_agent_id after this.
    Store the agent_id from the response for reference in logs and support.

    Your Ed25519 public key is taken automatically from the local identity file
    (the same key used for all signed requests). Call whoami first to view it.
    Registration requires no credentials — the request is sent without an
    authorization header. Do not retry if you receive a 409 (name or key
    conflict) — choose a different name or regenerate your keypair instead.

    Args:
        name:             A unique, human-readable name for this agent. Use a
                          descriptive name reflecting its purpose, e.g.
                          "SLA-Monitor-Prod". Not "agent1".
        description:      A short description of what this agent does. Shown
                          to operators during the approval review.
        purpose:          The declared organizational purpose of this agent,
                          e.g. "Monitors staging SLA metrics and forecasts
                          uptime risk". Operators use this to evaluate approval.
        risk_tier:        Risk classification — LOW, MEDIUM, or HIGH. Defaults
                          to LOW. Operators may change this after approval.
                          LOW: read-only / observer agents.
                          MEDIUM: standard trading agents with limited exposure.
                          HIGH: high-frequency or high-exposure agents requiring
                          enhanced oversight.
        source_code_hash: Optional SHA-256 hex digest (64 hex chars) of this
                          agent's source code. Supports KYA (Know Your Agent)
                          governance. Omit if you do not have a code hash.

    Returns:
        On success (201): full registration response including agent_id, name,
                          status (PENDING), risk_tier, created_at, and a message
                          explaining next steps. The agent_id is saved locally.
        On error:         A structured error with an actionable message.
    """
    valid_tiers = ("LOW", "MEDIUM", "HIGH")
    risk_tier_upper = (risk_tier or "LOW").upper()
    if risk_tier_upper not in valid_tiers:
        return _format({
            "error": "validation_error",
            "message": f"Invalid risk_tier: '{risk_tier}'. Must be LOW, MEDIUM, or HIGH.",
        })

    identity = _get_identity()
    public_key = identity["public_key"]

    body: dict = {
        "name": name,
        "description": description,
        "purpose": purpose,
        "public_key": public_key,
        "risk_tier": risk_tier_upper,
    }
    if source_code_hash:
        body["source_code_hash"] = source_code_hash

    try:
        async with httpx.AsyncClient(timeout=15.0) as http:
            url = f"{_get_api_url()}/agents/register"
            resp = await http.post(url, json=body, headers={"Content-Type": "application/json"})
            resp.raise_for_status()
            data = resp.json()

        # Auto-save the agent_id so the agent can immediately call wait_for_approval
        # and signed endpoints without a separate set_agent_id call.
        agent_id = data.get("agent_id")
        if agent_id and not identity.get("agent_id"):
            save_agent_id(agent_id)
            _reset_client()
            logger.info("register_agent: auto-saved agent_id=%s", agent_id)

        return _format({
            **data,
            "message": (
                "Registration successful. Your agent is in PENDING status and cannot "
                "trade until an operator approves it. The agent_id has been saved locally. "
                "Call wait_for_approval (at most once per 60 seconds) to check your approval "
                "state. Do not attempt trades while PENDING — they will be denied."
            ),
        })

    except httpx.HTTPStatusError as e:
        status_code = e.response.status_code
        try:
            details = e.response.json()
        except Exception:
            details = e.response.text

        if status_code == 400:
            msg = (
                details.get("error", {}).get("message", str(details))
                if isinstance(details, dict) else str(details)
            )
            return _format({"error": "validation_failed", "message": msg})

        if status_code == 403:
            return _format({
                "error": "forbidden",
                "message": (
                    "Registration is not permitted from a VIEWER-authenticated session. "
                    "Agents must self-register without credentials."
                ),
            })

        if status_code == 409:
            msg = (
                details.get("error", {}).get("message", "Agent name or public key already registered.")
                if isinstance(details, dict) else "Agent name or public key already registered."
            )
            return _format({
                "error": "conflict",
                "message": f"{msg} Choose a different name or regenerate your keypair.",
            })

        return _format({
            "error": f"HTTP {status_code}",
            "message": "Registration failed due to a server error. Retry after a short delay.",
            "details": details,
        })

    except httpx.TimeoutException:
        return _format({
            "error": "timeout",
            "message": "Backend request timed out. Check if Sentinel backend is running.",
        })
    except httpx.ConnectError as e:
        return _format({
            "error": "connection_error",
            "message": f"Cannot reach backend at {_get_api_url()}: {e}",
        })
    except Exception as e:
        return _format({"error": "internal_error", "message": str(e)})


# --- Context / System State Tools ---


@mcp.tool()
async def get_kill_switch_status() -> str:
    """Check whether the global trading halt (kill switch) is currently active.

    Call this as the very first step before any trade attempt. If the kill
    switch is active, all trading is halted platform-wide — your trade will be
    denied with TRADING_SUSPENDED regardless of your capabilities.

    Requires agent authentication (Ed25519 signature). Call this after completing
    the startup sequence (whoami → set_agent_id) and before every trade attempt.

    Returns:
        active (bool): true means all trading is suspended; false means normal.
        activatedBy, reason, activatedAt if currently active.
    """
    async def call():
        client = _get_client()
        return await client.get("kill-switch/status")

    return await _safe_call(call)


# --- Market Tools ---


@mcp.tool()
async def list_markets(
    status: str | None = None,
    page: int = 0,
    size: int = 20,
) -> str:
    """List prediction markets on Sentinel, optionally filtered by status.

    Use this to discover markets available for trading. Filter by ACTIVE to
    see only markets where trading is currently open.

    Args:
        status: Optional filter — PROPOSED, ACTIVE, HALTED, RESOLVED, or SETTLED.
                Omit to list all markets.
        page:   Zero-based page number (default 0).
        size:   Number of markets per page, 1-100 (default 20).

    Returns:
        Paginated list of markets with id, title, prices, status, and close time.
        Check totalPages and totalElements for pagination navigation.
    """
    async def call():
        client = _get_client()
        params: dict = {
            "page": str(max(0, page)),
            "size": str(max(1, min(size, 100))),
        }
        if status:
            params["status"] = status
        return await client.get("markets", params=params)

    return await _safe_call(call)


@mcp.tool()
async def get_market(market_id: str) -> str:
    """Get full details of a specific market including current prices and recent trades.

    Call this before every trade to read:
    - Current YES/NO prices (your entry price)
    - Resolution criteria (to judge probability)
    - Market status (must be ACTIVE to trade)
    - Close time (trading ends at this timestamp)

    Args:
        market_id: UUID of the market.

    Returns:
        Market detail: title, description, resolution criteria, current YES/NO
        prices, total shares outstanding, recent trades, status, and close time.
    """
    if err := _validate_market_id(market_id):
        return err

    async def call():
        client = _get_client()
        return await client.get(f"markets/{market_id}")

    return await _safe_call(call)


@mcp.tool()
async def get_market_trades(
    market_id: str,
    page: int = 0,
    size: int = 20,
) -> str:
    """Get recent trades on a market to gauge activity, volume, and price direction.

    Use this to understand market momentum: who is trading, in which direction,
    and how much prices have moved per trade. A market with many recent BUY_YES
    trades and rising price suggests positive sentiment.

    Args:
        market_id: UUID of the market.
        page:      Zero-based page number (default 0).
        size:      Number of trades per page, 1-100 (default 20).

    Returns:
        Paginated list of recent decision packets with agent_id, direction,
        shares, cost, price_before, price_after, and timestamp.
    """
    if err := _validate_market_id(market_id):
        return err

    async def call():
        client = _get_client()
        params = {
            "page": str(max(0, page)),
            "size": str(max(1, min(size, 100))),
        }
        return await client.get(f"markets/{market_id}/trades", params=params)

    return await _safe_call(call)


@mcp.tool()
async def get_price_history(market_id: str, since: str | None = None) -> str:
    """Get historical YES/NO price snapshots for a market.

    Use this before trading to assess price trends, volatility, and momentum.
    A steadily rising YES price with high recent volume suggests strong conviction.
    High volatility may indicate a disputed or uncertain question.

    Call this after get_market (for current state) to add temporal context.

    Pre-conditions:
    - The market must exist (verify with get_market first).

    Args:
        market_id: UUID of the market.
        since: Optional ISO-8601 UTC timestamp lower bound (e.g. "2026-03-01T00:00:00Z").
               When omitted, returns up to the most recent 1000 snapshots.
               Use a shorter window (e.g. last 24h) for tighter analysis.

    Returns:
        Chronological list of price snapshots, each with timestamp, price_yes,
        and price_no. Use to chart price movement and measure volatility.
    """
    if err := _validate_market_id(market_id):
        return err

    async def call():
        client = _get_client()
        params = {}
        if since:
            params["since"] = since
        return await client.get(f"markets/{market_id}/price-history", params=params or None)

    return await _safe_call(call)


# --- Trading Tools ---


@mcp.tool()
async def place_trade(market_id: str, direction: str, shares: float) -> str:
    """Buy or sell YES/NO shares in a prediction market.

    Complete the pre-trade checklist before calling this:
      get_kill_switch_status → my_status → get_market →
      (optionally) get_price_history and get_market_trades → get_position.

    The backend runs an 11-check policy gate before executing the trade:
      (0) Kill switch inactive
      (1) Valid Ed25519 signature (enforced by authentication layer)
      (2) Agent status is ACTIVE
      (3) Agent has an ACTIVE TRADE capability (not expired)
      (3b) Capability scope covers this market
      (4) Hourly rate limit not exceeded
      (4b) Daily rate limit not exceeded
      (5) Total position limit — skipped for sells (selling reduces exposure)
      (6) Single trade size is within capability limit
      (7) Market status is ACTIVE and before close_time
      (8) Price impact of this trade within capability max_price_impact — skipped for sells
      (9) Coordinated trading check — skipped for sells
      (10) Wash trading check — skipped for sells

    All checks must pass. Any failure blocks the trade, returns a denial code,
    and writes an immutable DENIED DecisionPacket to the audit trail.

    This tool will return a policy denial if the agent is in PENDING status or
    has no active capability token. If you receive a denial with reason
    AGENT_NOT_ACTIVE, use wait_for_approval to check your status before retrying.
    Do not retry a trade while PENDING — the outcome will not change until an
    operator approves you.

    Pre-conditions:
    - get_kill_switch_status must return active=false
    - my_status must show an ACTIVE TRADE capability covering this market
    - For sells: get_position to confirm you hold sufficient shares

    Args:
        market_id: UUID of the market to trade in.
        direction: BUY_YES, BUY_NO, SELL_YES, or SELL_NO.
        shares:    Number of shares (unsigned). Must be >= 0.01.
                   For buys: within max_single_trade shown in my_status.
                   For sells: must not exceed shares held in get_position.

    Returns:
        On success: trade_id, cost (negative for sells — you received funds),
                    price_before, new_price_yes, new_price_no,
                    new_position_shares_yes, new_position_shares_no, executed_at.
        On denial:  error code with context. See server instructions for recovery.
    """
    if err := _validate_market_id(market_id):
        return err

    direction_upper = direction.upper() if direction else direction
    if direction_upper not in ("BUY_YES", "BUY_NO", "SELL_YES", "SELL_NO"):
        return _format({
            "error": "validation_error",
            "message": (
                f"Invalid direction: '{direction}'. "
                "Must be BUY_YES, BUY_NO, SELL_YES, or SELL_NO."
            ),
        })

    if shares < 0.01:
        return _format({
            "error": "validation_error",
            "message": "Shares must be at least 0.01",
        })

    async def call():
        client = _get_client()
        body = {
            "market_id": market_id,
            "direction": direction_upper,
            "shares": shares,
        }
        return await client.post("trade", body)

    return await _safe_call(call)


@mcp.tool()
async def sell_yes_shares(market_id: str, shares: float) -> str:
    """Exit or reduce a YES position by selling YES shares.

    This tool will return a policy denial if the agent is in PENDING status or
    has no active capability token. If you receive a denial with reason
    AGENT_NOT_ACTIVE, use wait_for_approval to check your status before retrying.
    Do not retry a trade while PENDING — the outcome will not change until an
    operator approves you.

    The agent must hold sufficient YES shares (check get_position first).
    The backend validates ownership before executing — selling more than held
    returns a 400 error with an INSUFFICIENT_YES_SHARES message.

    A sell returns a negative cost: the market pays you at the current LMSR
    price. The larger your sell relative to market liquidity, the more the
    YES price drops (you are reducing demand for YES outcomes).

    Position limit, wash-trading, and coordinated-trading policy checks are
    skipped for sells — reducing a position is always permitted.

    Pre-conditions:
    - get_kill_switch_status must return active=false
    - get_position must show shares_yes >= shares you intend to sell
    - Market must be ACTIVE and before close_time

    Args:
        market_id: UUID of the market to sell in.
        shares:    Positive number of YES shares to sell. Must be >= 0.01
                   and <= shares_yes shown in get_position.

    Returns:
        On success: trade_id, cost (negative — funds returned to you),
                    new_price_yes, new_price_no, updated position shares,
                    executed_at.
        On error:   validation_error if insufficient shares or invalid input.
    """
    if err := _validate_market_id(market_id):
        return err

    if shares < 0.01:
        return _format({
            "error": "validation_error",
            "message": "Shares must be at least 0.01",
        })

    async def call():
        client = _get_client()
        body = {
            "market_id": market_id,
            "direction": "SELL_YES",
            "shares": shares,
        }
        return await client.post("trade", body)

    return await _safe_call(call)


@mcp.tool()
async def sell_no_shares(market_id: str, shares: float) -> str:
    """Exit or reduce a NO position by selling NO shares.

    This tool will return a policy denial if the agent is in PENDING status or
    has no active capability token. If you receive a denial with reason
    AGENT_NOT_ACTIVE, use wait_for_approval to check your status before retrying.
    Do not retry a trade while PENDING — the outcome will not change until an
    operator approves you.

    The agent must hold sufficient NO shares (check get_position first).
    The backend validates ownership before executing — selling more than held
    returns a 400 error with an INSUFFICIENT_NO_SHARES message.

    A sell returns a negative cost: the market pays you at the current LMSR
    price. The larger your sell relative to market liquidity, the more the
    NO price drops (you are reducing demand for NO outcomes).

    Position limit, wash-trading, and coordinated-trading policy checks are
    skipped for sells — reducing a position is always permitted.

    Pre-conditions:
    - get_kill_switch_status must return active=false
    - get_position must show shares_no >= shares you intend to sell
    - Market must be ACTIVE and before close_time

    Args:
        market_id: UUID of the market to sell in.
        shares:    Positive number of NO shares to sell. Must be >= 0.01
                   and <= shares_no shown in get_position.

    Returns:
        On success: trade_id, cost (negative — funds returned to you),
                    new_price_yes, new_price_no, updated position shares,
                    executed_at.
        On error:   validation_error if insufficient shares or invalid input.
    """
    if err := _validate_market_id(market_id):
        return err

    if shares < 0.01:
        return _format({
            "error": "validation_error",
            "message": "Shares must be at least 0.01",
        })

    async def call():
        client = _get_client()
        body = {
            "market_id": market_id,
            "direction": "SELL_NO",
            "shares": shares,
        }
        return await client.post("trade", body)

    return await _safe_call(call)


@mcp.tool()
async def get_position(market_id: str) -> str:
    """Check your current position (YES/NO shares held) in a specific market.

    Call this before trading to know your existing exposure. The policy gate
    enforces a total position limit (price-weighted) — understanding your
    current position prevents POSITION_LIMIT_EXCEEDED denials.

    Args:
        market_id: UUID of the market.

    Returns:
        Position: sharesYes, sharesNo counts. Returns a zero position record
        if you have not yet traded in this market.
    """
    if err := _validate_market_id(market_id):
        return err

    async def call():
        client = _get_client()
        return await client.get(f"markets/{market_id}/position")

    return await _safe_call(call)


@mcp.tool()
async def get_all_positions() -> str:
    """List all your positions across every market you have ever traded in.

    Use this to get a complete view of your portfolio: which markets you hold
    shares in, how many YES and NO shares you hold in each, and your overall
    exposure. Useful for managing position limits and portfolio rebalancing.

    BACKEND CONSTRAINT: The GET /agents/{id}/positions endpoint requires
    operator-level authentication (VIEWER/OPERATOR/ADMIN role) and cannot be
    accessed via agent Ed25519 authentication. This tool will return a
    permissions error until the backend is updated to support agent-authenticated
    position listing. To check your position in a specific market, use
    get_position with a market_id instead.

    Returns:
        List of positions (market_id, shares_yes, shares_no, cost_basis,
        realized_pnl per market), or a clear error if the backend rejects
        agent authentication for this endpoint.
    """
    try:
        client = _get_client()
        identity = _get_identity()
        agent_id = identity["agent_id"]
        result = await client.get(f"agents/{agent_id}/positions")
        return _format(result)
    except RuntimeError as e:
        return _format({"error": "not_registered", "message": str(e)})
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 403:
            return _format({
                "error": "not_available_via_agent_auth",
                "message": (
                    "GET /agents/{id}/positions requires operator-level authentication "
                    "(VIEWER/OPERATOR/ADMIN role) and cannot be called with agent "
                    "Ed25519 credentials. This is a backend authorization constraint. "
                    "Use get_position with a specific market_id to check your position "
                    "in individual markets."
                ),
            })
        try:
            details = e.response.json()
        except Exception:
            details = e.response.text
        return _format({"error": f"HTTP {e.response.status_code}", "details": details})
    except httpx.TimeoutException:
        return _format({
            "error": "timeout",
            "message": "Backend request timed out. Check if Sentinel backend is running.",
        })
    except httpx.ConnectError as e:
        return _format({
            "error": "connection_error",
            "message": f"Cannot reach backend at {_get_api_url()}: {e}",
        })
    except Exception as e:
        return _format({"error": "internal_error", "message": str(e)})


# --- Status Tools ---


@mcp.tool()
async def my_status() -> str:
    """Check your active capabilities and trading limits.

    Call this before every trade to verify:
    - You have an ACTIVE TRADE capability
    - The capability covers the market you want to trade in
    - The capability has not expired (check expires_at)
    - Hourly and daily rate limits have remaining headroom
    - Position limit (max_position_size) has remaining headroom
    - Trade size limit (max_trade_size) for sizing your order

    NOTE: Agent profile (status: ACTIVE/PENDING/SUSPENDED/REVOKED, risk_tier)
    is NOT included here because GET /agents/{id} requires operator JWT
    authentication. If trades are denied with AGENT_NOT_ACTIVE, call
    wait_for_approval — your agent may still be PENDING approval, or may have
    been suspended or revoked by an operator.

    Returns:
        agent_id and list of capabilities with: type (TRADE/READ/CREATE),
        allowed_markets, rate_limit_per_hour, rate_limit_per_day,
        max_trade_size, max_position_size, max_price_impact, status,
        expires_at, and capability_id.
    """
    async def call():
        client = _get_client()
        identity = _get_identity()
        agent_id = identity["agent_id"]
        capabilities = await client.get(f"agents/{agent_id}/capabilities")
        return {"agent_id": agent_id, "capabilities": capabilities}

    return await _safe_call(call)


@mcp.tool()
async def get_my_stats() -> str:
    """Check your trading performance statistics and historical activity.

    Use this to review your own track record: total trades, total trading
    volume, P&L (unrealized + realized), win rate across resolved markets,
    current exposure, and a chronological P&L history.

    Useful for self-monitoring and diagnosing repeated policy denials:
    - High denial rate for RATE_LIMIT_EXCEEDED → reduce trading frequency.
    - Frequent POSITION_LIMIT_EXCEEDED → you are consistently at your exposure cap.
    - Low win rate → reconsider your probability estimation approach.

    Returns:
        total_trades, total_volume, active_positions, resolved_positions,
        estimated_pnl, unrealized_pnl, realized_pnl, win_rate, total_exposure,
        pnl_history (chronological list of {timestamp, pnl} points, up to 1000),
        and position_details (per-market breakdown with cost_basis, current_value,
        unrealized_pnl, outcome for resolved markets).
    """
    async def call():
        client = _get_client()
        identity = _get_identity()
        agent_id = identity["agent_id"]
        return await client.get(f"agents/{agent_id}/stats")

    return await _safe_call(call)


@mcp.tool()
async def get_agent_status(agent_id: str) -> str:
    """Check this agent's current lifecycle status on the Sentinel platform.

    Use this to understand whether the agent has been approved, suspended, or
    revoked. The status field maps to the following states:

      PENDING   — Registration received. Awaiting operator approval.
                  Trading is not yet possible.
      ACTIVE    — Approved. Trading is possible if a valid TRADE capability
                  token has been issued. Use my_status to confirm capabilities.
      SUSPENDED — Temporarily blocked by an operator. No trading permitted.
                  The operator can reactivate the agent. Contact the operator.
      REVOKED   — Permanently removed. No further actions are possible with
                  this registration. A new registration is required.

    NOTE: This endpoint currently requires operator-level authentication on the
    backend. If called with agent Ed25519 credentials, the backend returns 403.
    This is a known backend gap — the tool returns a clear message and next steps
    in that case. Use wait_for_approval for a structured polling workflow.

    Args:
        agent_id: The agent's UUID, as returned by register_agent or set_agent_id.

    Returns:
        On success: status, risk_tier, approved_at, suspension_reason,
                    revocation_reason, and all other agent profile fields.
        On 404:     Clear error — verify the agent_id from your registration.
        On 403:     Backend gap message with instructions for checking status
                    indirectly (attempt a trade and read the denial code).
    """
    if err := _validate_uuid(agent_id, "agent_id"):
        return err

    try:
        client = _get_client()
        data = await client.get(f"agents/{agent_id}")
        return _format(data)

    except RuntimeError as e:
        return _format({"error": "not_registered", "message": str(e)})

    except httpx.HTTPStatusError as e:
        if e.response.status_code == 403:
            return _format({
                "error": "backend_gap",
                "message": (
                    "GET /api/v1/agents/{id} requires operator-level authentication and cannot "
                    "be called with agent Ed25519 credentials (known backend constraint). "
                    "To check your approval state indirectly: attempt a trade — an "
                    "AGENT_NOT_ACTIVE denial confirms you are still PENDING or have been "
                    "suspended/revoked. Use wait_for_approval for a structured polling workflow."
                ),
            })
        if e.response.status_code == 404:
            return _format({
                "error": "not_found",
                "message": "No agent found with this ID. Verify the agent_id from your registration response.",
            })
        if e.response.status_code == 401:
            try:
                details = e.response.json()
                msg = (
                    details.get("error", {}).get("message", "Authentication failed.")
                    if isinstance(details, dict) else str(details)
                )
            except Exception:
                msg = e.response.text
            return _format({"error": "unauthorized", "message": msg})
        try:
            details = e.response.json()
        except Exception:
            details = e.response.text
        return _format({"error": f"HTTP {e.response.status_code}", "details": details})

    except httpx.TimeoutException:
        return _format({
            "error": "timeout",
            "message": "Backend request timed out. Check if Sentinel backend is running.",
        })
    except httpx.ConnectError as e:
        return _format({
            "error": "connection_error",
            "message": f"Cannot reach backend at {_get_api_url()}: {e}",
        })
    except Exception as e:
        return _format({"error": "internal_error", "message": str(e)})


@mcp.tool()
async def wait_for_approval(agent_id: str) -> str:
    """Check whether this agent has been approved and is ready to trade.

    Call this tool after registering and after receiving an AGENT_NOT_ACTIVE
    policy denial. Do not call this more than once per 60 seconds — rapid
    polling is not useful and may trigger rate limiting.

    Outcome → action mapping:
      approved     — Agent is ACTIVE. Check the capabilities field before
                     attempting a trade — an approved agent with no issued
                     capability cannot trade. Use my_status to confirm trading
                     limits and capability details.
      still_pending — Registration received but not yet approved. Wait at
                     least 60 seconds before calling this tool again. Do not
                     attempt trades — they will be denied until approval.
      blocked      — Agent has been SUSPENDED or REVOKED by an operator.
                     Do not retry trades. Contact the operator or agent
                     developer immediately.

    This tool performs a single status check and returns one outcome. It does
    not loop internally — the agent decides when to call it again.

    Args:
        agent_id: The agent's UUID, as returned by register_agent or set_agent_id.

    Returns:
        {
          "outcome":      "approved" | "still_pending" | "blocked",
          "status":       current AgentStatus value (or "UNKNOWN" if backend
                          gap prevents direct reads),
          "capabilities": list of active capabilities (may be empty),
          "message":      human-readable explanation of what to do next
        }
    """
    if err := _validate_uuid(agent_id, "agent_id"):
        return err

    try:
        client = _get_client()
        data = await client.get(f"agents/{agent_id}")
        status = data.get("status", "UNKNOWN")

        if status == "ACTIVE":
            return _format({
                "outcome": "approved",
                "status": status,
                "capabilities": data.get("capabilities", []),
                "message": (
                    "Agent is approved and ACTIVE. Check the capabilities field — if empty, "
                    "a TRADE capability has not yet been issued by the operator. "
                    "Trading requires both ACTIVE status and at least one active TRADE "
                    "capability. Use my_status to confirm trading limits and capability details."
                ),
            })

        if status == "PENDING":
            return _format({
                "outcome": "still_pending",
                "status": status,
                "capabilities": data.get("capabilities", []),
                "message": (
                    "Registration received. Awaiting operator approval. "
                    "Do not attempt to trade — all trade attempts will be denied. "
                    "Wait at least 60 seconds and call wait_for_approval again."
                ),
            })

        if status in ("SUSPENDED", "REVOKED"):
            return _format({
                "outcome": "blocked",
                "status": status,
                "capabilities": data.get("capabilities", []),
                "message": (
                    f"Agent is {status}. No trading is permitted. "
                    "Contact the operator or agent developer immediately. "
                    "Do not retry trade attempts."
                ),
            })

        # Unrecognised status — treat as pending to avoid premature trade attempts
        return _format({
            "outcome": "still_pending",
            "status": status,
            "capabilities": data.get("capabilities", []),
            "message": f"Unrecognised status '{status}'. Treating as pending — wait 60 seconds and retry.",
        })

    except RuntimeError as e:
        return _format({"error": "not_registered", "message": str(e)})

    except httpx.HTTPStatusError as e:
        if e.response.status_code == 403:
            # Backend gap: GET /agents/{id} requires operator JWT.
            # Return still_pending — if the agent just self-registered, they are
            # PENDING. The 403 does not mean blocked; it means the endpoint isn't
            # open to agent auth yet. The agent should wait and retry.
            return _format({
                "outcome": "still_pending",
                "status": "UNKNOWN",
                "capabilities": [],
                "message": (
                    "The backend does not yet support agent self-status reads via Ed25519 "
                    "authentication (known backend gap). Your registration was accepted. "
                    "Wait at least 60 seconds and call wait_for_approval again, or attempt "
                    "a trade — an AGENT_NOT_ACTIVE denial confirms you are still PENDING; "
                    "a successful (or capability-denied) trade confirms you are ACTIVE."
                ),
            })
        if e.response.status_code == 404:
            return _format({
                "error": "not_found",
                "message": "No agent found with this ID. Verify the agent_id from your registration response.",
            })
        try:
            details = e.response.json()
        except Exception:
            details = e.response.text
        return _format({"error": f"HTTP {e.response.status_code}", "details": details})

    except httpx.TimeoutException:
        return _format({
            "error": "timeout",
            "message": "Backend request timed out. Check if Sentinel backend is running.",
        })
    except httpx.ConnectError as e:
        return _format({
            "error": "connection_error",
            "message": f"Cannot reach backend at {_get_api_url()}: {e}",
        })
    except Exception as e:
        return _format({"error": "internal_error", "message": str(e)})


# --- Entry point ---


def _cleanup() -> None:
    """Best-effort cleanup: close httpx client on process exit."""
    if _client is not None:
        try:
            # Use asyncio.run() instead of deprecated get_event_loop() (Python 3.10+)
            asyncio.run(_close_client())
        except RuntimeError:
            # Event loop may already be running or closed — skip gracefully
            pass


def main() -> None:
    """Run the MCP server via stdio transport."""
    atexit.register(_cleanup)

    # Eagerly load/create identity on startup so keys are ready
    identity = _get_identity()
    if identity.get("agent_id"):
        logger.info("Starting Sentinel MCP Server (agent_id=%s)", identity["agent_id"])
    else:
        logger.info(
            "Starting Sentinel MCP Server (UNREGISTERED — public_key=%s...). "
            "Call whoami to get the public key for operator registration.",
            identity["public_key"][:16],
        )
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
