import logging
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional, Tuple

from app.v1.utils.confidence import confidence_at_least, normalize_confidence

logger = logging.getLogger(__name__)


PAPER_TRADES_COLLECTION = "paper_trades"
PAPER_ACCOUNTS_COLLECTION = "paper_accounts"
PAPER_BALANCE_DAILY_COLLECTION = "paper_balance_daily"
ZERODHA_ORDER_MARGIN_CACHE_COLLECTION = "zerodha_order_margin_cache"
PAPER_TRADE_AUDIT_COLLECTION = "paper_trade_audit"


def _env_int(name: str, default: int, *, min_value: int = 0, max_value: int = 1_000_000_000) -> int:
    raw = os.getenv(name)
    if raw is None or str(raw).strip() == "":
        return int(default)
    try:
        v = int(str(raw).strip())
    except Exception:
        return int(default)
    return int(max(min_value, min(max_value, v)))


def _env_float(name: str, default: float, *, min_value: float = 0.0, max_value: float = 1e18) -> float:
    raw = os.getenv(name)
    if raw is None or str(raw).strip() == "":
        return float(default)
    try:
        v = float(str(raw).strip())
    except Exception:
        return float(default)
    if v != v or v in (float("inf"), float("-inf")):
        return float(default)
    return float(max(min_value, min(max_value, v)))


def _env_bool(name: str, default: bool) -> bool:
    raw = os.getenv(name)
    if raw is None:
        return bool(default)
    s = str(raw).strip().lower()
    if s in {"1", "true", "yes", "y", "on"}:
        return True
    if s in {"0", "false", "no", "n", "off"}:
        return False
    return bool(default)


def _paper_tz_name() -> str:
    # Prefer MARKET_TZ as the single source of truth.
    return (os.getenv("MARKET_TZ") or os.getenv("PAPER_TRADE_TZ") or "Asia/Kolkata").strip() or "Asia/Kolkata"


def _is_paper_trade_creation_allowed(now_utc: Optional[datetime] = None) -> bool:
    """Return True only within PAPER_TRADE_START <= now < PAPER_TRADE_END (in MARKET_TZ)."""
    from app.v1.utils.market_time import is_paper_trade_creation_time

    return is_paper_trade_creation_time(now_utc)


def close_paper_trades_for_intraday_time_exit(db, *, now_utc: Optional[datetime] = None) -> Dict[str, Any]:
    """Force-close OPEN intraday paper trades at PAPER_TRADE_END.

    Exit price: last candle close (trade.last_price_close; best-effort fallback to entry)
    Exit reason: INTRADAY_TIME_EXIT

    Returns:
      {"ran": bool, "scanned": int, "closed": int}
    """

    from app.v1.utils.market_time import is_paper_trade_end_reached

    now = _to_utc_naive(now_utc or _now_utc())
    if not is_paper_trade_end_reached(now):
        return {"ran": False, "scanned": 0, "closed": 0}

    ensure_paper_game_indexes(db)
    col = db[PAPER_TRADES_COLLECTION]

    q = {
        "status": "OPEN",
        # Intraday product only
        "$or": [
            {"settings.product": {"$exists": False}},
            {"settings.product": None},
            {"settings.product": ""},
            {"settings.product": "MIS"},
        ],
    }

    scanned = 0
    closed = 0

    for tr in col.find(
        q,
        {
            "_id": 1,
            "user_id": 1,
            "account_id": 1,
            "status": 1,
            "symbol": 1,
            "direction": 1,
            "entry_price": 1,
            "quantity": 1,
            "reserved_amount": 1,
            "trade_value": 1,
            "last_price_close": 1,
        },
    ):
        scanned += 1
        try:
            status_from = (tr.get("status") or "").strip().upper() or "OPEN"
            direction = (tr.get("direction") or "").strip().upper()

            qty = int(tr.get("quantity") or 0)
            qty = max(0, qty)
            entry = _safe_float(tr.get("entry_price"))
            exit_px = _safe_float(tr.get("last_price_close"))

            # Best-effort fallback when we don't have a candle close.
            if exit_px is None:
                exit_px = entry

            per_unit = 0.0
            if entry is not None and exit_px is not None:
                if direction == "LONG":
                    per_unit = float(exit_px) - float(entry)
                elif direction == "SHORT":
                    per_unit = float(entry) - float(exit_px)

            realized_total = float(per_unit) * float(qty or 0)

            patch: Dict[str, Any] = {
                "status": "CLOSED",
                "exit_price": float(exit_px) if exit_px is not None else None,
                "exit_reason": "INTRADAY_TIME_EXIT",
                "closed_at": _now_utc(),
                "realized_pnl_per_unit": float(per_unit),
                "realized_pnl": float(realized_total),
                "current_unrealized_pnl_per_unit": 0.0,
                "current_unrealized_pnl": 0.0,
                "updated_at": _now_utc(),
            }
            col.update_one({"_id": tr.get("_id")}, {"$set": patch})

            _apply_trade_close_to_account(
                db,
                user_id=str(tr.get("user_id") or ""),
                account_id=str(tr.get("account_id") or ""),
                trade_id=str(tr.get("_id")),
                trade_value=_safe_float(tr.get("reserved_amount") or tr.get("trade_value") or 0.0) or 0.0,
                pnl_total=float(realized_total),
                closed_dt=_now_utc(),
                exit_reason="INTRADAY_TIME_EXIT",
            )

            try:
                _audit_event(
                    db,
                    user_id=str(tr.get("user_id") or ""),
                    account_id=str(tr.get("account_id") or ""),
                    symbol=str(tr.get("symbol") or ""),
                    event="TRADE_CLOSED",
                    reason="INTRADAY_TIME_EXIT",
                    details={"trade_id": str(tr.get("_id")), "status_from": status_from},
                )
            except Exception:
                pass

            try:
                _mark_intraday_equity(
                    db,
                    user_id=str(tr.get("user_id") or ""),
                    account_id=str(tr.get("account_id") or ""),
                    reason="INTRADAY_TIME_EXIT",
                )
            except Exception:
                pass

            closed += 1
        except Exception:
            logger.exception("INTRADAY_TIME_EXIT close failed for paper trade")

    return {"ran": True, "scanned": int(scanned), "closed": int(closed)}


def _paper_day_key(dt_utc: datetime) -> str:
    """Compute YYYY-MM-DD key in PAPER_TRADE_TZ (default Asia/Kolkata)."""
    try:
        from zoneinfo import ZoneInfo  # py3.9+

        tz = ZoneInfo(_paper_tz_name())
        utc = ZoneInfo("UTC")
        aware_utc = dt_utc.replace(tzinfo=None).replace(tzinfo=utc)
        return aware_utc.astimezone(tz).date().isoformat()
    except Exception:
        return dt_utc.date().isoformat()


def _account_defaults() -> Dict[str, Any]:
    return {
        "starting_balance": _env_float("PAPER_ACCOUNT_STARTING_BALANCE", 1_000_000.0, min_value=0.0),
        "min_trade_value": _env_float("PAPER_ACCOUNT_MIN_TRADE_VALUE", 100.0, min_value=0.0),
        "max_trade_value": _env_float("PAPER_ACCOUNT_MAX_TRADE_VALUE", 50_000.0, min_value=0.0),
        "max_quantity": int(_env_float("PAPER_ACCOUNT_MAX_QUANTITY", 10_000.0, min_value=1.0, max_value=1e9)),
        "max_loss_pct": _env_float("PAPER_ACCOUNT_MAX_LOSS_PCT", -1.0, min_value=-1.0, max_value=100.0),
        "max_profit_pct": _env_float("PAPER_ACCOUNT_MAX_PROFIT_PCT", -1.0, min_value=-1.0, max_value=500.0),
        "eod_exit": _env_bool("PAPER_ACCOUNT_EOD_EXIT", True),
        # Zerodha product used for margin requirement lookups.
        "product": (os.getenv("PAPER_TRADE_PRODUCT") or "MIS").strip().upper() or "MIS",
    }


def ensure_paper_game_indexes(db) -> None:
    try:
        db[PAPER_ACCOUNTS_COLLECTION].create_index(
            [("user_id", 1), ("account_id", 1)],
            name="paper_accounts_user_account",
            unique=True,
        )
        db[PAPER_BALANCE_DAILY_COLLECTION].create_index(
            [("user_id", 1), ("account_id", 1), ("day", 1)],
            name="paper_balance_daily_user_day",
            unique=True,
        )

        # Cache Zerodha order/basket margin lookups to reduce API calls.
        db[ZERODHA_ORDER_MARGIN_CACHE_COLLECTION].create_index(
            [("user_id", 1), ("account_id", 1), ("key", 1)],
            name="zerodha_order_margin_cache_key",
            unique=True,
        )
        # TTL index
        db[ZERODHA_ORDER_MARGIN_CACHE_COLLECTION].create_index(
            [("expires_at", 1)],
            name="zerodha_order_margin_cache_ttl",
            expireAfterSeconds=0,
        )

        db[PAPER_TRADE_AUDIT_COLLECTION].create_index(
            [("user_id", 1), ("account_id", 1), ("ts", -1)],
            name="paper_trade_audit_user_ts",
        )
    except Exception:
        logger.exception("Failed to ensure paper game indexes")


def _audit_event(
    db,
    *,
    user_id: str,
    account_id: str,
    symbol: Optional[str],
    event: str,
    reason: str,
    details: Optional[Dict[str, Any]] = None,
) -> None:
    """Best-effort audit log to Mongo for skip/close/open decisions."""

    try:
        if not user_id or not account_id:
            return
        ensure_paper_game_indexes(db)
        doc: Dict[str, Any] = {
            "user_id": str(user_id),
            "account_id": str(account_id),
            "symbol": _normalize_symbol(symbol) if symbol else None,
            "event": str(event or ""),
            "reason": str(reason or ""),
            "details": details if isinstance(details, dict) else {},
            "ts": _now_utc(),
        }
        # Remove Nones
        doc = {k: v for k, v in doc.items() if v is not None}
        db[PAPER_TRADE_AUDIT_COLLECTION].insert_one(doc)
    except Exception:
        # never break trading loop
        return


def _env_str(name: str, default: str) -> str:
    raw = os.getenv(name)
    if raw is None:
        return str(default)
    s = str(raw).strip()
    return s if s else str(default)


def _paper_margin_required() -> bool:
    # When Zerodha client is present, do not fall back to notional sizing if margin is unavailable.
    return _env_bool("PAPER_MARGIN_REQUIRED", True)


