"""
=========================================
CV6 AI Trading OS
Order fill tracker — Phase 2.5 (Goal 2)

"Accepted" (broker returned an order_id) is NOT "filled". This module
polls the broker's own order book until an order reaches a final state
(FILLED / REJECTED / CANCELLED) or a timeout elapses, and normalizes
Angel One's and MStock's differing field names into one shape. Callers
must gate any Position/Portfolio/Risk update on the result of this poll,
not on the immediate place_order() response.

Also used as a best-effort reconciliation path for the "broker timeout"
case: if place_order() itself raised/timed out with no order_id, we
don't know whether the exchange actually received it. find_order() can
match by symbol+side+quantity+recency instead of order_id so a
just-placed order can still be discovered even without its id.
"""

import time
from dataclasses import dataclass
from typing import Any, Optional

from loguru import logger

_FINAL_STATES = {"FILLED", "REJECTED", "CANCELLED"}


@dataclass
class FillResult:
    status: str          # FILLED | PARTIALLY_FILLED | REJECTED | CANCELLED | PENDING | UNKNOWN
    filled_qty: int = 0
    avg_price: float = 0.0
    order_id: str = ""
    raw_status: str = ""
    timed_out: bool = False


def _extract_order_list(raw: Any) -> list:
    if isinstance(raw, list):
        return raw
    if isinstance(raw, dict):
        if raw.get("success") is False:
            return []
        data = raw.get("data")
        if isinstance(data, list):
            return data
        # Some broker shapes nest a second level (e.g. {"data": {"orders": [...]}})
        if isinstance(data, dict):
            inner = data.get("orders") or data.get("data")
            if isinstance(inner, list):
                return inner
    return []


def normalize_status(raw: str) -> str:
    r = (raw or "").strip().lower()
    if r in ("complete", "completed", "filled", "success", "executed"):
        return "FILLED"
    if r in ("rejected", "error", "failed"):
        return "REJECTED"
    if r in ("cancelled", "canceled"):
        return "CANCELLED"
    if "partial" in r:
        return "PARTIALLY_FILLED"
    if r in ("open", "pending", "trigger pending", "open pending", "modified", "put order req received"):
        return "PENDING"
    return "UNKNOWN"


def _order_qty_fields(o: dict) -> tuple[int, float]:
    filled = (
        o.get("filledshares") or o.get("filled_quantity") or o.get("filledqty")
        or o.get("filled_qty") or 0
    )
    avg = (
        o.get("averageprice") or o.get("average_price") or o.get("avgprice") or 0
    )
    try:
        filled = int(float(filled))
    except (TypeError, ValueError):
        filled = 0
    try:
        avg = float(avg)
    except (TypeError, ValueError):
        avg = 0.0
    return filled, avg


def find_order(
    order_list: list,
    order_id: str = "",
    symbol: str = "",
    side: str = "",
    quantity: int = 0,
    since_ts: Optional[float] = None,
) -> Optional[dict]:
    """
    Primary match: exact order_id. Fallback (only used when order_id is
    unknown — the ambiguous broker-timeout case): most recent order for
    the same symbol+side+quantity placed at/after since_ts.
    """
    if order_id:
        for o in order_list:
            oid = str(o.get("orderid") or o.get("order_id") or "")
            if oid and oid == str(order_id):
                return o
        return None

    if not symbol:
        return None
    candidates = []
    for o in order_list:
        osym = str(o.get("tradingsymbol") or o.get("symbol", "")).upper()
        if osym != symbol.upper():
            continue
        oside = str(o.get("transactiontype") or o.get("transaction_type") or o.get("side", "")).upper()
        if side and oside and oside != side.upper():
            continue
        oqty = o.get("quantity") or o.get("qty") or 0
        try:
            oqty = int(float(oqty))
        except (TypeError, ValueError):
            oqty = 0
        if quantity and oqty and oqty != quantity:
            continue
        candidates.append(o)
    if not candidates:
        return None
    # Most recent candidate is the best guess for "the order we just placed"
    return candidates[-1]


def poll_until_final(
    broker,
    order_id: str = "",
    symbol: str = "",
    side: str = "",
    quantity: int = 0,
    since_ts: Optional[float] = None,
    max_wait_secs: float = 10.0,
    interval_secs: float = 1.0,
) -> FillResult:
    """
    Poll broker.orders() until the order reaches FILLED/REJECTED/CANCELLED
    or max_wait_secs elapses. When order_id is empty (ambiguous timeout
    case), falls back to matching by symbol+side+quantity+recency.
    """
    deadline = time.time() + max_wait_secs
    last_match: Optional[dict] = None

    while True:
        try:
            raw = broker.orders()
            order_list = _extract_order_list(raw)
        except Exception as e:
            logger.warning(f"[FillTracker] orders() poll failed: {e}")
            order_list = []

        match = find_order(order_list, order_id=order_id, symbol=symbol, side=side,
                            quantity=quantity, since_ts=since_ts)
        if match:
            last_match = match
            raw_status = str(match.get("status") or match.get("orderstatus") or "")
            status = normalize_status(raw_status)
            filled_qty, avg_price = _order_qty_fields(match)
            resolved_order_id = str(match.get("orderid") or match.get("order_id") or order_id or "")

            if status == "FILLED" or (status == "PARTIALLY_FILLED" and quantity and filled_qty >= quantity):
                return FillResult(status="FILLED", filled_qty=filled_qty or quantity, avg_price=avg_price,
                                   order_id=resolved_order_id, raw_status=raw_status)
            if status in ("REJECTED", "CANCELLED"):
                return FillResult(status=status, filled_qty=filled_qty, avg_price=avg_price,
                                   order_id=resolved_order_id, raw_status=raw_status)
            # else PENDING/PARTIALLY_FILLED/UNKNOWN → keep polling

        if time.time() >= deadline:
            break
        time.sleep(interval_secs)

    # Timed out without reaching a final state
    if last_match:
        raw_status = str(last_match.get("status") or "")
        status = normalize_status(raw_status)
        filled_qty, avg_price = _order_qty_fields(last_match)
        resolved_order_id = str(last_match.get("orderid") or last_match.get("order_id") or order_id or "")
        # Report PARTIALLY_FILLED explicitly if some quantity is confirmed filled
        if filled_qty > 0 and (not quantity or filled_qty < quantity):
            status = "PARTIALLY_FILLED"
        return FillResult(status=status, filled_qty=filled_qty, avg_price=avg_price,
                           order_id=resolved_order_id, raw_status=raw_status, timed_out=True)

    return FillResult(status="UNKNOWN", timed_out=True, order_id=order_id)


# Substrings in a broker error message that suggest the failure was a
# network/timeout issue rather than a definitive broker-side rejection —
# in that case the outcome is genuinely ambiguous and worth reconciling
# against the order book before assuming the order never went through.
_AMBIGUOUS_FAILURE_MARKERS = (
    "timeout", "timed out", "read timed out", "connection", "econnreset",
    "connection reset", "temporarily unavailable", "gateway", "502", "503", "504",
)


def looks_ambiguous(message: str) -> bool:
    m = (message or "").lower()
    return any(marker in m for marker in _AMBIGUOUS_FAILURE_MARKERS)
