"""
Paper Trading Executor — simulates order execution without real money.

Simulates realistic slippage and fees to get accurate paper P&L.
All trades are logged to PostgreSQL for metrics analysis.
"""
import asyncio
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime, UTC
from typing import Optional

from bot.risk.manager import Order, Portfolio
from bot.data.db import Database
from bot.notify import telegram

log = logging.getLogger(__name__)

# Polymarket taker fee used for paper simulation.
# Also stored as commission in each Trade for audit purposes.
# NOTE: this is a heuristic — see COMMISSION_RATE in bayesian.py for context.
POLYMARKET_FEE = 0.02  # 2%

# Strong references to in-flight notification tasks.  The event loop only
# keeps a weak reference to tasks created via create_task(), so without this
# set a pending Telegram notification could be garbage-collected before it
# runs.  Tasks remove themselves from the set on completion.
_background_tasks: set[asyncio.Task] = set()


def _notify_in_background(coro) -> None:
    """Fire-and-forget a Telegram notification, keeping the task referenced."""
    task = asyncio.create_task(coro)
    _background_tasks.add(task)
    task.add_done_callback(_background_tasks.discard)


def cash_available(bankroll: float, total_net_cost_open: float) -> float:
    """Cash left after the net cost (fees included) of all open positions.

    Single source of truth for the cash figure, shared by
    PaperExecutor.initialize() and the /api/summary endpoint so both always
    report the same number for the same DB state.
    total_net_cost_open comes from Database.get_open_position_data().
    """
    return max(0.0, bankroll - total_net_cost_open)


@dataclass
class Trade:
    id: str
    market_id: str
    question: str
    direction: str
    size_usdc: float
    entry_price: float
    shares: float          # How many YES/NO shares bought
    fee_usdc: float
    net_cost: float
    timestamp: datetime
    reasoning: str
    paper: bool = True
    # ── Phase 1: edge neto audit fields ──────────────────────────────────────
    # edge_gross: raw model edge before any cost deductions
    # edge_net:   edge_gross - spread_estimate - commission/size_usdc
    # Both are heuristic estimates — see schema.sql comment for details.
    edge_gross: float = 0.0
    edge_net: float = 0.0
    prior_prob: float = 0.0      # market.yes_price clamped, before Bayesian update
    final_prob: float = 0.0      # estimated probability after all signals
    # mid_price: order-book midpoint when available; falls back to market.yes_price
    mid_price: float = 0.0
    spread_estimate: float = 0.02
    commission: float = 0.0      # = POLYMARKET_FEE * size_usdc
    # ── Phase 2: market family ────────────────────────────────────────────────
    family_key: str = ""
    # ── Phase 6: per-feature log-odds contributions ───────────────────────────
    feat_fg_lo:      float = 0.0
    feat_mom_lo:     float = 0.0
    feat_news_lo:    float = 0.0
    feat_mfld_lo:    float = 0.0
    feat_btc_dom_lo: float = 0.0
    # ── Manifold match audit ──────────────────────────────────────────────────
    mfld_market_id:   Optional[str]   = None
    mfld_market_title: Optional[str]  = None
    mfld_market_url:  Optional[str]   = None
    mfld_prob_raw:    Optional[float] = None
    mfld_prob_final:  Optional[float] = None
    mfld_inverted:    bool            = False
    mfld_match_score: Optional[float] = None
    mfld_match_reason: Optional[str]  = None
    mfld_match_status: Optional[str]  = None

    def __str__(self) -> str:
        return (
            f"[PAPER] {self.direction} {self.shares:.1f} shares @ {self.entry_price:.3f} "
            f"= ${self.net_cost:.2f} (fee ${self.fee_usdc:.2f}) "
            f"edge_net={self.edge_net:+.3f} family={self.family_key} "
            f"| {self.question[:40]}"
        )