def _long_pending_max_candles() -> int:
    return _env_int("PAPER_LONG_PENDING_MAX_CANDLES", 2, min_value=0, max_value=500)


def _long_open_max_candles() -> int:
    return _env_int("PAPER_LONG_OPEN_MAX_CANDLES", 2, min_value=0, max_value=500)


def _allow_opposite_signal_exit() -> bool:
    return _env_bool("PAPER_EXIT_ON_OPPOSITE_VERY_STRONG", True)


def _paper_eod_cutoff_hour() -> int:
    return _env_int("PAPER_EOD_CUTOFF_HOUR", 15, min_value=0, max_value=23)


def _paper_eod_cutoff_minute() -> int:
    return _env_int("PAPER_EOD_CUTOFF_MINUTE", 29, min_value=0, max_value=59)


def _is_eod_cutoff_reached(candle_ts_utc: Optional[datetime]) -> bool:
    """Return True if candle timestamp (UTC naive) is at/after EOD cutoff in PAPER_TRADE_TZ.

    Best-effort: if tz conversion fails, return False.
    """

    if candle_ts_utc is None:
        return False
    try:
        from zoneinfo import ZoneInfo

        tz = ZoneInfo(_paper_tz_name())
        utc = ZoneInfo("UTC")
        aware_utc = candle_ts_utc.replace(tzinfo=None).replace(tzinfo=utc)
        loc = aware_utc.astimezone(tz)
        h = _paper_eod_cutoff_hour()
        m = _paper_eod_cutoff_minute()
        return (loc.hour > h) or (loc.hour == h and loc.minute >= m)
    except Exception:
        return False


def close_paper_trades_for_eod(db, *, now_utc: Optional[datetime] = None) -> Dict[str, Any]:
    """Force-close any remaining intraday paper trades after EOD cutoff.

    This is a safety net to ensure intraday-only paper trades do not remain OPEN
    just because a candle update didn't arrive after the cutoff.

    Returns:
      {"ran": bool, "scanned": int, "closed": int}
    """

    now = _to_utc_naive(now_utc or _now_utc())
    if not _is_eod_cutoff_reached(now):
        return {"ran": False, "scanned": 0, "closed": 0}

    ensure_paper_game_indexes(db)
    col = db[PAPER_TRADES_COLLECTION]

    q = {
        "status": {"$in": ["OPEN", "PENDING"]},
        "settings.eod_exit": True,
        # Intraday product only
        "$or": [
            {"settings.product": {"$exists": False}},
            {"settings.product": None},
            {"settings.product": ""},
            {"settings.product": "MIS"},
        ],
    }

    scanned = 0
    closed = 0

    for tr in col.find(
        q,
        {
            "_id": 1,
            "user_id": 1,
            "account_id": 1,
            "status": 1,
            "symbol": 1,
            "direction": 1,
            "entry_price": 1,
            "quantity": 1,
            "reserved_amount": 1,
            "trade_value": 1,
            "last_price_close": 1,
            "opened_at": 1,
            "created_at": 1,
            "snapshot_id": 1,
            "settings": 1,
        },
    ):
        scanned += 1
        try:
            status = (tr.get("status") or "").strip().upper() or "OPEN"
            direction = (tr.get("direction") or "").strip().upper()

            qty = int(tr.get("quantity") or 0)
            if qty <= 0:
                qty = 0

            entry = _safe_float(tr.get("entry_price"))
            exit_px = _safe_float(tr.get("last_price_close"))

            # Best-effort: if last_price_close missing, fall back to entry (pnl=0).
            if exit_px is None:
                exit_px = entry

            per_unit = 0.0
            if entry is not None and exit_px is not None:
                if direction == "LONG":
                    per_unit = float(exit_px) - float(entry)
                elif direction == "SHORT":
                    per_unit = float(entry) - float(exit_px)

            realized_total = float(per_unit) * float(qty or 0)

            patch: Dict[str, Any] = {
                "status": "CLOSED",
                "exit_price": float(exit_px) if exit_px is not None else None,
                "exit_reason": "EOD_EXIT",
                "closed_at": _now_utc(),
                "realized_pnl_per_unit": float(per_unit),
                "realized_pnl": float(realized_total),
                "current_unrealized_pnl_per_unit": 0.0,
                "current_unrealized_pnl": 0.0,
                "updated_at": _now_utc(),
            }

            col.update_one({"_id": tr.get("_id")}, {"$set": patch})

            _apply_trade_close_to_account(
                db,
                user_id=str(tr.get("user_id") or ""),
                account_id=str(tr.get("account_id") or ""),
                trade_id=str(tr.get("_id")),
                trade_value=_safe_float(tr.get("reserved_amount") or tr.get("trade_value") or 0.0) or 0.0,
                pnl_total=float(realized_total),
                closed_dt=_now_utc(),
                exit_reason="EOD_EXIT",
            )

            try:
                _audit_event(
                    db,
                    user_id=str(tr.get("user_id") or ""),
                    account_id=str(tr.get("account_id") or ""),
                    symbol=str(tr.get("symbol") or ""),
                    event="TRADE_CLOSED",
                    reason="EOD_EXIT",
                    details={"trade_id": str(tr.get("_id")), "status_from": status},
                )
            except Exception:
                pass

            try:
                _mark_intraday_equity(
                    db,
                    user_id=str(tr.get("user_id") or ""),
                    account_id=str(tr.get("account_id") or ""),
                    reason="EOD_EXIT",
                )
            except Exception:
                pass

            closed += 1
        except Exception:
            logger.exception("EOD close failed for paper trade")

    return {"ran": True, "scanned": int(scanned), "closed": int(closed)}


def _safe_get(d: Any, path: Tuple[str, ...]) -> Any:
    cur = d
    for k in path:
        if not isinstance(cur, dict):
            return None
        cur = cur.get(k)
    return cur


def _extract_last_close_from_market_data(market_data: Optional[Dict[str, Any]], preferred_tf: str) -> Optional[float]:
    candle_raw, _ = _pick_latest_candle(market_data, preferred_tf=preferred_tf)
    if not candle_raw:
        return None
    return _safe_float(_normalize_candle(candle_raw).get("close"))


def _long_regime_allows(
    analysis: Dict[str, Any],
    *,
    market_data: Optional[Dict[str, Any]],
    preferred_timeframe: str,
) -> Tuple[bool, Dict[str, Any]]:
    """LONG-only gate: allow when trend-up OR above VWAP OR above HTF support.

    Deterministic, best-effort: only blocks when at least one gate is evaluable and all evaluable gates are False.
    If nothing is evaluable, allow and record that it was unknown.
    """

    last_close = _extract_last_close_from_market_data(market_data, preferred_timeframe)

    # Trend (index/sector/market) hints
    trend_hint = None
    for p in (
        ("regime", "trend"),
        ("regime", "bias"),
        ("market", "trend"),
        ("context", "trend"),
        ("trend",),
        ("market_trend",),
        ("index_trend",),
        ("sector_trend",),
    ):
        v = _safe_get(analysis, p)
        if isinstance(v, str) and v.strip():
            trend_hint = v.strip().upper()
            break

    trend_up: Optional[bool] = None
    if isinstance(trend_hint, str):
        if any(x in trend_hint for x in ("UP", "BULL", "BULLISH", "POSITIVE", "RISING")):
            trend_up = True
        elif any(x in trend_hint for x in ("DOWN", "BEAR", "BEARISH", "NEGATIVE", "FALLING")):
            trend_up = False

    # VWAP gate
    above_vwap: Optional[bool] = None
    explicit_above_vwap = analysis.get("above_vwap")
    if isinstance(explicit_above_vwap, bool):
        above_vwap = explicit_above_vwap
    else:
        vwap = _safe_float(analysis.get("vwap"))
        if vwap is None:
            vwap = _safe_float(_safe_get(analysis, ("indicators", "vwap")))
        price = _safe_float(analysis.get("last_price"))
        if price is None:
            price = _safe_float(analysis.get("close"))
        if price is None:
            price = last_close
        if vwap is not None and price is not None:
            above_vwap = bool(float(price) >= float(vwap))

    # HTF support gate
    above_support: Optional[bool] = None
    support = _safe_float(analysis.get("htf_support"))
    if support is None:
        support = _safe_float(_safe_get(analysis, ("levels", "htf_support")))
    if support is None:
        support = _safe_float(_safe_get(analysis, ("context", "support")))
    price2 = _safe_float(analysis.get("last_price"))
    if price2 is None:
        price2 = last_close
    if support is not None and price2 is not None:
        above_support = bool(float(price2) >= float(support))

    evaluable = [x for x in (trend_up, above_vwap, above_support) if x is not None]
    allowed = True
    if evaluable:
        allowed = any(bool(x) for x in evaluable)

    details = {
        "trend_hint": trend_hint,
        "trend_up": trend_up,
        "above_vwap": above_vwap,
        "above_support": above_support,
        "last_close": last_close,
    }
    if not evaluable:
        details["regime_eval"] = "UNKNOWN_ALLOW"
    else:
        details["regime_eval"] = "ALLOW" if allowed else "BLOCK"

    return bool(allowed), details


def _margin_cache_seconds() -> int:
    return _env_int("PAPER_MARGIN_CACHE_SECONDS", 900, min_value=30, max_value=86400)


def _parse_zerodha_required_margin(obj: Any) -> Optional[float]:
    """Best-effort parse of Kite order_margins response to a single required margin number."""
    if obj is None:
        return None
    if isinstance(obj, (int, float)):
        return float(obj)
    if not isinstance(obj, dict):
        return None

    # Common patterns
    for k in ("required", "required_margin", "final", "total_margin"):
        v = obj.get(k)
        f = _safe_float(v)
        if f is not None and f >= 0:
            return float(f)

    total = obj.get("total")
    if isinstance(total, (int, float)):
        return float(total)
    if isinstance(total, dict):
        s = 0.0
        any_num = False
        for vv in total.values():
            ff = _safe_float(vv)
            if ff is None:
                continue
            s += float(ff)
            any_num = True
        if any_num and s >= 0:
            return float(s)

    return None


