"""
Kalshi API client — authentication + read-only portfolio methods.

Phase 1 of the auto-trade rollout. Order placement is intentionally NOT
implemented yet — that's Phase 2. This module only signs requests and
reads portfolio state.

Auth scheme: RSA-PSS signature of `timestamp + method + path`, base64-encoded,
sent in KALSHI-ACCESS-SIGNATURE header alongside KALSHI-ACCESS-KEY and
KALSHI-ACCESS-TIMESTAMP.
"""
import os
import time
import base64
from typing import Optional
import requests
from pathlib import Path
from dotenv import load_dotenv

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey

load_dotenv(r'E:\soccer\.env')

API_BASE = "https://api.elections.kalshi.com/trade-api/v2"

ACCESS_KEY = os.getenv("KALSHI_ACCESS_KEY")
PRIVATE_KEY_PATH = os.getenv("KALSHI_PRIVATE_KEY_PATH")

_private_key_cache: Optional[RSAPrivateKey] = None


def _load_private_key() -> RSAPrivateKey:
    global _private_key_cache
    if _private_key_cache is not None:
        return _private_key_cache
    if not PRIVATE_KEY_PATH:
        raise RuntimeError("KALSHI_PRIVATE_KEY_PATH not set in .env")
    pem_bytes = Path(PRIVATE_KEY_PATH).read_bytes()
    _private_key_cache = serialization.load_pem_private_key(pem_bytes, password=None)
    return _private_key_cache


def _sign(method: str, path: str, ts_ms: str) -> str:
    """RSA-PSS sign `timestamp + method + path`, return base64."""
    msg = (ts_ms + method.upper() + path).encode("utf-8")
    key = _load_private_key()
    sig = key.sign(
        msg,
        padding.PSS(
            mgf=padding.MGF1(hashes.SHA256()),
            salt_length=padding.PSS.DIGEST_LENGTH,
        ),
        hashes.SHA256(),
    )
    return base64.b64encode(sig).decode("utf-8")


def _auth_headers(method: str, path: str) -> dict:
    """Build the three required Kalshi auth headers."""
    if not ACCESS_KEY:
        raise RuntimeError("KALSHI_ACCESS_KEY not set in .env")
    ts_ms = str(int(time.time() * 1000))
    # `path` for signing must include the /trade-api/v2 prefix
    full_path = "/trade-api/v2" + path
    sig = _sign(method, full_path, ts_ms)
    return {
        "KALSHI-ACCESS-KEY": ACCESS_KEY,
        "KALSHI-ACCESS-SIGNATURE": sig,
        "KALSHI-ACCESS-TIMESTAMP": ts_ms,
        "Accept": "application/json",
    }


def _get(path: str, params: Optional[dict] = None) -> dict:
    headers = _auth_headers("GET", path)
    r = requests.get(API_BASE + path, headers=headers, params=params, timeout=15)
    if r.status_code != 200:
        raise RuntimeError(f"Kalshi GET {path} -> {r.status_code}: {r.text}")
    return r.json()


def _post(path: str, body: dict) -> dict:
    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 not in (200, 201):
        raise RuntimeError(f"Kalshi POST {path} -> {r.status_code}: {r.text}")
    return r.json()


# ─────────────────────────────────────────────────────────────────────
# Hard safety caps — module-level constants, enforced before HTTP send
# ─────────────────────────────────────────────────────────────────────
MAX_PRICE_CENTS = 99
MAX_CONTRACTS_PER_ORDER = 100
# 1.00 -> 2.00 on 2026-09-01. Kalshi trades whole contracts, so the xlow sizer
# now rounds UP to guarantee a bet is at least its $1 budget (user request);
# overshoot is bounded by one contract, i.e. budget + price, and a 0.99 ask
# would land at $1.98. This is the hard backstop, not the sizing knob -- the
# real per-bet target is daily_signals.AUTO_TRADE_PER_BET_CAP, still $1.00.
MAX_DOLLARS_PER_ORDER = 2.00   # user cap, raised 2026-09-01 for ceil sizing


def get_market(ticker: str) -> dict:
    """GET /markets/{ticker} — public market data (auth not strictly required, but consistent)."""
    return _get(f"/markets/{ticker}")