class PaperExecutor:
    """Executes trades on paper — no real money, realistic simulation."""

    def __init__(self, db: Database, bankroll: float) -> None:
        self._db = db
        self._portfolio = Portfolio(
            cash=bankroll,
            positions={},
        )
        log.info("Paper executor initialized with $%.2f bankroll", bankroll)

    async def initialize(self) -> None:
        """Reconcile in-memory portfolio with DB state.

        Called once after __init__ so the executor reflects any trades that
        survived a pod restart.  After a TRUNCATE the DB is empty and the
        portfolio resets to a full bankroll automatically.

        Accounting model (must match execute() exactly):
          positions[market_id] = size_usdc   (fee excluded — same as runtime)
          cash = bankroll - sum(net_cost)    (fees already spent)
          total_value = cash + sum(size_usdc) = bankroll - sum(fees)
          exposure_pct = sum(size_usdc) / total_value
        """
        positions_size, total_net_cost = await self._db.get_open_position_data()
        if not positions_size:
            log.info("No open positions in DB — starting with full bankroll")
            return

        positions_value = sum(positions_size.values())
        self._portfolio.positions = positions_size
        self._portfolio.cash = cash_available(self._portfolio.cash, total_net_cost)

        total_value = self._portfolio.cash + positions_value
        exposure_pct = positions_value / total_value if total_value > 0 else 0.0
        log.info(
            "Restored %d open position(s) from DB — "
            "positions_value=$%.2f net_cost_spent=$%.2f cash=$%.2f "
            "total_value=$%.2f exposure=%.2f%%",
            len(positions_size),
            positions_value,
            total_net_cost,
            self._portfolio.cash,
            total_value,
            exposure_pct * 100,
        )

    def get_portfolio(self) -> Portfolio:
        return self._portfolio

    async def execute(self, order: Order) -> Optional[Trade]:
        """Simulate order execution with fees and slippage."""
        if order.size_usdc > self._portfolio.cash:
            log.warning(
                "Insufficient paper cash: need $%.2f have $%.2f",
                order.size_usdc,
                self._portfolio.cash,
            )
            return None

        # Simulate small slippage (0.1-0.3% depending on order size)
        slippage = min(0.003, order.size_usdc / 100000)

        # Determine entry price based on direction.
        # We fill at the current Polymarket mid price + slippage (buying at ask).
        # BUY_YES  → paying the YES price (order.market_price)
        # BUY_NO   → paying the NO price (1 - order.market_price)
        if order.direction == "BUY_YES":
            entry_price = min(0.99, order.market_price + slippage)
        else:
            entry_price = min(0.99, (1 - order.market_price) + slippage)

        fee = order.size_usdc * POLYMARKET_FEE
        net_cost = order.size_usdc + fee
        shares = order.size_usdc / entry_price

        # commission mirrors the heuristic COMMISSION_RATE applied in bayesian.py
        # when computing edge_net.  Stored for audit: confirms cost assumption held.
        commission = order.size_usdc * POLYMARKET_FEE  # = fee_usdc at current rate

        trade = Trade(
            id=str(uuid.uuid4()),
            market_id=order.market_id,
            question=order.question,
            direction=order.direction,
            size_usdc=order.size_usdc,
            entry_price=entry_price,
            shares=shares,
            fee_usdc=fee,
            net_cost=net_cost,
            timestamp=datetime.now(UTC),
            reasoning=order.reasoning,
            paper=True,
            # Phase 1 audit fields
            edge_gross=order.edge_gross,
            edge_net=order.edge_net,
            prior_prob=order.prior_prob,
            final_prob=order.final_prob,
            mid_price=order.mid_price,
            spread_estimate=order.spread_estimate,
            commission=commission,
            # Phase 2 family
            family_key=order.family_key,
            # Phase 6 feature log-odds
            feat_fg_lo=order.feat_fg_lo,
            feat_mom_lo=order.feat_mom_lo,
            feat_news_lo=order.feat_news_lo,
            feat_mfld_lo=order.feat_mfld_lo,
            feat_btc_dom_lo=order.feat_btc_dom_lo,
            # Manifold audit
            mfld_market_id=order.mfld_market_id,
            mfld_market_title=order.mfld_market_title,
            mfld_market_url=order.mfld_market_url,
            mfld_prob_raw=order.mfld_prob_raw,
            mfld_prob_final=order.mfld_prob_final,
            mfld_inverted=order.mfld_inverted,
            mfld_match_score=order.mfld_match_score,
            mfld_match_reason=order.mfld_match_reason,
            mfld_match_status=order.mfld_match_status,
        )

        # Update paper portfolio
        self._portfolio.cash -= net_cost
        self._portfolio.positions[order.market_id] = order.size_usdc

        # Persist to DB
        await self._db.save_trade(trade)

        _notify_in_background(
            telegram.trade_opened(trade.question, trade.direction, trade.size_usdc, trade.edge_net)
        )

        return trade

    async def close_legacy_position(self, market_id: str, reason: str, question: str = "") -> float:
        """
        Close a paper position flagged by the legacy scan.

        Returns the capital recovered to cash (cost basis, assuming break-even
        exit — exact P&L would require the live exit price which isn't available
        at scan time).
        """
        cost = self._portfolio.positions.pop(market_id, 0.0)
        self._portfolio.cash += cost  # return capital at break-even
        await self._db.close_paper_position(market_id, reason)
        log.warning(
            "LEGACY_CLOSE market=%s | returned $%.2f to cash | %s",
            market_id, cost, reason[:80],
        )
        _notify_in_background(
            telegram.trade_legacy_closed(question or market_id, cost, reason)
        )
        return cost

    async def close_position(self, market_id: str, resolution: float, question: str = "") -> Optional[float]:
        """Close a paper position after market resolution.

        resolution: 1.0 if YES won, 0.0 if NO won.
        Settlement payout per trade:
          BUY_YES: shares * resolution
          BUY_NO:  shares * (1 - resolution)
        pnl = payout - net_cost.
        Persists resolution and close_pnl to DB. Returns realized P&L for
        logging, or None if no position is open.
        """
        if market_id not in self._portfolio.positions:
            return None

        position_cost = self._portfolio.positions[market_id]
        open_trades = await self._db.get_open_trades_for_market(market_id)

        if open_trades:
            payout = sum(
                float(t["shares"])
                * (resolution if t["direction"] == "BUY_YES" else 1.0 - resolution)
                for t in open_trades
            )
            net_cost = sum(float(t["net_cost"]) for t in open_trades)
            pnl = payout - net_cost
        else:
            # In-memory position with no open DB trades: direction/shares are
            # unknown, so settle at break-even instead of guessing the payout.
            log.warning(
                "close_position: no open DB trades for market %s — "
                "settling at break-even", market_id,
            )
            payout = position_cost
            pnl = 0.0

        # Persist first, mutate memory after: if the DB write fails, the
        # in-memory portfolio must keep the position so the next resolution
        # check can retry the close.
        await self._db.close_paper_position(
            market_id,
            reason="resolved",
            resolution=resolution,
        )

        self._portfolio.positions.pop(market_id)
        self._portfolio.cash += payout
        log.info(
            "Closed position in %s, resolution=%.1f payout=$%.2f pnl=%+.2f",
            market_id, resolution, payout, pnl,
        )
        _notify_in_background(
            telegram.trade_closed(question or market_id, pnl)
        )
        return pnl
