"""
Polymarket US API client — order placement (Phase 2).

Mirrors kalshi_trader.py's shape: hard safety caps enforced BEFORE any HTTP
request, minimal surface (place + cancel + read fills). Uses poly_us_auth's
Ed25519-signed request helpers.

Order model (docs.polymarket.us/concepts/orders): price is always the YES
side (0 < price < 1, tick 0.01); quantity is in CONTRACTS, not dollars —
notional = quantity * price. To bet a side's "YES" (matches xlow's bet
convention exactly): marketSlug = that side's specific market slug (per-team
market, from the event's `markets[].slug`), intent = ORDER_INTENT_BUY_LONG.
"""
import os
import math
import datetime as _dt
from typing import Optional

import requests

from poly_us_auth import _auth_headers, API_BASE

# ─────────────────────────────────────────────────────────────────────
# Hard safety caps — module-level constants, enforced before HTTP send.
# Account balance is currently ~$25 (2026-07-23) -- keep these SMALL until
# the account is funded further and this leg has its own forward track
# record, same bar BR2/CO1/NWSL had to clear on the Poly.com side.
# ─────────────────────────────────────────────────────────────────────
MAX_PRICE = 0.99
MIN_PRICE = 0.01
MAX_QUANTITY_PER_ORDER = 50
MAX_PRICE_DRIFT = 0.20   # fraction the live price may move from the signal
MAX_DOLLARS_PER_ORDER = 1.00   # user cap, 2026-07-23 -- raise only when they say so

# The f10 variant's price floor. Imported from the forward test so the two can
# never drift; the fallback is the same 0.10 and exists only so a research-path
# import failure cannot silently remove the floor.
try:
    import sys as _sys, os as _os
    _sys.path.insert(0, _os.path.join(_os.path.dirname(_os.path.dirname(
        _os.path.abspath(__file__))), 'research'))
    from forward_test_unique_book import XLOW_PRICE_FLOOR, BAND
except Exception:
    XLOW_PRICE_FLOOR = 0.10
    BAND = (0.05, 0.12)


def place_limit_order(market_slug: str, price: float, quantity: float,
                       intent: str = "ORDER_INTENT_BUY_LONG",
                       tif: str = "TIME_IN_FORCE_DAY",
                       post_only: bool = False,
                       ttl_secs: int = 0) -> dict:
    """
    Place a LIMIT order. Hard caps enforced BEFORE any HTTP request.
    price: 0 < price < 1 (YES-side price)
    quantity: contracts (notional = quantity * price)

    post_only -- sends `participateDontInitiate`, which the venue documents as
        "order must rest on the book prior to matching (maker only). Order will
        be rejected if it would immediately match." REQUIRED for the maker: the
        entire economic case is receiving the 0.0125*p(1-p) maker rebate instead
        of paying the 0.06*p(1-p) taker fee, a ~5x swing, and a single crossing
        order flips the sign on that book. Kalshi's maker has always run
        post_only for the same reason.

    ttl_secs -- when >0, sends TIME_IN_FORCE_GOOD_TILL_DATE with `goodTillTime`
        that many seconds out, so the exchange expires the order by itself.
        This is the safety net the Kalshi maker relies on (MAKER_TTL_S=90): a
        crash or a power cut cannot leave anything resting. That failure is not
        hypothetical here -- this machine sat off for 2.5 days unnoticed on
        2026-08-10 and had a 15-hour silent outage on 2026-08-18. Default DAY
        orders would rest until the session closes.

    NOTE both fields are documented but UNVERIFIED on this account. Verify with
    research/polyus_capability_probe.py before trusting them. Docs and reality
    have diverged here before: cancel_order below called an endpoint that simply
    did not exist, and nothing noticed for the whole period it was auto-trading.
    """
    if not (MIN_PRICE <= price <= MAX_PRICE):
        raise ValueError(f"price must be in [{MIN_PRICE},{MAX_PRICE}], got {price}")
    if not (0 < quantity <= MAX_QUANTITY_PER_ORDER):
        raise ValueError(f"quantity must be in (0,{MAX_QUANTITY_PER_ORDER}], got {quantity}")
    dollar_exposure = quantity * price
    if dollar_exposure > MAX_DOLLARS_PER_ORDER:
        raise ValueError(f"dollar exposure ${dollar_exposure:.2f} exceeds cap ${MAX_DOLLARS_PER_ORDER:.2f}")

    body = {
        "marketSlug": market_slug,
        "type": "ORDER_TYPE_LIMIT",
        "price": {"value": f"{price:.2f}", "currency": "USD"},
        "quantity": round(quantity, 2),
        "tif": tif,
        "intent": intent,
    }
    if post_only:
        body["participateDontInitiate"] = True
    if ttl_secs and ttl_secs > 0:
        # RFC3339 UTC. The venue takes `goodTillTime` as a string alongside
        # TIME_IN_FORCE_GOOD_TILL_DATE; there is no unix-timestamp field.
        expires = _dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(seconds=ttl_secs)
        body["tif"] = "TIME_IN_FORCE_GOOD_TILL_DATE"
        body["goodTillTime"] = expires.strftime('%Y-%m-%dT%H:%M:%SZ')
    headers = _auth_headers("POST", "/v1/orders")
    headers["Content-Type"] = "application/json"
    r = requests.post(API_BASE + "/v1/orders", headers=headers, json=body, timeout=15)
    if r.status_code not in (200, 201):
        raise RuntimeError(f"POST /v1/orders -> {r.status_code}: {r.text}")
    return r.json()