def place_limit_order(ticker: str, side: str, count: int, price_cents: int,
                      client_order_id: Optional[str] = None,
                      subaccount: Optional[int] = None) -> dict:
    """
    Place a LIMIT order via the V2 orders endpoint. Hard caps enforced BEFORE
    any HTTP request. V1 (/portfolio/orders) returns 410 as of 2026-07-23 --
    migrated per https://docs.kalshi.com/api-reference/orders/create-order-v2.

    side: 'yes' or 'no' -- kept as the external interface callers already use
    (daily_signals.place_auto_order, etc.), translated internally. V2's `side`
    field is YES-perspective only (bid=buy YES, ask=sell YES) with NO direct
    "buy NO" -- every real caller in this codebase only ever passes 'yes' (a
    separate guard upstream already rejects NO-side auto-trades), so 'no' is
    rejected here rather than silently mapped to the wrong economic action.
    count: integer number of contracts
    price_cents: limit price 1-99
    subaccount: route the order to a Kalshi subaccount. None (default) means
        the primary, which is byte-identical to passing 0.

        WHY THIS EXISTS: Kalshi nets long and short on the same ticker WITHIN a
        subaccount, so xlow buying a leg the maker is short partially closes
        each against the other. Separate subaccounts do not net, which removes
        the conflict instead of trading one bad outcome for another.

        VERIFIED 2026-09-01 against the live API, because the endpoint accepts
        unknown JSON fields SILENTLY -- a 201 proves nothing on its own. Three
        IOC bids priced far below the ask (so they could not fill) were sent
        and then located by order_id: `subaccount: 1` landed in subaccount 1,
        while both no-field and `subaccount_number: 1` landed in the primary.
        So the key is `subaccount`, an int, and a misspelling fails OPEN into
        the primary rather than erroring. Do not rename it casually.
    """
    side = side.lower()
    if side == "no":
        raise ValueError("side='no' not supported by this client -- V2 has no direct "
                         "buy-NO order; NO-side auto-trades are rejected upstream anyway")
    if side != "yes":
        raise ValueError(f"side must be 'yes', got {side}")
    if not isinstance(count, int) or count < 1:
        raise ValueError(f"count must be int >= 1, got {count}")
    if not isinstance(price_cents, int) or price_cents < 1 or price_cents > MAX_PRICE_CENTS:
        raise ValueError(f"price_cents must be int 1..{MAX_PRICE_CENTS}, got {price_cents}")
    if count > MAX_CONTRACTS_PER_ORDER:
        raise ValueError(f"count {count} exceeds MAX_CONTRACTS_PER_ORDER={MAX_CONTRACTS_PER_ORDER}")
    dollar_exposure = count * price_cents / 100.0
    if dollar_exposure > MAX_DOLLARS_PER_ORDER:
        raise ValueError(f"dollar exposure ${dollar_exposure:.2f} exceeds cap ${MAX_DOLLARS_PER_ORDER:.2f}")

    body = {
        "ticker": ticker,
        "side": "bid",                                # buy YES
        "count": f"{count:.2f}",
        "price": f"{price_cents / 100.0:.4f}",
        # IOC, not GTC: take the ask now or don't -- matches the existing
        # no-sub-ask-limits discipline (project_no_subask_limits), never leave
        # a resting order sitting unmonitored.
        "time_in_force": "immediate_or_cancel",
        "self_trade_prevention_type": "taker_at_cross",
    }
    if client_order_id:
        body["client_order_id"] = client_order_id
    if subaccount is not None:
        if not isinstance(subaccount, int) or subaccount < 0:
            raise ValueError(f"subaccount must be a non-negative int, got {subaccount!r}")
        body["subaccount"] = subaccount
    return _post("/portfolio/events/orders", body)


def cancel_order(order_id: str) -> dict:
    """Cancel via the V2 endpoint.

    This used the V1 path (/portfolio/orders/{id}) until 2026-08-15, which has
    returned 410 deprecated_v1_order_endpoint since the 2026-07-23 migration --
    placement was moved to V2 that day and cancellation was missed. Nothing
    caught it for three weeks because every order this project sent was
    immediate-or-cancel and so never rested long enough to be cancelled. The
    first resting order (the live maker) could not be cancelled at all.
    """
    path = f"/portfolio/events/orders/{order_id}"
    r = requests.delete(API_BASE + path, headers=_auth_headers("DELETE", path),
                        timeout=15)
    if r.status_code not in (200, 204):
        raise RuntimeError(f"Kalshi DELETE order -> {r.status_code}: {r.text}")
    return r.json() if r.text else {}


# ─────────────────────────────────────────────────────────────────────
# Read-only portfolio methods (Phase 1)
# ─────────────────────────────────────────────────────────────────────

def get_balance() -> dict:
    """GET /portfolio/balance — returns {'balance': cents_int, ...}"""
    return _get("/portfolio/balance")


