"""
starter_2_single_market_arb.py — Single-market arbitrage bot.

Scans active binary markets for the inefficiency:

    best_ask(YES) + best_ask(NO) < 1.00 - epsilon

Buying both sides at that combined price guarantees $1 payout per pair
when the market resolves. This is the cleanest, lowest-risk Polymarket
strategy — but profitable opportunities are rare and tiny (1-3 cents)
because professional bots arbitrage them away in milliseconds.

Customize: MIN_EDGE_USD, MAX_TRADE_USD, MIN_DEPTH_USD, scan interval.

Run:
    python main.py

Defaults to DRY_RUN=true.
"""

from __future__ import annotations

import asyncio
import os
import signal
import time
from typing import Optional

from dotenv import load_dotenv

from common import (
    JsonlLogger,
    OrderIntent,
    RiskLayer,
    book_depth_at_price,
    cancel_all,
    close_http,
    fetch_active_markets,
    get_market_book,
    healthcheck,
    make_client,
    parse_token_ids,
    place_fok_with_retry,
)


# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------

# Profit threshold per $1 pair, AFTER 2% fee assumption. 0.005 = 0.5 cent edge.
MIN_EDGE_USD = float(os.getenv("MIN_EDGE_USD", "0.005"))
# Max USD per arb trade per side.
MAX_TRADE_USD = float(os.getenv("MAX_TRADE_USD", "20"))
# Need at least this much $ liquidity at the target price on both books.
MIN_DEPTH_USD = float(os.getenv("MIN_DEPTH_USD", "10"))
# How often to scan (seconds). Lower = catches more, costs more rate limit.
SCAN_INTERVAL_SEC = float(os.getenv("SCAN_INTERVAL_SEC", "5"))
# Cap on number of markets per cycle (prevents drowning the API).
MAX_MARKETS_PER_CYCLE = int(os.getenv("MAX_MARKETS_PER_CYCLE", "200"))

DRY_RUN = os.getenv("DRY_RUN", "true").lower() != "false"


# ---------------------------------------------------------------------------
# Strategy
# ---------------------------------------------------------------------------

def find_arb(yes_book, no_book) -> Optional[dict]:
    """
    Given order books for the YES and NO tokens, return arb opportunity dict
    or None. Combined ask < 1 means risk-free profit if filled.
    """
    if not yes_book or not no_book:
        return None
    yes_asks = yes_book.get("asks") or []
    no_asks = no_book.get("asks") or []
    if not yes_asks or not no_asks:
        return None

    # Best ask = lowest price (book is sorted ascending in price for asks)
    best_yes_ask = float(yes_asks[0]["price"])
    best_no_ask = float(no_asks[0]["price"])
    combined = best_yes_ask + best_no_ask

    # Account for 2% Polymarket fee on profit. Profit per $1 = 1 - combined.
    raw_edge = 1.0 - combined
    net_edge = raw_edge * 0.98  # rough — exact calc depends on which side has profit

    if net_edge < MIN_EDGE_USD:
        return None

    yes_depth = book_depth_at_price(yes_book, "buy", best_yes_ask)
    no_depth = book_depth_at_price(no_book, "buy", best_no_ask)
    max_pair_size_usd = min(yes_depth * best_yes_ask, no_depth * best_no_ask, MAX_TRADE_USD)
    if max_pair_size_usd < MIN_DEPTH_USD:
        return None

    return {
        "yes_price": best_yes_ask,
        "no_price": best_no_ask,
        "combined": combined,
        "net_edge": net_edge,
        "size_usd": max_pair_size_usd,
    }


# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------