def _get_required_margin_from_zerodha(
    db,
    zerodha_client,
    *,
    user_id: str,
    account_id: str,
    symbol: str,
    direction: str,
    qty: int,
    price: float,
    product: str,
) -> Tuple[Optional[float], Optional[Dict[str, Any]]]:
    """Return (required_margin, raw_response). Uses Mongo cache to respect rate limits."""
    if zerodha_client is None:
        return None, None
    if not user_id or not account_id or not symbol or qty <= 0 or price <= 0:
        return None, None

    ensure_paper_game_indexes(db)

    tx = "BUY" if (direction or "").strip().upper() == "LONG" else "SELL"
    product = (product or "MIS").strip().upper() or "MIS"
    key = f"{symbol}|{tx}|{product}|{qty}|{round(float(price), 2)}"

    col = db[ZERODHA_ORDER_MARGIN_CACHE_COLLECTION]
    now = _now_utc()
    cached = col.find_one({"user_id": str(user_id), "account_id": str(account_id), "key": key})
    if cached and isinstance(cached.get("expires_at"), datetime) and cached.get("expires_at") > now:
        return _safe_float(cached.get("required_margin")), cached.get("raw") if isinstance(cached.get("raw"), dict) else None

    # Kite order_margins expects a list of orders.
    try:
        orders = [
            {
                "exchange": "NSE",
                "tradingsymbol": symbol,
                "transaction_type": tx,
                "product": product,
                "order_type": "MARKET",
                "quantity": int(qty),
                "price": float(price),
                "variety": "regular",
            }
        ]
        resp = zerodha_client.get_order_margins(orders)
    except Exception:
        return None, None

    raw0 = None
    if isinstance(resp, list) and resp:
        raw0 = resp[0] if isinstance(resp[0], dict) else None
    elif isinstance(resp, dict):
        raw0 = resp

    required = _parse_zerodha_required_margin(raw0)
    expires = now + timedelta(seconds=_margin_cache_seconds())

    try:
        col.update_one(
            {"user_id": str(user_id), "account_id": str(account_id), "key": key},
            {
                "$set": {
                    "user_id": str(user_id),
                    "account_id": str(account_id),
                    "key": key,
                    "symbol": symbol,
                    "tx": tx,
                    "product": product,
                    "qty": int(qty),
                    "price": float(price),
                    "required_margin": float(required) if required is not None else None,
                    "raw": raw0,
                    "updated_at": now,
                    "expires_at": expires,
                }
            },
            upsert=True,
        )
    except Exception:
        pass

    return required, raw0


def _mark_intraday_equity(db, user_id: str, account_id: str, *, reason: str) -> None:
    """Write mark-to-market info into today's `paper_balance_daily` without changing realized pnl_day."""
    if not user_id or not account_id:
        return
    ensure_paper_game_indexes(db)

    now = _now_utc()
    day = _paper_day_key(now)
    daily_col = db[PAPER_BALANCE_DAILY_COLLECTION]

    # Throttle marks to avoid hot loops: max once per 60s.
    doc = daily_col.find_one({"user_id": str(user_id), "account_id": str(account_id), "day": day}, {"last_mark_ts": 1})
    last_mark = doc.get("last_mark_ts") if isinstance(doc, dict) else None
    if isinstance(last_mark, datetime) and (now - last_mark).total_seconds() < 60:
        return

    acc = get_or_create_paper_account(db, user_id=str(user_id), account_id=str(account_id))
    balance = _safe_float(acc.get("balance")) or 0.0
    reserved = _safe_float(acc.get("reserved_balance")) or 0.0

    unreal = 0.0
    for t in db[PAPER_TRADES_COLLECTION].find(
        {"user_id": str(user_id), "account_id": str(account_id), "status": "OPEN"},
        {"current_unrealized_pnl": 1},
    ):
        u = _safe_float(t.get("current_unrealized_pnl"))
        if u is not None:
            unreal += float(u)

    equity = float(balance) + float(unreal)
    available = max(0.0, float(balance) - float(reserved))

    daily_col.update_one(
        {"user_id": str(user_id), "account_id": str(account_id), "day": day},
        {
            "$setOnInsert": {
                "user_id": str(user_id),
                "account_id": str(account_id),
                "day": day,
                "balance_open": float(balance),
                "pnl_day": 0.0,
                "events": [],
                "created_at": now,
            },
            "$set": {
                "balance_close": float(balance),
                "equity_close": float(equity),
                "unrealized_close": float(unreal),
                "reserved_close": float(reserved),
                "available_close": float(available),
                "last_mark_ts": now,
                "updated_at": now,
            },
            "$push": {"events": {"ts": now, "type": "MARK", "reason": str(reason or "MARK")}},
        },
        upsert=True,
    )


def _upsert_daily_balance(db, user_id: str, account_id: str, dt_utc: datetime, balance: float, delta: float, reason: str) -> None:
    day = _paper_day_key(dt_utc)
    col = db[PAPER_BALANCE_DAILY_COLLECTION]
    now = _now_utc()
    try:
        col.update_one(
            {"user_id": str(user_id), "account_id": str(account_id), "day": day},
            {
                "$setOnInsert": {
                    "user_id": str(user_id),
                    "account_id": str(account_id),
                    "day": day,
                    "balance_open": float(balance - delta),
                    "pnl_day": 0.0,
                    "events": [],
                    "created_at": now,
                },
                "$set": {"balance_close": float(balance), "updated_at": now},
                "$inc": {"pnl_day": float(delta)},
                "$push": {"events": {"ts": now, "delta": float(delta), "reason": str(reason or "")}},
            },
            upsert=True,
        )
    except Exception:
        logger.exception("Failed to upsert daily balance")


def get_or_create_paper_account(db, user_id: str, account_id: str) -> Dict[str, Any]:
    ensure_paper_game_indexes(db)
    col = db[PAPER_ACCOUNTS_COLLECTION]
    q = {"user_id": str(user_id), "account_id": str(account_id)}
    acc = col.find_one(q)
    if acc:
        return acc

    now = _now_utc()
    d = _account_defaults()
    bal = float(d["starting_balance"])
    doc = {
        "user_id": str(user_id),
        "account_id": str(account_id),
        "paper_account_id": f"{user_id}:{account_id}",
        "starting_balance": float(bal),
        "balance": float(bal),
        "reserved_balance": 0.0,
        "available_balance": float(bal),
        "settings": {
            "min_trade_value": float(d["min_trade_value"]),
            "max_trade_value": float(d["max_trade_value"]),
            "max_quantity": int(d["max_quantity"]),
            "max_loss_pct": float(d["max_loss_pct"]),
            "max_profit_pct": float(d["max_profit_pct"]),
            "eod_exit": bool(d["eod_exit"]),
            "product": str(d.get("product") or "MIS"),
        },
        "events": [],
        "created_at": now,
        "updated_at": now,
    }

    try:
        col.insert_one(doc)
    except Exception:
        # race
        acc = col.find_one(q)
        if acc:
            return acc
        raise

    _upsert_daily_balance(db, user_id=str(user_id), account_id=str(account_id), dt_utc=now, balance=float(bal), delta=0.0, reason="ACCOUNT_CREATED")
    return col.find_one(q) or doc


def _apply_overrides(entry: float, sl: float, target: float, direction: str, settings: Dict[str, Any]) -> Tuple[float, float, float]:
    """Apply optional max loss/profit % caps (tighter SL / nearer target)."""
    max_loss_pct = _safe_float((settings or {}).get("max_loss_pct"))
    max_profit_pct = _safe_float((settings or {}).get("max_profit_pct"))

    eff_sl = float(sl)
    eff_target = float(target)
    d = (direction or "").strip().upper()

    if max_loss_pct is not None and max_loss_pct >= 0:
        if d == "LONG":
            override_sl = entry * (1.0 - (max_loss_pct / 100.0))
            eff_sl = max(eff_sl, override_sl)
        elif d == "SHORT":
            override_sl = entry * (1.0 + (max_loss_pct / 100.0))
            eff_sl = min(eff_sl, override_sl)

    if max_profit_pct is not None and max_profit_pct >= 0:
        if d == "LONG":
            override_t = entry * (1.0 + (max_profit_pct / 100.0))
            eff_target = min(eff_target, override_t)
        elif d == "SHORT":
            override_t = entry * (1.0 - (max_profit_pct / 100.0))
            eff_target = max(eff_target, override_t)

    return float(eff_sl), float(eff_target), float(entry)


