"""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. The operator must then register the agent
through the Sentinel console using the public key, approve it, and issue
TRADE capabilities before the agent can trade.

Tools:
    whoami               — Show agent identity and registration status
    set_agent_id         — Save the operator-assigned agent ID after registration
    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 YES or NO shares (runs 10-check policy gate)
    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

BACKEND GAPS (tracked for backend team):
    GET /api/v1/agents/{id} requires operator JWT — agents cannot read their own
    profile (status, risk_tier) via Ed25519 signature auth. Agent approval state
    is only surfaced via AGENT_NOT_ACTIVE policy denial codes.

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"
        "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 10-check policy gate in order:\n"
        "(0) kill switch inactive, (1) agent ACTIVE, (2) has ACTIVE TRADE capability,\n"
        "(3) capability not expired, (3b) capability covers this market,\n"
        "(4) hourly rate limit not exceeded, (4b) daily rate limit not exceeded,\n"
        "(5) position limit not exceeded (price-weighted), (6) trade size within limit,\n"
        "(7) market is ACTIVE and before closeTime, (7b) price impact within cap,\n"
        "(7c) no coordinated trading detected, (7a) no wash trading detected.\n"
        "Any failure blocks the trade and returns a structured denial code. "
        "Denied trades are still logged in the immutable audit trail.\n"
        "\n"
        "ERROR RECOVERY BY DENIAL CODE:\n"
        "- AGENT_NOT_ACTIVE → suspended or revoked; stop trading, alert 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"
        "- KILL_SWITCH_ACTIVE → halt all trading immediately.\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."""
    return os.environ.get("SENTINEL_API_URL", "http://localhost:8080/api/v1")


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. The public key is
    displayed so the operator can register the agent in the Sentinel console.

    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. "
            "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."
        ),
    })


# --- 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 KILL_SWITCH_ACTIVE regardless of your capabilities.

    This is a public endpoint — no agent authentication required. It is safe
    to call even before your agent is registered.

    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, or RESOLVED.
                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 YES or 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 a multi-check policy gate before executing the trade:
      (0) Kill switch inactive
      (1) Agent status is ACTIVE
      (2) Agent has an ACTIVE TRADE capability (not expired)
      (3) Capability scope covers this market
      (4) Hourly rate limit not exceeded
      (4b) Daily rate limit not exceeded
      (5) Total position limit (price-weighted exposure) not exceeded
      (6) Single trade size is within capability limit
      (7) Market status is ACTIVE and before close_time
      (7b) Price impact of this trade within capability max_price_impact
      (7c) No coordinated trading detected (sibling agent same direction recently)
      (7a) No wash trading detected (you traded opposite direction recently)

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

    Pre-conditions:
    - get_kill_switch_status must return active=false
    - my_status must show an ACTIVE TRADE capability covering this market
    - Capability must not be expired
    - Rate limit and position limit must have headroom

    Args:
        market_id: UUID of the market to trade in.
        direction: BUY_YES to buy YES shares, BUY_NO to buy NO shares.
        shares:    Number of shares to buy. Must be >= 0.01 and within the
                   max_single_trade shown in my_status capabilities.

    Returns:
        On success: trade_id, cost, price_before (price_yes before trade),
                    new_price_yes, new_price_no, new_position_shares_yes,
                    new_position_shares_no, executed_at. Use this to confirm
                    execution and track your updated position.
        On denial:  error code (e.g. RATE_LIMIT_EXCEEDED, POSITION_LIMIT_EXCEEDED,
                    WASH_TRADING_DETECTED) with context. See server instructions
                    for recovery actions per denial code.
    """
    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"):
        return _format({
            "error": "validation_error",
            "message": f"Invalid direction: '{direction}'. Must be BUY_YES or BUY_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 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.

    Returns:
        List of positions, each with market_id, shares_yes, shares_no,
        and last_updated. Empty list if you have never placed a trade.
    """
    async def call():
        client = _get_client()
        identity = _get_identity()
        agent_id = identity["agent_id"]
        return await client.get(f"agents/{agent_id}/positions")

    return await _safe_call(call)


# --- 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/SUSPENDED/REVOKED, risk_tier) is NOT
    included here because GET /agents/{id} requires operator JWT authentication.
    Agents cannot read their own profile. If trades are denied with
    AGENT_NOT_ACTIVE, your agent has been suspended or revoked — contact
    the operator immediately.

    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)


# --- 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()