async def scan_once(client, risk: RiskLayer, log: JsonlLogger) -> int:
    """One scan pass over active markets. Returns count of opportunities found."""
    markets = await fetch_active_markets(closed=False, limit=MAX_MARKETS_PER_CYCLE)
    found = 0

    for m in markets:
        try:
            yes_id, no_id = parse_token_ids(m)
        except Exception:
            continue

        # Pull both books in parallel
        yes_book, no_book = await asyncio.gather(
            asyncio.to_thread(get_market_book, client, yes_id),
            asyncio.to_thread(get_market_book, client, no_id),
        )
        opp = find_arb(yes_book, no_book)
        if not opp:
            continue

        found += 1
        slug = m.get("slug") or m.get("question", "")[:40]
        print(f"\n🎯 ARB FOUND: {slug}")
        print(f"   YES @ {opp['yes_price']:.4f} + NO @ {opp['no_price']:.4f} = {opp['combined']:.4f}")
        print(f"   Net edge: ${opp['net_edge']:.4f} per $1 pair, size: ${opp['size_usd']:.2f}")

        log.log("arb_opportunity", market=slug, **opp)

        if DRY_RUN:
            print("   [DRY RUN] not placing orders")
            continue

        # Place both legs as FOK so we don't get stuck holding only one side
        size_yes = opp["size_usd"] / opp["yes_price"]
        size_no = opp["size_usd"] / opp["no_price"]

        # Risk check on total exposure
        intent_yes = OrderIntent(
            token_id=yes_id, side="BUY", size=size_yes, price=opp["yes_price"],
            strategy_id="arb_v1", market_slug=slug,
        )
        intent_no = OrderIntent(
            token_id=no_id, side="BUY", size=size_no, price=opp["no_price"],
            strategy_id="arb_v1", market_slug=slug,
        )
        if not risk.allow(intent_yes) or not risk.allow(intent_no):
            log.log("arb_skipped", reason="risk_layer", market=slug)
            continue

        # Submit both legs concurrently
        try:
            res_yes, res_no = await asyncio.gather(
                asyncio.to_thread(
                    place_fok_with_retry, client, yes_id, "BUY", size_yes, opp["yes_price"]
                ),
                asyncio.to_thread(
                    place_fok_with_retry, client, no_id, "BUY", size_no, opp["no_price"]
                ),
            )
            log.log("arb_executed", market=slug, yes=res_yes.__dict__, no=res_no.__dict__)
            risk.record_fill(intent_yes, res_yes.filled_size)
            risk.record_fill(intent_no, res_no.filled_size)
        except Exception as e:
            log.log("arb_error", market=slug, error=str(e))
            print(f"   ❌ error: {e}")

    return found


async def main():
    load_dotenv()
    log = JsonlLogger("logs/arb_v1")

    print("=" * 60)
    print("Polymarket Single-Market Arbitrage Bot")
    print("=" * 60)
    print(f"Mode:           {'DRY-RUN' if DRY_RUN else '🔴 LIVE'}")
    print(f"Min edge:       ${MIN_EDGE_USD}")
    print(f"Max trade:      ${MAX_TRADE_USD}")
    print(f"Min depth:      ${MIN_DEPTH_USD}")
    print(f"Scan interval:  {SCAN_INTERVAL_SEC}s")
    print("=" * 60)

    client = make_client()
    healthcheck(client)
    risk = RiskLayer.from_env(strategy_id="arb_v1")

    stop = asyncio.Event()

    def _shutdown(*_):
        print("\nShutdown signal received, exiting...")
        stop.set()

    for sig in (signal.SIGINT, signal.SIGTERM):
        try:
            asyncio.get_running_loop().add_signal_handler(sig, _shutdown)
        except NotImplementedError:
            signal.signal(sig, _shutdown)  # Windows fallback

    cycles = 0
    total_found = 0
    while not stop.is_set():
        t0 = time.time()
        try:
            found = await scan_once(client, risk, log)
            total_found += found
            cycles += 1
            elapsed = time.time() - t0
            print(
                f"[cycle {cycles:>4}] scanned in {elapsed:5.1f}s — "
                f"opps this cycle: {found} — total: {total_found}"
            )
        except Exception as e:
            log.log("scan_error", error=str(e))
            print(f"⚠️  scan error: {e}")

        try:
            await asyncio.wait_for(stop.wait(), timeout=SCAN_INTERVAL_SEC)
        except asyncio.TimeoutError:
            pass

    if not DRY_RUN:
        print("Cancelling open orders...")
        try:
            cancel_all(client)
        except Exception as e:
            print(f"cancel_all error: {e}")
    await close_http()
    print("Done.")


if __name__ == "__main__":
    asyncio.run(main())