def size_for_dollars(dollars: float, price: float) -> float:
    """Contracts to hit a target dollar notional at this price, capped, floored at 1 tick worth.
    Rounds DOWN (not to-nearest) -- round-to-nearest could push cost slightly
    OVER the dollar cap (e.g. 1.00/0.18=5.5555 -> round()=5.56 -> 5.56*0.18=
    $1.0008 > $1.00 cap), tripping place_limit_order's own safety check and
    rejecting the order entirely. Found 2026-07-24 on a real $1.00-cap order."""
    if not (MIN_PRICE <= price <= MAX_PRICE):
        raise ValueError(f"price out of range: {price}")
    dollars = min(dollars, MAX_DOLLARS_PER_ORDER)
    qty = math.floor((dollars / price) * 100) / 100
    return min(qty, MAX_QUANTITY_PER_ORDER)


def open_orders() -> list:
    """Every resting order on the account. GET /v1/orders is 501; /open works."""
    headers = _auth_headers("GET", "/v1/orders/open")
    r = requests.get(API_BASE + "/v1/orders/open", headers=headers, timeout=15)
    if r.status_code != 200:
        raise RuntimeError(f"GET /v1/orders/open -> {r.status_code}: {r.text}")
    d = r.json() or {}
    return d.get('orders') or d.get('data') or []


def cancel_order(order_id: str, market_slug: str) -> dict:
    """Cancel a resting order, then VERIFY it is actually gone.

    FIXED 2026-07-29. The previous implementation called
    `DELETE /v1/orders/{id}`, which does not exist and returns 404 -- so this
    account had NO working cancel for the entire period it was auto-trading.
    Nothing surfaced it because nothing had ever needed to cancel until a
    capability probe left a resting sell that could not be pulled.

    Uses the BATCHED endpoint rather than the documented single-order path
    (`POST /v1/order/{id}/cancel`) because batched is the one we have actually
    observed working end-to-end here: it returned 200 and the order verifiably
    left the book. Prefer the path with evidence over the path with docs.

    The response CANNOT be trusted on its own -- Polymarket's own docs say
    `canceledOrderIds` is "an echo of the request, not a confirmation", and the
    exchange may silently ignore unknown ids. So this re-reads open orders and
    raises if the order is still resting. A cancel that reports success while
    leaving an order live is the worst possible failure for a market maker.
    """
    path = '/v1/orders/batched/cancel'
    body = {"orders": [{"orderId": order_id, "marketSlug": market_slug}]}
    headers = _auth_headers("POST", path)
    headers["Content-Type"] = "application/json"
    r = requests.post(API_BASE + path, headers=headers, json=body, timeout=15)
    if r.status_code != 200:
        raise RuntimeError(f"cancel {order_id} -> {r.status_code}: {r.text}")
    resp = r.json() if r.text else {}

    still = [o for o in open_orders() if o.get('id') == order_id]
    if still:
        raise RuntimeError(
            f"CANCEL NOT EFFECTIVE: {order_id} is still resting after a 200 "
            f"response ({resp}). Cancel it manually.")
    return resp