def _compute_quantity(entry: float, available_balance: float, settings: Dict[str, Any]) -> Tuple[int, float]:
    min_tv = _safe_float((settings or {}).get("min_trade_value"))
    max_tv = _safe_float((settings or {}).get("max_trade_value"))
    max_qty = int((settings or {}).get("max_quantity") or 0)

    if min_tv is None:
        min_tv = 0.0
    if max_tv is None or max_tv <= 0:
        max_tv = float(available_balance)
    if max_qty <= 0:
        max_qty = 1_000_000_000

    if entry <= 0 or available_balance <= 0:
        return 0, 0.0

    trade_value = min(float(max_tv), float(available_balance))
    if trade_value < float(min_tv):
        return 0, 0.0

    qty = int(trade_value // float(entry))
    qty = max(0, min(qty, max_qty))
    if qty <= 0:
        return 0, 0.0

    tv = float(entry) * float(qty)
    if tv < float(min_tv) or tv > float(available_balance) + 1e-9:
        return 0, 0.0
    return qty, tv


def _compute_quantity_by_limits(entry: float, settings: Dict[str, Any]) -> Tuple[int, float]:
    """Size by min/max trade value + max quantity (does not cap by available balance)."""
    min_tv = _safe_float((settings or {}).get("min_trade_value"))
    max_tv = _safe_float((settings or {}).get("max_trade_value"))
    max_qty = int((settings or {}).get("max_quantity") or 0)

    if min_tv is None:
        min_tv = 0.0
    if max_tv is None or max_tv <= 0:
        return 0, 0.0
    if max_qty <= 0:
        max_qty = 1_000_000_000

    if entry <= 0:
        return 0, 0.0

    if float(max_tv) < float(min_tv):
        return 0, 0.0

    qty = int(float(max_tv) // float(entry))
    qty = max(0, min(qty, max_qty))
    if qty <= 0:
        return 0, 0.0

    tv = float(entry) * float(qty)
    if tv < float(min_tv) or tv > float(max_tv) + 1e-9:
        return 0, 0.0
    return qty, tv


def _min_strength_required() -> str:
    # Default to LOW so score-threshold based gating can drive creation.
    # Can be overridden via env if you want a confidence gate.
    v = (os.getenv("PAPER_TRADE_MIN_STRENGTH") or "LOW").strip().upper()
    # Canonical levels:
    # - LOW / MEDIUM / HIGH / VERY_STRONG
    # Backward-compatible aliases:
    # - WEAK -> LOW
    # - STRONG -> HIGH
    return normalize_confidence(v)


def _strength_at_least(actual: str, minimum: str) -> bool:
    return confidence_at_least(actual, minimum)


def _strength_level(v: str) -> int:
    """Map normalized confidence to an integer level."""
    s = normalize_confidence(v)
    order = ["LOW", "MEDIUM", "HIGH", "VERY_STRONG"]
    try:
        return order.index(s)
    except Exception:
        return 0


def _strength_from_level(level: int) -> str:
    order = ["LOW", "MEDIUM", "HIGH", "VERY_STRONG"]
    level = int(level)
    level = max(0, min(level, len(order) - 1))
    return order[level]


def _market_intelligence_latest(db) -> Optional[Dict[str, Any]]:
    """Fetch latest market intelligence payload (DB-only)."""
    try:
        doc = db["market_intelligence_summary"].find_one({"type": "latest"}, {"_id": 0}) or {}
        payload = doc.get("payload")
        return payload if isinstance(payload, dict) else None
    except Exception:
        return None


def _market_bias_alignment(decision: str, market_bias: str) -> Optional[bool]:
    d = (decision or "").strip().upper()
    mb = (market_bias or "").strip().upper()
    if d == "BUY":
        if mb.startswith("BULL"):
            return True
        if mb.startswith("BEAR"):
            return False
    if d == "SELL":
        if mb.startswith("BEAR"):
            return True
        if mb.startswith("BULL"):
            return False
    return None


def _effective_strength_for_paper_entry(db, analysis: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
    """Compute effective strength for paper-trade creation.

    By default this equals the signal's normalized confidence.
    Optionally boosts 1 level when market bias aligns with the trade direction.
    """

    base = _signal_strength(analysis)
    details: Dict[str, Any] = {"base_strength": base}

    if not _env_bool("PAPER_MARKET_BIAS_BONUS_ENABLED", False):
        return base, details

    try:
        max_age = _env_int("PAPER_MARKET_BIAS_MAX_AGE_MINUTES", 60, min_value=1, max_value=24 * 60)
        payload = _market_intelligence_latest(db)
        if not payload:
            details["mi"] = {"used": False, "reason": "missing_payload"}
            return base, details

        captured_at = _safe_dt(payload.get("captured_at"))
        if captured_at is None:
            details["mi"] = {"used": False, "reason": "missing_captured_at"}
            return base, details

        age_min = (datetime.utcnow() - captured_at).total_seconds() / 60.0
        if age_min > float(max_age):
            details["mi"] = {"used": False, "reason": "stale", "age_minutes": age_min}
            return base, details

        market_bias = payload.get("market_bias")
        aligned = _market_bias_alignment(str(analysis.get("decision", "")), str(market_bias or ""))
        details["mi"] = {
            "used": True,
            "captured_at": captured_at.isoformat() + "Z",
            "market_bias": market_bias,
            "aligned": aligned,
        }

        # Boost only when clearly aligned.
        if aligned is True:
            bonus = _env_int("PAPER_MARKET_BIAS_STRENGTH_BONUS", 1, min_value=0, max_value=2)
            boosted = _strength_from_level(_strength_level(base) + int(bonus))
            details["mi"]["bonus"] = int(bonus)
            details["effective_strength"] = boosted
            return boosted, details

        details["effective_strength"] = base
        return base, details
    except Exception:
        details["mi"] = {"used": False, "reason": "exception"}
        return base, details


def _safe_float(v: Any) -> Optional[float]:
    try:
        if v is None:
            return None
        if isinstance(v, bool):
            return None
        return float(v)
    except Exception:
        return None


def _to_utc_naive(dt: datetime) -> datetime:
    """Return a UTC-naive datetime for consistent DB/storage comparisons.

    - If dt is tz-aware, convert to UTC then drop tzinfo.
    - If dt is tz-naive, assume it's already UTC and keep as-is.
    """

    try:
        if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
            return dt.replace(tzinfo=None)
        return dt.astimezone(timezone.utc).replace(tzinfo=None)
    except Exception:
        return dt.replace(tzinfo=None)


def _safe_dt(v: Any) -> Optional[datetime]:
    if v is None:
        return None
    if isinstance(v, datetime):
        return _to_utc_naive(v)
    if isinstance(v, str):
        try:
            parsed = datetime.fromisoformat(v.replace("Z", "+00:00"))
            return _to_utc_naive(parsed)
        except Exception:
            return None
    return None


def _normalize_symbol(v: Any) -> str:
    return (v or "").strip().upper()


def _direction_from_decision(decision: Any) -> Optional[str]:
    d = (decision or "").strip().upper()
    if d == "BUY":
        return "LONG"
    if d == "SELL":
        return "SHORT"
    return None


def _signal_strength(analysis: Dict[str, Any]) -> str:
    return normalize_confidence(
        analysis.get("confidence"),
        decision_probability=analysis.get("decision_probability"),
        score=analysis.get("score"),
    )


def _extract_trade_plan(analysis: Dict[str, Any]) -> Tuple[Optional[float], Optional[float], Optional[float]]:
    entry = _safe_float(analysis.get("entry_price"))
    sl = _safe_float(analysis.get("stop_loss"))

    target = _safe_float(analysis.get("price_target"))
    if target is None:
        target = _safe_float(analysis.get("target"))

    if target is None:
        raw_targets = analysis.get("targets")
        if isinstance(raw_targets, (list, tuple)) and raw_targets:
            target = _safe_float(raw_targets[0])

    if entry is None:
        targets_obj = analysis.get("targets")
        if isinstance(targets_obj, dict):
            entry = _safe_float(targets_obj.get("entry"))
            if target is None:
                target = _safe_float(targets_obj.get("exit"))

    return entry, sl, target


def _candle_ts(candle: Dict[str, Any]) -> Optional[datetime]:
    # Zerodha DF records typically have `date`; tradeGPT snapshot uses `t`.
    for k in ("date", "t", "timestamp", "time"):
        ts = _safe_dt(candle.get(k))
        if ts:
            return ts
    return None


def _normalize_candle(candle: Dict[str, Any]) -> Dict[str, Any]:
    # Normalize to {open, high, low, close, ts}
    out = {
        "ts": _candle_ts(candle),
        "open": _safe_float(candle.get("open") or candle.get("o")),
        "high": _safe_float(candle.get("high") or candle.get("h")),
        "low": _safe_float(candle.get("low") or candle.get("l")),
        "close": _safe_float(candle.get("close") or candle.get("c")),
    }
    return out


def _pick_latest_candle(market_data: Optional[Dict[str, Any]], preferred_tf: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
    if not isinstance(market_data, dict):
        return None, None

    candles_by_tf = market_data.get("candles") or {}
    if not isinstance(candles_by_tf, dict):
        return None, None

    preferred_tf = (preferred_tf or "").strip()
    tfs = []
    if preferred_tf:
        tfs.append(preferred_tf)
    # sensible fallbacks
    tfs.extend(["15minute", "30minute", "5minute", "60minute", "day"])

    for tf in tfs:
        arr = candles_by_tf.get(tf)
        if isinstance(arr, list) and arr:
            last = arr[-1]
            if isinstance(last, dict):
                return last, tf

    # last resort: first non-empty list
    for tf, arr in candles_by_tf.items():
        if isinstance(arr, list) and arr and isinstance(arr[-1], dict):
            return arr[-1], str(tf)

    return None, None


def ensure_paper_trades_indexes(db) -> None:
    try:
        col = db[PAPER_TRADES_COLLECTION]
        col.create_index(
            [("user_id", 1), ("account_id", 1), ("symbol", 1), ("status", 1), ("created_at", -1)],
            name="paper_trades_user_symbol_status",
        )
        # Prevent duplicates for the same signal per user/account.
        col.create_index(
            [("user_id", 1), ("account_id", 1), ("signal_id", 1)],
            name="paper_trades_unique_signal",
            unique=True,
            partialFilterExpression={"signal_id": {"$exists": True, "$type": "string"}},
        )
    except Exception:
        logger.exception("Failed to ensure paper_trades indexes")


def _now_utc() -> datetime:
    return datetime.utcnow()


def _maybe_close_trade_with_candle(trade: Dict[str, Any], candle: Dict[str, Any]) -> Dict[str, Any]:
    """Return patch dict for DB update based on deterministic candle rules.

    Deterministic rule: Stop-loss is checked first (worst-case) when both SL/target occur in same candle.
    """

    entry = _safe_float(trade.get("entry_price"))
    sl = _safe_float(trade.get("stop_loss"))
    target = _safe_float(trade.get("target"))
    direction = (trade.get("direction") or "").strip().upper()
    # Candle-only evaluation for SL/Target + unrealized stats.

    c = _normalize_candle(candle)
    high = c.get("high")
    low = c.get("low")
    close = c.get("close")
    candle_ts = c.get("ts")

    if entry is None or sl is None or target is None or high is None or low is None:
        # Cannot evaluate deterministically without these.
        return {
            "last_price_close": close,
            "last_candle_ts": candle_ts,
            "updated_at": _now_utc(),
        }

    # Compute excursions
    prev_mfe = _safe_float(trade.get("max_favorable_move")) or 0.0
    prev_mae = _safe_float(trade.get("max_adverse_move")) or 0.0

    if direction == "LONG":
        favorable = max(0.0, high - entry)
        adverse = max(0.0, entry - low)
        mfe = max(prev_mfe, favorable)
        mae = max(prev_mae, adverse)

        # Exit rules (SL first)
        if low <= sl:
            exit_price = sl
            realized = exit_price - entry
            return {
                "status": "CLOSED",
                "exit_price": exit_price,
                "exit_reason": "STOP_LOSS",
                "closed_at": _now_utc(),
                "realized_pnl_per_unit": float(realized),
                "current_unrealized_pnl_per_unit": 0.0,
                "max_favorable_move": mfe,
                "max_adverse_move": mae,
                "last_price_close": close,
                "last_candle_ts": candle_ts,
                "updated_at": _now_utc(),
            }
        if high >= target:
            exit_price = target
            realized = exit_price - entry
            return {
                "status": "CLOSED",
                "exit_price": exit_price,
                "exit_reason": "TARGET",
                "closed_at": _now_utc(),
                "realized_pnl_per_unit": float(realized),
                "current_unrealized_pnl_per_unit": 0.0,
                "max_favorable_move": mfe,
                "max_adverse_move": mae,
                "last_price_close": close,
                "last_candle_ts": candle_ts,
                "updated_at": _now_utc(),
            }

        unreal = (close - entry) if close is not None else None
        return {
            "max_favorable_move": mfe,
            "max_adverse_move": mae,
            "current_unrealized_pnl_per_unit": float(unreal) if unreal is not None else None,
            "last_price_close": close,
            "last_candle_ts": candle_ts,
            "updated_at": _now_utc(),
        }

    if direction == "SHORT":
        favorable = max(0.0, entry - low)
        adverse = max(0.0, high - entry)
        mfe = max(prev_mfe, favorable)
        mae = max(prev_mae, adverse)

        # Exit rules (SL first)
        if high >= sl:
            exit_price = sl
            realized = entry - exit_price
            return {
                "status": "CLOSED",
                "exit_price": exit_price,
                "exit_reason": "STOP_LOSS",
                "closed_at": _now_utc(),
                "realized_pnl_per_unit": float(realized),
                "current_unrealized_pnl_per_unit": 0.0,
                "max_favorable_move": mfe,
                "max_adverse_move": mae,
                "last_price_close": close,
                "last_candle_ts": candle_ts,
                "updated_at": _now_utc(),
            }
        if low <= target:
            exit_price = target
            realized = entry - exit_price
            return {
                "status": "CLOSED",
                "exit_price": exit_price,
                "exit_reason": "TARGET",
                "closed_at": _now_utc(),
                "realized_pnl_per_unit": float(realized),
                "current_unrealized_pnl_per_unit": 0.0,
                "max_favorable_move": mfe,
                "max_adverse_move": mae,
                "last_price_close": close,
                "last_candle_ts": candle_ts,
                "updated_at": _now_utc(),
            }

        unreal = (entry - close) if close is not None else None
        return {
            "max_favorable_move": mfe,
            "max_adverse_move": mae,
            "current_unrealized_pnl_per_unit": float(unreal) if unreal is not None else None,
            "last_price_close": close,
            "last_candle_ts": candle_ts,
            "updated_at": _now_utc(),
        }

    # Unknown direction
    return {
        "last_price_close": close,
        "last_candle_ts": candle_ts,
        "updated_at": _now_utc(),
    }


def update_open_paper_trades_for_symbol(
    db,
    symbol: str,
    candle: Dict[str, Any],
    candle_timeframe: Optional[str] = None,
    analysis: Optional[Dict[str, Any]] = None,
) -> int:
    """Evaluate all ACTIVE paper trades (OPEN + PENDING) for symbol using the provided candle."""

    symbol = _normalize_symbol(symbol)
    if not symbol or not isinstance(candle, dict):
        return 0

    col = db[PAPER_TRADES_COLLECTION]
    norm = _normalize_candle(candle)
    candle_ts = norm.get("ts")

    q: Dict[str, Any] = {"symbol": symbol, "status": {"$in": ["OPEN", "PENDING"]}}

    # Optional opposite VERY_STRONG signal exit (applies after SL/Target).
    opposite_exit_enabled = _allow_opposite_signal_exit()
    analysis_dir = None
    analysis_strength = None
    if isinstance(analysis, dict) and opposite_exit_enabled:
        analysis_dir = _direction_from_decision(analysis.get("decision"))
        analysis_strength = _signal_strength(analysis)

    updated = 0
    for tr in col.find(
        q,
        {
            "_id": 1,
            "user_id": 1,
            "account_id": 1,
            "entry_price": 1,
            "stop_loss": 1,
            "target": 1,
            "direction": 1,
            "quantity": 1,
            "trade_value": 1,
            "reserved_amount": 1,
            "opened_at": 1,
            "pending_at": 1,
            "settings": 1,
            "max_favorable_move": 1,
            "max_adverse_move": 1,
            "last_candle_ts": 1,
            "pending_candle_count": 1,
            "open_candle_count": 1,
            "status": 1,
        },
    ):
        try:
            last_seen = _safe_dt(tr.get("last_candle_ts"))
            if candle_ts and last_seen and last_seen >= candle_ts:
                continue

            status = (tr.get("status") or "").strip().upper() or "OPEN"
            direction = (tr.get("direction") or "").strip().upper()
            settings = tr.get("settings") if isinstance(tr.get("settings"), dict) else {}
            eod_exit = settings.get("eod_exit")
            eod_exit = bool(eod_exit) if eod_exit is not None else False

            patch: Dict[str, Any] = {}

            # PENDING LONG confirmation / expiry
            if status == "PENDING":
                entry = _safe_float(tr.get("entry_price"))
                if entry is None:
                    patch = {
                        "last_price_close": norm.get("close"),
                        "last_candle_ts": candle_ts,
                        "updated_at": _now_utc(),
                    }
                else:
                    pending_cnt = int(tr.get("pending_candle_count") or 0)
                    pending_cnt = max(0, pending_cnt + 1)
                    pending_max = _long_pending_max_candles()

                    # LONG confirmation rules (SHORT should not be pending under our engine)
                    copen = norm.get("open")
                    cclose = norm.get("close")
                    confirm_by_open = copen is not None and float(copen) > float(entry)
                    confirm_by_close = cclose is not None and float(cclose) >= float(entry)

                    if direction == "LONG" and (confirm_by_open or confirm_by_close):
                        patch = {
                            "status": "OPEN",
                            "opened_at": _now_utc(),
                            "opened_candle_ts": candle_ts,
                            "pending_candle_count": int(pending_cnt),
                            "open_candle_count": 0,
                            "confirm_reason": "OPEN_ABOVE_ENTRY" if confirm_by_open else "CLOSE_AT_OR_ABOVE_ENTRY",
                            "last_price_close": norm.get("close"),
                            "last_candle_ts": candle_ts,
                            "updated_at": _now_utc(),
                        }
                        # If confirmation happened at candle OPEN, we can evaluate SL/Target within the same candle.
                        if confirm_by_open:
                            patch2 = _maybe_close_trade_with_candle(tr, candle)
                            patch.update(patch2)
                            # Ensure it remains OPEN if not closed by SL/Target.
                            if patch.get("status") != "CLOSED":
                                patch["status"] = "OPEN"
                                patch["exit_reason"] = None
                                patch["exit_price"] = None
                                patch["closed_at"] = None
                                patch["realized_pnl"] = None
                                patch["realized_pnl_per_unit"] = None
                    else:
                        # Not confirmed yet.
                        patch = {
                            "pending_candle_count": int(pending_cnt),
                            "last_price_close": norm.get("close"),
                            "last_candle_ts": candle_ts,
                            "updated_at": _now_utc(),
                        }

                        # Opposite VERY_STRONG signal can cancel pending.
                        if (
                            opposite_exit_enabled
                            and analysis_dir
                            and analysis_strength
                            and _strength_at_least(str(analysis_strength), "VERY_STRONG")
                            and direction in {"LONG", "SHORT"}
                            and analysis_dir in {"LONG", "SHORT"}
                            and analysis_dir != direction
                            and norm.get("close") is not None
                        ):
                            patch.update(
                                {
                                    "status": "CLOSED",
                                    "exit_price": norm.get("close"),
                                    "exit_reason": "OPPOSITE_SIGNAL",
                                    "closed_at": _now_utc(),
                                    "realized_pnl_per_unit": 0.0,
                                    "current_unrealized_pnl_per_unit": 0.0,
                                }
                            )
                        # Time expiry (LONG-only asymmetry)
                        elif direction == "LONG" and pending_max > 0 and pending_cnt >= pending_max and norm.get("close") is not None:
                            patch.update(
                                {
                                    "status": "CLOSED",
                                    "exit_price": norm.get("close"),
                                    "exit_reason": "TIME_EXIT",
                                    "closed_at": _now_utc(),
                                    "realized_pnl_per_unit": 0.0,
                                    "current_unrealized_pnl_per_unit": 0.0,
                                }
                            )

                        # EOD exit for PENDING (last)
                        if (
                            patch.get("status") != "CLOSED"
                            and eod_exit
                            and (str(settings.get("product") or "MIS").strip().upper() or "MIS") == "MIS"
                            and candle_ts
                            and _is_eod_cutoff_reached(candle_ts)
                            and norm.get("close") is not None
                        ):
                            patch.update(
                                {
                                    "status": "CLOSED",
                                    "exit_price": norm.get("close"),
                                    "exit_reason": "EOD_EXIT",
                                    "closed_at": _now_utc(),
                                    "realized_pnl_per_unit": 0.0,
                                    "current_unrealized_pnl_per_unit": 0.0,
                                }
                            )

                if candle_timeframe:
                    patch["last_candle_timeframe"] = candle_timeframe

            else:
                # OPEN trade: SL/Target first
                patch = _maybe_close_trade_with_candle(tr, candle)
                if candle_timeframe:
                    patch["last_candle_timeframe"] = candle_timeframe

                # If still OPEN after SL/Target, apply opposite-signal exit, then time-expiry, then EOD.
                if patch.get("status") != "CLOSED":
                    open_cnt = int(tr.get("open_candle_count") or 0)
                    open_cnt = max(0, open_cnt + 1)
                    patch["open_candle_count"] = int(open_cnt)

                    # Opposite VERY_STRONG signal exit
                    if (
                        opposite_exit_enabled
                        and analysis_dir
                        and analysis_strength
                        and _strength_at_least(str(analysis_strength), "VERY_STRONG")
                        and direction in {"LONG", "SHORT"}
                        and analysis_dir in {"LONG", "SHORT"}
                        and analysis_dir != direction
                        and norm.get("close") is not None
                    ):
                        entry = _safe_float(tr.get("entry_price"))
                        close_px = _safe_float(norm.get("close"))
                        per_unit = 0.0
                        if entry is not None and close_px is not None:
                            if direction == "LONG":
                                per_unit = float(close_px) - float(entry)
                            elif direction == "SHORT":
                                per_unit = float(entry) - float(close_px)
                        patch.update(
                            {
                                "status": "CLOSED",
                                "exit_price": close_px,
                                "exit_reason": "OPPOSITE_SIGNAL",
                                "closed_at": _now_utc(),
                                "realized_pnl_per_unit": float(per_unit),
                                "current_unrealized_pnl_per_unit": 0.0,
                            }
                        )
                    # Time expiry asymmetry (LONG-only by default)
                    elif direction == "LONG":
                        max_candles = _long_open_max_candles()
                        if max_candles > 0 and open_cnt >= max_candles and norm.get("close") is not None:
                            entry = _safe_float(tr.get("entry_price"))
                            close_px = _safe_float(norm.get("close"))
                            per_unit = 0.0
                            if entry is not None and close_px is not None:
                                per_unit = float(close_px) - float(entry)
                            patch.update(
                                {
                                    "status": "CLOSED",
                                    "exit_price": close_px,
                                    "exit_reason": "TIME_EXIT",
                                    "closed_at": _now_utc(),
                                    "realized_pnl_per_unit": float(per_unit),
                                    "current_unrealized_pnl_per_unit": 0.0,
                                }
                            )

                    # EOD exit (last)
                    if (
                        patch.get("status") != "CLOSED"
                        and candle_ts
                        and tr.get("opened_at")
                        and eod_exit
                        and (str(settings.get("product") or "MIS").strip().upper() or "MIS") == "MIS"
                        and _is_eod_cutoff_reached(candle_ts)
                    ):
                        opened_at = _safe_dt(tr.get("opened_at"))
                        close_px = _safe_float(norm.get("close"))
                        if opened_at and close_px is not None:
                            try:
                                if _paper_day_key(opened_at) == _paper_day_key(candle_ts):
                                    entry = _safe_float(tr.get("entry_price"))
                                    per_unit = 0.0
                                    if entry is not None:
                                        if direction == "LONG":
                                            per_unit = float(close_px) - float(entry)
                                        elif direction == "SHORT":
                                            per_unit = float(entry) - float(close_px)
                                    patch.update(
                                        {
                                            "status": "CLOSED",
                                            "exit_price": close_px,
                                            "exit_reason": "EOD_EXIT",
                                            "closed_at": _now_utc(),
                                            "realized_pnl_per_unit": float(per_unit),
                                            "current_unrealized_pnl_per_unit": 0.0,
                                        }
                                    )
                            except Exception:
                                pass

            qty = int(tr.get("quantity") or 0)
            if qty < 0:
                qty = 0

            per_unit_real = _safe_float(patch.get("realized_pnl_per_unit"))
            per_unit_unreal = _safe_float(patch.get("current_unrealized_pnl_per_unit"))

            if per_unit_unreal is not None and qty > 0:
                patch["current_unrealized_pnl"] = float(per_unit_unreal) * float(qty)
            if per_unit_real is not None and qty > 0:
                patch["realized_pnl"] = float(per_unit_real) * float(qty)

            if patch.get("status") == "CLOSED":
                # Apply to account before writing trade (best-effort). Balance never below 0.
                _apply_trade_close_to_account(
                    db,
                    user_id=str(tr.get("user_id") or ""),
                    account_id=str(tr.get("account_id") or ""),
                    trade_id=str(tr.get("_id")),
                    trade_value=_safe_float(tr.get("reserved_amount") or tr.get("trade_value") or 0.0) or 0.0,
                    pnl_total=_safe_float(patch.get("realized_pnl")) or 0.0,
                    closed_dt=_now_utc(),
                    exit_reason=str(patch.get("exit_reason") or "CLOSED"),
                )

                _audit_event(
                    db,
                    user_id=str(tr.get("user_id") or ""),
                    account_id=str(tr.get("account_id") or ""),
                    symbol=symbol,
                    event="TRADE_CLOSED",
                    reason=str(patch.get("exit_reason") or "CLOSED"),
                    details={"trade_id": str(tr.get("_id")), "status_from": status},
                )

            if status == "PENDING" and patch.get("status") == "OPEN":
                try:
                    db[PAPER_ACCOUNTS_COLLECTION].update_one(
                        {"user_id": str(tr.get("user_id") or ""), "account_id": str(tr.get("account_id") or "")},
                        {
                            "$push": {
                                "events": {
                                    "ts": _now_utc(),
                                    "type": "TRADE_PENDING_TO_OPEN",
                                    "trade_id": str(tr.get("_id")),
                                    "symbol": symbol,
                                    "confirm_reason": str(patch.get("confirm_reason") or ""),
                                }
                            },
                            "$set": {"updated_at": _now_utc()},
                        },
                    )
                except Exception:
                    pass

            col.update_one({"_id": tr["_id"]}, {"$set": patch})

            # Best-effort intraday mark-to-market for gamification.
            _mark_intraday_equity(
                db,
                user_id=str(tr.get("user_id") or ""),
                account_id=str(tr.get("account_id") or ""),
                reason=f"CANDLE_{symbol}",
            )
            updated += 1
        except Exception:
            logger.exception("Paper trade update failed for symbol=%s", symbol)

    return updated


def _apply_trade_close_to_account(
    db,
    user_id: str,
    account_id: str,
    trade_id: str,
    trade_value: float,
    pnl_total: float,
    closed_dt: datetime,
    exit_reason: str,
) -> None:
    if not user_id or not account_id:
        return

    acc = get_or_create_paper_account(db, user_id=str(user_id), account_id=str(account_id))
    col = db[PAPER_ACCOUNTS_COLLECTION]
    q = {"user_id": str(user_id), "account_id": str(account_id)}

    balance = _safe_float(acc.get("balance")) or 0.0
    reserved = _safe_float(acc.get("reserved_balance")) or 0.0
    tv = float(trade_value or 0.0)
    pnl = float(pnl_total or 0.0)

    reserved_after = max(0.0, reserved - tv)
    raw_new_balance = balance + pnl
    new_balance = max(0.0, raw_new_balance)
    applied_delta = new_balance - balance
    available = max(0.0, new_balance - reserved_after)

    col.update_one(
        q,
        {
            "$set": {
                "balance": float(new_balance),
                "reserved_balance": float(reserved_after),
                "available_balance": float(available),
                "updated_at": _now_utc(),
            },
            "$push": {
                "events": {
                    "ts": _now_utc(),
                    "type": "TRADE_CLOSED",
                    "trade_id": str(trade_id),
                    "trade_value": float(tv),
                    "pnl": float(pnl),
                    "pnl_applied": float(applied_delta),
                    "exit_reason": str(exit_reason or ""),
                    "balance_before": float(balance),
                    "balance_after": float(new_balance),
                }
            },
        },
    )

    _upsert_daily_balance(
        db,
        user_id=str(user_id),
        account_id=str(account_id),
        dt_utc=closed_dt,
        balance=float(new_balance),
        delta=float(applied_delta),
        reason=f"TRADE_{str(exit_reason or 'CLOSED').upper()}",
    )


def create_paper_trade_from_signal(
    db,
    user_id: str,
    account_id: Optional[str],
    stock_id: Optional[str],
    symbol: str,
    analysis: Dict[str, Any],
    source: str,
    snapshot_id: Optional[str] = None,
    snapshot_timestamp: Optional[datetime] = None,
    zerodha_client: Any = None,
    market_data: Optional[Dict[str, Any]] = None,
    preferred_timeframe: str = "15minute",
) -> Optional[str]:
    """Create OPEN/PENDING paper trade if signal passes filters and BUY/SELL.

    Returns paper_trade_id (string) or None if skipped.
    """

    if not isinstance(analysis, dict):
        return None


def _score_to_pct(v: Any) -> Optional[float]:
    """Normalize various score/probability formats to 0-100.

    Accepts:
    - 0..1 probability -> 0..100
    - 0..100 score -> 0..100
    """

    x = _safe_float(v)
    if x is None:
        return None
    if x != x or x in (float("inf"), float("-inf")):
        return None
    # Treat 0..1 as a probability.
    if 0.0 <= float(x) <= 1.0:
        return float(x) * 100.0
    return float(x)

    # Paper trading requires an account bucket for sizing/audits.
    # Some deployments don't populate `users.account_id`; default to a stable value.
    if not account_id or not str(account_id).strip():
        account_id = (os.getenv("PAPER_DEFAULT_ACCOUNT_ID") or "default").strip() or "default"

    symbol = _normalize_symbol(symbol or analysis.get("symbol"))
    if not symbol:
        return None

    direction = _direction_from_decision(analysis.get("decision"))
    if direction is None:
        return None

    # Settings-driven filters (separate from Alerts settings).
    settings_obj: Dict[str, Any] = {}
    try:
        if account_id:
            acc0 = get_or_create_paper_account(db, user_id=str(user_id), account_id=str(account_id))
            settings_obj = acc0.get("settings") if isinstance(acc0.get("settings"), dict) else {}
    except Exception:
        settings_obj = {}

    # Source filter (MANUAL/GAINER/LOSER/EARLY_MOVERS)
    allowed_sources = settings_obj.get("sources")
    if not isinstance(allowed_sources, list) or not allowed_sources:
        allowed_sources = ["MANUAL", "GAINER", "LOSER", "EARLY_MOVERS"]
    allowed_sources = [str(s or "").strip().upper() for s in allowed_sources]
    if source and str(source).strip().upper() not in set(allowed_sources):
        return None

    # Decision filter (BUY/SELL)
    allowed_decisions = settings_obj.get("decisions")
    if not isinstance(allowed_decisions, list) or not allowed_decisions:
        allowed_decisions = ["BUY", "SELL"]
    allowed_decisions = [str(d or "").strip().upper() for d in allowed_decisions]
    # normalize LONG/SHORT
    allowed_decisions = [("BUY" if d == "LONG" else "SELL" if d == "SHORT" else d) for d in allowed_decisions]
    d0 = (analysis.get("decision") or "").strip().upper()
    d0 = "BUY" if d0 == "LONG" else "SELL" if d0 == "SHORT" else d0
    if d0 not in set(allowed_decisions):
        return None

    # Score threshold filter (0-100)
    min_score = _safe_float(settings_obj.get("min_score"))
    if min_score is None:
        try:
            raw = os.getenv("PAPER_TRADE_MIN_SCORE", "85")
            min_score = float(raw)
        except Exception:
            min_score = 85.0
    min_score = max(0.0, min(100.0, float(min_score)))

    # Prefer explicit `score`, but fall back to `decision_probability` (many analyses only emit that).
    score = _score_to_pct(analysis.get("score"))
    if score is None:
        score = _score_to_pct(analysis.get("decision_probability"))

    if score is None:
        logger.info(
            "Paper trade skipped (score missing) | symbol=%s | keys=%s",
            symbol,
            sorted(list(analysis.keys()))[:30],
        )
        try:
            _audit_event(
                db,
                user_id=str(user_id),
                account_id=str(account_id),
                symbol=symbol,
                event="TRADE_SKIPPED",
                reason="SCORE_MISSING",
                details={"min_score": float(min_score)},
            )
        except Exception:
            pass
        return None

    if float(score) < float(min_score):
        logger.info(
            "Paper trade skipped (score too low) | symbol=%s score=%s min_score=%s",
            symbol,
            str(score),
            str(min_score),
        )
        # Best-effort audit (only if we have an account).
        try:
            _audit_event(
                db,
                user_id=str(user_id),
                account_id=str(account_id),
                symbol=symbol,
                event="TRADE_SKIPPED",
                reason="SCORE_TOO_LOW",
                details={"min_score": float(min_score), "score": float(score)},
            )
        except Exception:
            pass
        return None

    # Intraday-only: never create new paper trades outside the configured window.
    now = _now_utc()
    if not _is_paper_trade_creation_allowed(now):
        try:
            from app.v1.utils.market_time import format_window, local_time_str, paper_trade_window

            logger.info(
                "Paper trade skipped due to time | now=%s | window=%s | symbol=%s",
                local_time_str(now),
                format_window(paper_trade_window()),
                symbol,
            )
        except Exception:
            logger.info("Paper trade skipped due to time | symbol=%s", symbol)

        # Best-effort audit (only if we have an account).
        try:
            _audit_event(
                db,
                user_id=str(user_id),
                account_id=str(account_id),
                symbol=symbol,
                event="TRADE_SKIPPED",
                reason="TIME_WINDOW",
                details={"exit_reason": "INTRADAY_TIME_EXIT"},
            )
        except Exception:
            pass
        return None

    strength, strength_details = _effective_strength_for_paper_entry(db, analysis)
    if not _strength_at_least(strength, _min_strength_required()):
        logger.info(
            "Paper trade skipped (strength too low) | symbol=%s strength=%s min_required=%s",
            symbol,
            str(strength),
            str(_min_strength_required()),
        )
        # Best-effort audit to explain why trades are not being created.
        try:
            _audit_event(
                db,
                user_id=str(user_id),
                account_id=str(account_id),
                symbol=symbol,
                event="TRADE_SKIPPED",
                reason="STRENGTH_TOO_LOW",
                details={
                    "min_required": _min_strength_required(),
                    **(strength_details or {}),
                },
            )
        except Exception:
            pass
        return None

    entry, sl, target = _extract_trade_plan(analysis)
    if entry is None or sl is None or target is None:
        logger.info(
            "Paper trade skipped (missing plan) | symbol=%s entry=%s sl=%s target=%s",
            symbol,
            str(entry),
            str(sl),
            str(target),
        )
        return None

    # Paper account is required for sizing/reservation.
    acc = get_or_create_paper_account(db, user_id=str(user_id), account_id=str(account_id))
    settings = acc.get("settings") if isinstance(acc.get("settings"), dict) else {}
    available = _safe_float(acc.get("available_balance"))
    if available is None:
        bal = _safe_float(acc.get("balance")) or 0.0
        reserved = _safe_float(acc.get("reserved_balance")) or 0.0
        available = max(0.0, bal - reserved)

    planned_entry = float(entry)
    planned_sl = float(sl)
    planned_target = float(target)

    # Apply optional caps.
    sl, target, entry = _apply_overrides(float(entry), float(sl), float(target), direction, settings)

    # LONG-only regime gate (minimal and best-effort).
    if direction == "LONG":
        allowed, details = _long_regime_allows(analysis, market_data=market_data, preferred_timeframe=preferred_timeframe)
        if not allowed:
            _audit_event(
                db,
                user_id=str(user_id),
                account_id=str(account_id),
                symbol=symbol,
                event="TRADE_SKIPPED",
                reason="LONG_BLOCKED_BY_REGIME",
                details={"strength": strength, "details": details},
            )
            return None

    # If Zerodha is available, size by limits (can exceed balance) and reserve required margin.
    qty = 0
    trade_value = 0.0
    reserved_amount = 0.0
    required_margin = None
    margin_raw = None
    product = (settings.get("product") or "MIS") if isinstance(settings, dict) else "MIS"
    product = (str(product).strip().upper() or "MIS")

    if zerodha_client is not None:
        qty0, tv0 = _compute_quantity_by_limits(float(entry), settings)
        if qty0 > 0 and tv0 > 0:
            req, raw = _get_required_margin_from_zerodha(
                db,
                zerodha_client,
                user_id=str(user_id),
                account_id=str(account_id),
                symbol=symbol,
                direction=direction,
                qty=int(qty0),
                price=float(entry),
                product=product,
            )
            if req is not None and req >= 0:
                if float(req) <= float(available) + 1e-9:
                    qty, trade_value = int(qty0), float(tv0)
                    required_margin, margin_raw = float(req), raw
                else:
                    # Try scaling down once to fit available.
                    scaled_qty = int((float(qty0) * float(available)) // float(req)) if float(req) > 0 else 0
                    scaled_qty = max(0, min(scaled_qty, int(settings.get("max_quantity") or scaled_qty or 0) or scaled_qty))
                    if scaled_qty > 0:
                        req2, raw2 = _get_required_margin_from_zerodha(
                            db,
                            zerodha_client,
                            user_id=str(user_id),
                            account_id=str(account_id),
                            symbol=symbol,
                            direction=direction,
                            qty=int(scaled_qty),
                            price=float(entry),
                            product=product,
                        )
                        tv2 = float(entry) * float(scaled_qty)
                        min_tv = _safe_float((settings or {}).get("min_trade_value")) or 0.0
                        if req2 is not None and float(req2) <= float(available) + 1e-9 and tv2 > 0 and float(tv2) + 1e-9 >= float(min_tv):
                            qty, trade_value = int(scaled_qty), float(tv2)
                            required_margin, margin_raw = float(req2), raw2

    if zerodha_client is not None and _paper_margin_required():
        # If Zerodha is available but we couldn't compute a required margin, do NOT fall back to notional sizing.
        if required_margin is None or required_margin < 0 or qty <= 0:
            _audit_event(
                db,
                user_id=str(user_id),
                account_id=str(account_id),
                symbol=symbol,
                event="TRADE_SKIPPED",
                reason="MARGIN_UNAVAILABLE",
                details={"strength": strength, "product": product},
            )
            return None

    # Fallback: no Zerodha margin available -> reserve notional (no leverage).
    if qty <= 0 or trade_value <= 0:
        qty, trade_value = _compute_quantity(float(entry), float(available), settings)
        if qty <= 0 or trade_value <= 0:
            _audit_event(
                db,
                user_id=str(user_id),
                account_id=str(account_id),
                symbol=symbol,
                event="TRADE_SKIPPED",
                reason="LIMITS_OR_BALANCE",
                details={"strength": strength, "available": float(available)},
            )
            return None

    if required_margin is not None and required_margin >= 0:
        reserved_amount = float(required_margin)
    else:
        reserved_amount = float(trade_value)

    signal_id = analysis.get("analysis_id")
    if signal_id is not None:
        signal_id = str(signal_id)

    # Enforce "one ACTIVE trade per user/account/symbol" (OPEN or PENDING).
    col = db[PAPER_TRADES_COLLECTION]
    existing = col.find_one(
        {"user_id": str(user_id), "account_id": str(account_id), "symbol": symbol, "status": {"$in": ["OPEN", "PENDING"]}},
        {"_id": 1, "status": 1},
    )
    if existing:
        _audit_event(
            db,
            user_id=str(user_id),
            account_id=str(account_id),
            symbol=symbol,
            event="TRADE_SKIPPED",
            reason="SYMBOL_ALREADY_ACTIVE",
            details={"existing_status": str(existing.get("status") or "")},
        )
        return None

    now = _now_utc()
    ts = _safe_dt(analysis.get("timestamp"))
    snap_ts = _safe_dt(snapshot_timestamp) if snapshot_timestamp is not None else None

    decision_probability = analysis.get("decision_probability")
    if decision_probability is None:
        decision_probability = analysis.get("score")

    doc: Dict[str, Any] = {
        "trade_type": "PAPER",
        "user_id": str(user_id),
        "account_id": str(account_id),
        "stock_id": str(stock_id) if stock_id is not None else None,
        "symbol": symbol,
        "direction": direction,
        "entry_price": float(entry),
        "stop_loss": float(sl),
        "target": float(target),
        "planned_entry_price": float(planned_entry),
        "planned_stop_loss": float(planned_sl),
        "planned_target": float(planned_target),
        "quantity": int(qty),
        "trade_value": float(trade_value),
        "reserved_amount": float(reserved_amount),
        "required_margin": float(required_margin) if required_margin is not None else None,
        "margin_product": str(product) if product else None,
        "margin_raw": margin_raw,
        "signal_id": str(signal_id) if signal_id else None,
        "snapshot_id": str(snapshot_id) if snapshot_id else None,
        "snapshot_timestamp": snap_ts,
        "signal_timestamp": ts or now,
        "signal_strength": strength,
        "signal_confidence_raw": (analysis.get("confidence") or None),
        "decision_probability": _safe_float(decision_probability),
        "source": str(source or "").upper() or None,
        "status": "PENDING" if direction == "LONG" else "OPEN",
        "created_at": now,
        "opened_at": now if direction != "LONG" else None,
        "pending_at": now if direction == "LONG" else None,
        "pending_candle_count": 0 if direction == "LONG" else None,
        "open_candle_count": 0,
        "closed_at": None,
        "exit_price": None,
        "exit_reason": None,
        "realized_pnl": None,
        "realized_pnl_per_unit": None,
        "current_unrealized_pnl": None,
        "current_unrealized_pnl_per_unit": None,
        "max_favorable_move": 0.0,
        "max_adverse_move": 0.0,
        "last_price_close": None,
        "last_candle_ts": None,
        "last_candle_timeframe": None,
        "updated_at": now,
        "settings": {
            "min_trade_value": _safe_float(settings.get("min_trade_value")),
            "max_trade_value": _safe_float(settings.get("max_trade_value")),
            "max_quantity": int(settings.get("max_quantity") or 0) if settings.get("max_quantity") is not None else None,
            "max_loss_pct": _safe_float(settings.get("max_loss_pct")),
            "max_profit_pct": _safe_float(settings.get("max_profit_pct")),
            "eod_exit": bool(settings.get("eod_exit")) if settings.get("eod_exit") is not None else None,
            "product": (str(settings.get("product")).strip().upper() if settings.get("product") is not None else None),
        },
        # Keep a compact copy of the plan for audit/debug (no market data blobs).
        "signal": {
            "decision": analysis.get("decision"),
            "entry_price": planned_entry,
            "stop_loss": planned_sl,
            "target": planned_target,
            "confidence": analysis.get("confidence"),
            "decision_probability": _safe_float(decision_probability),
            "rationale": analysis.get("rationale") if isinstance(analysis.get("rationale"), list) else [],
        },
        "effective_plan": {
            "entry_price": float(entry),
            "stop_loss": float(sl),
            "target": float(target),
        },
    }

    # Remove Nones to keep docs clean
    doc = {k: v for k, v in doc.items() if v is not None}

    try:
        # Reserve funds before inserting trade (prevents overspending across multiple open trades).
        acc_col = db[PAPER_ACCOUNTS_COLLECTION]
        qacc = {
            "user_id": str(user_id),
            "account_id": str(account_id),
            "available_balance": {"$gte": float(reserved_amount)},
        }
        upd = {
            "$inc": {"reserved_balance": float(reserved_amount), "available_balance": -float(reserved_amount)},
            "$set": {"updated_at": _now_utc()},
            "$push": {
                "events": {
                    "ts": _now_utc(),
                    "type": "TRADE_PENDING_RESERVED" if direction == "LONG" else "TRADE_OPEN_RESERVED",
                    "symbol": symbol,
                    "reserved": float(reserved_amount),
                    "entry": float(entry),
                    "qty": int(qty),
                    "signal_id": str(signal_id) if signal_id else None,
                }
            },
        }

        acc_upd = acc_col.update_one(qacc, upd)
        if acc_upd.modified_count != 1:
            return None

        res = col.insert_one(doc)
        paper_trade_id = str(res.inserted_id)
        col.update_one({"_id": res.inserted_id}, {"$set": {"paper_trade_id": paper_trade_id}})

        _audit_event(
            db,
            user_id=str(user_id),
            account_id=str(account_id),
            symbol=symbol,
            event="TRADE_CREATED",
            reason="PENDING" if direction == "LONG" else "OPEN",
            details={
                "trade_id": paper_trade_id,
                "direction": direction,
                "strength": strength,
                "qty": int(qty),
                "reserved": float(reserved_amount),
                "required_margin": float(required_margin) if required_margin is not None else None,
                "product": product,
            },
        )

        _mark_intraday_equity(db, user_id=str(user_id), account_id=str(account_id), reason=f"OPEN_{symbol}")
        return paper_trade_id
    except Exception:
        # Likely unique index collision on signal_id or race.
        logger.exception("Failed to create paper trade for %s", symbol)
        return None


def process_signal_and_market_update(
    db,
    user_id: str,
    account_id: Optional[str],
    stock_id: Optional[str],
    symbol: str,
    analysis: Dict[str, Any],
    market_data: Optional[Dict[str, Any]],
    source: str,
    preferred_timeframe: str,
    snapshot_id: Optional[str] = None,
    snapshot_timestamp: Optional[datetime] = None,
    zerodha_client: Any = None,
) -> Dict[str, Any]:
    """Core loop: update OPEN trades using latest candle, then create new trade if VERY_STRONG."""

    ensure_paper_trades_indexes(db)
    ensure_paper_game_indexes(db)

    symbol = _normalize_symbol(symbol or (analysis or {}).get("symbol"))

    candle_raw, tf = _pick_latest_candle(market_data, preferred_tf=preferred_timeframe)
    updated = 0
    if candle_raw:
        updated = update_open_paper_trades_for_symbol(db, symbol=symbol, candle=candle_raw, candle_timeframe=tf, analysis=analysis)
        if snapshot_id:
            try:
                q: Dict[str, Any] = {"symbol": symbol, "status": "OPEN"}
                if user_id:
                    q["user_id"] = str(user_id)
                if account_id:
                    q["account_id"] = str(account_id)
                db[PAPER_TRADES_COLLECTION].update_many(
                    q,
                    {"$set": {"last_snapshot_id": str(snapshot_id)}},
                )
            except Exception:
                logger.exception("Failed to update last_snapshot_id for symbol=%s", symbol)

    created_id = create_paper_trade_from_signal(
        db,
        user_id=user_id,
        account_id=account_id,
        stock_id=stock_id,
        symbol=symbol,
        analysis=analysis,
        source=source,
        snapshot_id=snapshot_id,
        snapshot_timestamp=snapshot_timestamp,
        zerodha_client=zerodha_client,
        market_data=market_data,
        preferred_timeframe=preferred_timeframe,
    )

    return {"updated_open_trades": updated, "created_paper_trade_id": created_id}


def create_manual_paper_trade(
    db,
    *,
    user_id: str,
    account_id: Optional[str],
    symbol: str,
    direction: str,
    entry_price: float,
    stop_loss: float,
    target: float,
    quantity: int,
    stock_id: Optional[str] = None,
    snapshot_id: Optional[str] = None,
) -> Dict[str, Any]:
    """Create a manual paper trade with explicit plan + quantity.

    This bypasses GPT score/strength filters and creates an OPEN trade immediately.
    Returns {ok, paper_trade_id?, reason?}.
    """

    ensure_paper_trades_indexes(db)
    ensure_paper_game_indexes(db)

    uid = str(user_id or "").strip()
    if not uid:
        return {"ok": False, "reason": "USER_ID_MISSING"}

    acct = str(account_id).strip() if account_id is not None and str(account_id).strip() else None
    if not acct:
        acct = (os.getenv("PAPER_DEFAULT_ACCOUNT_ID") or "default").strip() or "default"

    sym = _normalize_symbol(symbol)
    if not sym:
        return {"ok": False, "reason": "SYMBOL_MISSING"}

    d = (direction or "").strip().upper()
    if d in {"BUY", "LONG"}:
        d = "LONG"
    elif d in {"SELL", "SHORT"}:
        d = "SHORT"
    if d not in {"LONG", "SHORT"}:
        return {"ok": False, "reason": "DIRECTION_INVALID"}

    try:
        entry = float(entry_price)
        sl = float(stop_loss)
        tgt = float(target)
    except Exception:
        return {"ok": False, "reason": "PRICE_INVALID"}

    qty = int(quantity or 0)
    if qty <= 0:
        return {"ok": False, "reason": "QTY_INVALID"}

    # Basic sanity: prices must be positive
    if entry <= 0 or sl <= 0 or tgt <= 0:
        return {"ok": False, "reason": "PRICE_NON_POSITIVE"}

    # Enforce "one ACTIVE trade per user/account/symbol" (OPEN only, manual creates OPEN).
    col = db[PAPER_TRADES_COLLECTION]
    existing = col.find_one(
        {"user_id": uid, "account_id": acct, "symbol": sym, "status": {"$in": ["OPEN", "PENDING"]}},
        {"_id": 1, "status": 1},
    )
    if existing:
        try:
            _audit_event(
                db,
                user_id=uid,
                account_id=acct,
                symbol=sym,
                event="TRADE_SKIPPED",
                reason="SYMBOL_ALREADY_ACTIVE",
                details={"existing_status": str(existing.get("status") or "")},
            )
        except Exception:
            pass
        return {"ok": False, "reason": "SYMBOL_ALREADY_ACTIVE"}

    acc = get_or_create_paper_account(db, user_id=uid, account_id=acct)
    settings = acc.get("settings") if isinstance(acc.get("settings"), dict) else {}
    max_qty = settings.get("max_quantity")
    try:
        if max_qty is not None:
            qty = min(qty, int(max_qty))
    except Exception:
        pass
    if qty <= 0:
        return {"ok": False, "reason": "QTY_INVALID"}

    trade_value = float(entry) * float(qty)
    reserved_amount = float(trade_value)

    available = _safe_float(acc.get("available_balance"))
    if available is None:
        bal = _safe_float(acc.get("balance")) or 0.0
        reserved = _safe_float(acc.get("reserved_balance")) or 0.0
        available = max(0.0, bal - reserved)

    if float(available) + 1e-9 < float(reserved_amount):
        try:
            _audit_event(
                db,
                user_id=uid,
                account_id=acct,
                symbol=sym,
                event="TRADE_SKIPPED",
                reason="LIMITS_OR_BALANCE",
                details={"available": float(available), "required": float(reserved_amount)},
            )
        except Exception:
            pass
        return {"ok": False, "reason": "INSUFFICIENT_BALANCE"}

    now = _now_utc()

    # Reserve first, then insert.
    acc_col = db[PAPER_ACCOUNTS_COLLECTION]
    qacc = {"user_id": uid, "account_id": acct, "available_balance": {"$gte": float(reserved_amount)}}
    upd = {
        "$inc": {"reserved_balance": float(reserved_amount), "available_balance": -float(reserved_amount)},
        "$set": {"updated_at": _now_utc()},
        "$push": {
            "events": {
                "ts": _now_utc(),
                "type": "TRADE_MANUAL_RESERVED",
                "symbol": sym,
                "reserved": float(reserved_amount),
                "entry": float(entry),
                "qty": int(qty),
            }
        },
    }

    acc_upd = acc_col.update_one(qacc, upd)
    if acc_upd.modified_count != 1:
        return {"ok": False, "reason": "INSUFFICIENT_BALANCE"}

    doc: Dict[str, Any] = {
        "trade_type": "PAPER",
        "user_id": uid,
        "account_id": acct,
        "stock_id": str(stock_id) if stock_id is not None else None,
        "symbol": sym,
        "direction": d,
        "entry_price": float(entry),
        "stop_loss": float(sl),
        "target": float(tgt),
        "quantity": int(qty),
        "trade_value": float(trade_value),
        "reserved_amount": float(reserved_amount),
        "required_margin": None,
        "source": "MANUAL",
        "status": "OPEN",
        "created_at": now,
        "opened_at": now,
        "closed_at": None,
        "exit_price": None,
        "exit_reason": None,
        "realized_pnl": None,
        "realized_pnl_per_unit": None,
        "current_unrealized_pnl": None,
        "current_unrealized_pnl_per_unit": None,
        "max_favorable_move": 0.0,
        "max_adverse_move": 0.0,
        "last_price_close": None,
        "last_candle_ts": None,
        "last_candle_timeframe": None,
        "snapshot_id": str(snapshot_id) if snapshot_id else None,
        "updated_at": now,
        "signal": {
            "decision": "BUY" if d == "LONG" else "SELL",
            "entry_price": float(entry),
            "stop_loss": float(sl),
            "target": float(tgt),
            "confidence": "MANUAL",
        },
        "effective_plan": {"entry_price": float(entry), "stop_loss": float(sl), "target": float(tgt)},
    }

    # Remove Nones
    doc = {k: v for k, v in doc.items() if v is not None}

    try:
        res = col.insert_one(doc)
        paper_trade_id = str(res.inserted_id)
        col.update_one({"_id": res.inserted_id}, {"$set": {"paper_trade_id": paper_trade_id}})

        try:
            _audit_event(
                db,
                user_id=uid,
                account_id=acct,
                symbol=sym,
                event="TRADE_CREATED",
                reason="MANUAL_OPEN",
                details={"trade_id": paper_trade_id, "direction": d, "qty": int(qty), "reserved": float(reserved_amount)},
            )
        except Exception:
            pass

        _mark_intraday_equity(db, user_id=uid, account_id=acct, reason=f"MANUAL_{sym}")
        return {"ok": True, "paper_trade_id": paper_trade_id}
    except Exception:
        logger.exception("Failed to create manual paper trade for %s", sym)
        return {"ok": False, "reason": "DB_INSERT_FAILED"}