# ── ACCOUNT-VALUE FLOOR (2026-09-29, user) ──────────────────────────────────
# "Set a stop loss for account value going below $50" -- Kalshi only. One
# number for the WHOLE Kalshi account (every subaccount), so moving cash
# between subaccounts can never trip or disarm it. Checked on xlow's order
# path before every order; a breach engages the kill files, which every
# order path and the maker guard already honour, and they stay engaged until
# a person deletes them.
KALSHI_FLOOR_USD = 50.0
KALSHI_SUBACCOUNTS = (0, 1)
KALSHI_FLOOR_KILLS = (r'E:\soccer\STOP_MAKER', r'E:\soccer\data\AUTO_TRADE_KILL')


def kalshi_account_value() -> Optional[float]:
    """Cash + open positions across every Kalshi subaccount, in dollars.

    None if ANY subaccount is unreadable: a partial sum would read low and
    halt everything on a network blip, or read high and hide a breach.
    """
    tot = 0.0
    for sub in KALSHI_SUBACCOUNTS:
        try:
            b = _get('/portfolio/balance', params={'subaccount': sub}) or {}
            tot += (float(b.get('balance', 0) or 0)
                    + float(b.get('portfolio_value', 0) or 0)) / 100.0
        except Exception:
            return None
    return tot


def kalshi_floor_breached(who: str) -> bool:
    """True if account value is below KALSHI_FLOOR_USD -- and if so, engage the
    kill files so Kalshi trading stays stopped until a person clears them.
    Durable on purpose: a stop that a scheduled job silently undoes is not a
    stop.

    Unreadable value -> True for the CALLER (do not place this order) but no
    kill files: a network blip should skip one order, not halt the strategy.
    """
    v = kalshi_account_value()
    if v is None:
        print(f'[KALSHI FLOOR] account value unreadable ({who}) -- skipping this order')
        return True
    if v >= KALSHI_FLOOR_USD:
        return False
    msg = (f'Kalshi floor: account value ${v:.2f} < ${KALSHI_FLOOR_USD:.2f} '
           f'(seen by {who}) -- all Kalshi trading halted. Delete the kill '
           f'file(s) by hand to resume.\n')
    for p in KALSHI_FLOOR_KILLS:
        try:
            with open(p, 'w', encoding='utf-8') as fh:
                fh.write(msg)
        except Exception:
            pass
    print('[KALSHI FLOOR] ' + msg.strip())
    return True


def _get_all(path: str, key: str, params: Optional[dict] = None,
             max_pages: int = 50) -> dict:
    """GET every page of a cursor-paginated portfolio endpoint.

    WHY THIS EXISTS. These endpoints return {'<key>': [...], 'cursor': '...'}
    and a single call yields ONE page. get_open_orders used to make that single
    call, so with 111 resting orders it reported 55 -- the live maker's
    duplicate detection and its own "too many resting" abort both read that
    halved number and neither could fire. Silent truncation on a portfolio
    read is worse than an error: every count downstream is quietly wrong.
    """
    out, seen, cursor, pages, dup = [], set(), None, 0, 0
    while pages < max_pages:
        p = dict(params or {})
        p.setdefault("limit", 200)
        if cursor:
            p["cursor"] = cursor
        d = _get(path, params=p) or {}
        batch = d.get(key) or []
        for item in batch:
            # DEDUPE ACROSS PAGES. The portfolio is live: orders rest, fill and
            # cancel while we are walking the cursor, and a row that shifts
            # position between two page reads can come back on both of them.
            # Measured on 2026-08-16 -- paging /portfolio/fills at limit=7
            # returned one fill twice while the maker was trading.
            #
            # For orders that is not cosmetic. The live maker treats "two
            # resting orders on one leg" as duplication and cancels BOTH, so a
            # row echoed across a page boundary would make it cancel a
            # perfectly good single order and re-post it next cycle.
            k = item.get("order_id") or item.get("fill_id") or item.get("ticker")
            if k is not None:
                if k in seen:
                    dup += 1
                    continue
                seen.add(k)
            out.append(item)
        cursor = d.get("cursor")
        pages += 1
        if not cursor or not batch:
            break
    return {key: out, "cursor": None, "pages": pages, "deduped": dup}


def get_positions() -> dict:
    """GET /portfolio/positions — all open market positions, every page."""
    return _get_all("/portfolio/positions", "market_positions")


def get_open_orders() -> dict:
    """GET /portfolio/orders — every resting order, following the cursor."""
    return _get_all("/portfolio/orders", "orders", {"status": "resting"})


def get_fills(after_ts: Optional[int] = None) -> dict:
    """GET /portfolio/fills — order fills, every page."""
    params = {}
    if after_ts is not None:
        params["min_ts"] = after_ts
    return _get_all("/portfolio/fills", "fills", params)