def get_trades(limit: int = 100) -> list:
    """Our fills. FIXED 2026-07-29: `GET /v1/trades` does not exist (404) --
    trades live on the portfolio activities feed. `isAggressor` is the
    taker flag, which is what a maker strategy needs to confirm it was
    passive (and therefore earned the rebate rather than paying the fee)."""
    path = '/v1/portfolio/activities'
    headers = _auth_headers("GET", path)
    r = requests.get(API_BASE + path, headers=headers,
                     params={'types': 'ACTIVITY_TYPE_TRADE', 'limit': limit},
                     timeout=15)
    if r.status_code != 200:
        raise RuntimeError(f"GET {path} -> {r.status_code}: {r.text}")
    return (r.json() or {}).get('activities', [])


# ─────────────────────────────────────────────────────────────────────
# Auto-trade hook (Phase 3) — mirrors daily_signals.place_auto_order's
# gate sequence exactly, against poly_us_prices instead of kalshi_trader.
# ─────────────────────────────────────────────────────────────────────
import json
import os
from datetime import datetime, timezone
from pathlib import Path

AUTO_TRADE_ENABLED   = True    # LIVE 2026-08-30 (user), $1/order, Poly-US only.
# Turned on knowing the expectation is negative: the measured edge is +0.31c
# against a ~0.7c half-spread, reproduced on two signals five months apart, and
# xlow has lost $51.94 real on Kalshi over 89 settled legs. Poly-US is not a
# better price -- rescoring the frozen curve at Poly-US asks moved 175 bets by
# +$4.40 -- it is a SEPARATE ACCOUNT, which ends the maker interference.
# MAX_DOLLARS_PER_ORDER caps the damage at $1 a bet. Kill: create
# data/AUTO_TRADE_POLYUS_KILL (checked per order, no restart needed).
# Same reason as daily_signals.AUTO_TRADE_ENABLED -- xlow's line movement
# (+0.31c) is smaller than the half-spread paid to enter (0.67c), so the edge
# does not survive execution. Poly-US spreads are WIDER than Kalshi's, so this
# leg is the worse of the two, not the better one. Signals still log; only real
# order placement is off. See memory/project_xlow_spread_verdict.md.
# Both this flag AND daily_signals.AUTO_TRADE_ENABLED must be True to trade real.
AUTO_TRADE_KILL_FILE = r'E:\soccer\data\AUTO_TRADE_POLYUS_KILL'
AUTO_TRADE_LOG       = r'E:\soccer\data\auto_trade_polyus_log.json'
AUTO_TRADE_ERR_LOG   = r'E:\soccer\logs\auto_trade_polyus_errors.log'


def _load_auto_log():
    try:
        return json.loads(Path(AUTO_TRADE_LOG).read_text())
    except (FileNotFoundError, ValueError):
        return []


def _save_auto_log(log):
    Path(AUTO_TRADE_LOG).write_text(json.dumps(log, indent=2))


def _already_placed(client_order_id):
    return any(e.get('client_order_id') == client_order_id for e in _load_auto_log())


def _log_error(msg):
    try:
        Path(AUTO_TRADE_ERR_LOG).parent.mkdir(parents=True, exist_ok=True)
        with open(AUTO_TRADE_ERR_LOG, 'a', encoding='utf-8') as f:
            f.write(f"{datetime.now(timezone.utc).isoformat(timespec='seconds')}  {msg}\n")
    except Exception:
        pass
    print(f"  [AUTO-POLYUS] ERROR: {msg}")


def place_auto_order(signal: dict) -> Optional[dict]:
    """
    Auto-place a Polymarket US limit-at-ask order for an xlow signal.
    Returns dict with order details on success, None on skip/failure.
    Mirrors daily_signals.place_auto_order's gate order:
      1. AUTO_TRADE_ENABLED  2. kill-switch  3. league WHITELIST then exclude
      4. find matching market  5. idempotent client_order_id
      6. live BBO fetch + drift check  7. sanity  8. place  9. log + email
    """
    import poly_us_prices as prices

    if not AUTO_TRADE_ENABLED:
        return None
    if os.path.exists(AUTO_TRADE_KILL_FILE):
        print("  [AUTO-POLYUS] kill-switch engaged -> skip")
        return None

    div = signal.get('div')
    # WHITELIST FIRST, and it must not be silently skipped. This path checked
    # only the EXCLUDE list until 2026-08-23, so it would have placed on any
    # league that merely wasn't blacklisted -- including PL1/USL and everything
    # outside the selected scope. Inert while AUTO_TRADE_ENABLED is False here,
    # but live_monitor's venue cascade calls THIS placer BEFORE the Kalshi one,
    # so flipping that flag would have traded the wrong leagues first.
    #
    # The bare `except: pass` below is why it mattered: an import failure used to
    # mean "no gate at all". A whitelist that fails open is not a whitelist, so
    # this one fails CLOSED -- if the scope cannot be read, nothing trades.
    try:
        from daily_signals import AUTO_TRADE_LEAGUES, AUTO_TRADE_EXCLUDE_LEAGUES
    except Exception as e:
        print(f"  [AUTO-POLYUS] cannot read league scope ({e}) -> refusing to trade")
        return None
    if AUTO_TRADE_LEAGUES is not None and div not in AUTO_TRADE_LEAGUES:
        print(f"  [AUTO-POLYUS] {div} not in AUTO_TRADE_LEAGUES -> paper only")
        return None
    if div in AUTO_TRADE_EXCLUDE_LEAGUES:
        print(f"  [AUTO-POLYUS] {div} excluded from real orders -> paper only")
        return None

    # Execution-only longshot filter (AUTO_TRADE_MIN_PIN_FAIR). Imported rather
    # than reimplemented so the Kalshi and Poly-US paths can never drift apart --
    # otherwise a signal blocked on one venue would still fund the other.
    try:
        from daily_signals import pin_fair_ok
        if not pin_fair_ok(signal):
            return None
    except ImportError:
        pass

    side = 'away' if 'AWAY' in signal.get('bet', '').upper() else 'home'
    home, away = signal.get('home'), signal.get('away')
    client_order_id = f"polyus-{signal.get('fixture_id')}-{side}"
    if _already_placed(client_order_id):
        print(f"  [AUTO-POLYUS] {client_order_id} already in log -> skip (idempotent)")
        return None

    market = prices.find_market(div, home, away, side)
    if not market:
        return None   # no Poly-US market for this fixture -- silent, not an error

    bbo = prices.get_bbo(market['slug'])
    if not bbo or not bbo.get('bestAsk'):
        return None
    live_ask = bbo['bestAsk']
    if not (MIN_PRICE <= live_ask <= MAX_PRICE):
        print(f"  [AUTO-POLYUS] {market['slug']} live_ask={live_ask} out of range -> skip")
        return None

    # F10: TRADE THE PRICE-FLOOR VARIANT, NOT THE RAW RULE (2026-08-30, user).
    # xlow_f10 is the frozen primary minus entries priced under 10c. The sub-10c
    # bucket went 0W-12L in the backtest and the floor improved every metric
    # (ROI +24.7 -> +30.2%, z 2.59 -> 2.74, bootstrap CI floor +8.3 -> +12.9).
    #
    # EXECUTION-ONLY, exactly like AUTO_TRADE_MIN_PIN_FAIR above it: the signal
    # still fires, still lands in pending_bets, and still counts unfiltered in
    # the frozen forward test. The floor decides what gets MONEY, never what
    # gets measured -- if it leaks into research/ the test starts fitting the
    # same data that chose the cutoff.
    #
    # Checked against the LIVE ask, not the signal ask: the rule's 10c refers to
    # what a bet costs, and what it costs here is the Poly-US price we are about
    # to pay, which is not the price the signal fired on.
    if live_ask < XLOW_PRICE_FLOOR:
        print(f"  [AUTO-POLYUS] {market['slug']} ask={live_ask:.3f} below the f10 "
              f"floor {XLOW_PRICE_FLOOR:.2f} -> paper only")
        return None

    # DRIFT, NOT A BAND RE-TEST (corrected 2026-09-07).
    #
    # The first version of this guard recomputed CLV at the live ask and
    # required it inside BAND. That is wrong: the rule FIRES on the mid and
    # ALWAYS pays the ask, and the forward test scores at the ask -- so the
    # +23%% headline is already net of that spread. Demanding the ask clear the
    # band is a different, never-measured strategy, and it refused ~75%% of
    # signals: Elche v Real Sociedad was skipped at ask 0.310 vs fair 0.324
    # (CLV +4.2%%) when the rule had fired at mid 0.3055 (+5.7%%). Normal.
    #
    # What actually needed catching is the price MOVING between signal and
    # fill -- FC Sion, mid 0.50 at signal, ask 0.99 at placement.
    #
    # The reference is the mid recorded at signal time, or fair*(1-clv) which
    # reconstructs it. NOT the stored ask: live_monitor overwrites that with
    # the live price on the Poly-US fallback path, which is how the original
    # 20%% check was silently defeated.
    _ref = None
    try:
        _m = float(signal.get('entry_p_mid') or 0)
        if 0 < _m < 1:
            _ref = _m
    except (TypeError, ValueError):
        pass
    if _ref is None:
        try:
            _f2 = float(signal.get('pin_fair') or 0)
            _c2 = float(signal.get('clv_poly') or 0) / 100.0
            _p2 = _f2 * (1.0 - _c2)
            if 0 < _p2 < 1:
                _ref = _p2
        except (TypeError, ValueError):
            pass
    if _ref is not None and abs(live_ask - _ref) / _ref > MAX_PRICE_DRIFT:
        print(f"  [AUTO-POLYUS] {market['slug']} drifted: signal {_ref:.3f} -> "
              f"live {live_ask:.3f} ({abs(live_ask-_ref)/_ref*100:.0f}%) -> skip")
        return None

    qty = size_for_dollars(MAX_DOLLARS_PER_ORDER, live_ask)
    if qty <= 0:
        return None
    dollar_committed = round(qty * live_ask, 4)

    try:
        resp = place_limit_order(market['slug'], live_ask, qty)
    except Exception as e:
        _log_error(f"place_limit_order failed {market['slug']} {qty}@{live_ask}: {e}")
        return None

    record = {
        'placed_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
        'order_id': resp.get('id'),
        'client_order_id': client_order_id,
        'market_slug': market['slug'],
        'quantity': qty,
        'price': live_ask,
        'dollar_committed': dollar_committed,
        'fixture_id': signal.get('fixture_id'),
        'home': home, 'away': away, 'div': div, 'bet': signal.get('bet'),
    }
    log = _load_auto_log()
    log.append(record)
    _save_auto_log(log)
    print(f"  [AUTO-POLYUS] PLACED {market['slug']} {qty}@{live_ask} (${dollar_committed:.2f}) id={resp.get('id')}")

    # No separate confirmation email (2026-07-24, user instruction: combine
    # xlow + auto-trade into one email) -- the batched XLOW ALERT already
    # shows this fill in its "Real Trade" column via live_monitor's
    # _log_pending_bets, which reads this function's return value.
    return record
