"""
position_manager.py

Owns local position state for this project: sizing a new position off
the stop the entry rule module supplied (1% account risk, capped by
max_position_notional_pct_of_equity), submitting entry/exit orders
through alpaca_client, and persisting to data_store so a mid-day restart
doesn't lose track of what's actually open.

Core file: contains NO entry or exit strategy -- those live in the rule
modules (breakout_rules.py, reversal_rules.py, exit_rules.py). Reuses
the same wash-trade / position-not-found error handling already coded
into alpaca_client.py, since those are real broker-timing races
already proven to happen against this same paper-account family, not
something worth reinventing.

Known simplification: a filled position is recorded locally at the
entry decision price (the market price at the instant BUY
fired), not the order's actual average fill price -- a market order
can fill a few cents away. Same simplification alpaca_client.py's own
docstrings note sip_bot's position_manager.py makes at entry time.
Acceptable for now; revisit if fill slippage turns out to matter once
there's real trade data.
"""

import math
from datetime import datetime, timezone

from config_loader import get_config
from logger_setup import get_logger
from alpaca_client import get_client, parse_wash_trade_error, parse_position_not_found_error
import data_store

log = get_logger("position_manager")


class PositionManager:
    def __init__(self):
        self.client = get_client()
        self.cfg = get_config()["trading"]
        self.positions = data_store.load_positions()

    # ------------------------------------------------------------------
    def open_positions_count(self) -> int:
        return len(self.positions)

    def has_available_slot(self) -> bool:
        return self.open_positions_count() < self.cfg["max_positions"]

    def is_symbol_open(self, symbol: str) -> bool:
        return symbol in self.positions

    def get_open_symbols(self) -> list:
        return list(self.positions.keys())

    # ------------------------------------------------------------------
    def closed_trades_today(self) -> list:
        """Today's closed trades, oldest first (rule modules see the last
        one per symbol as view.last_trade)."""
        return [t for t in data_store.load_today_trades() if t.get("status") == "closed"]

    def calculate_qty(self, equity: float, entry_price: float, stop_price: float) -> int:
        """Risk account_risk_pct_per_trade% of equity on the distance
        between entry and the supplied stop, capped so no single
        position exceeds max_position_notional_pct_of_equity."""
        risk_per_share = entry_price - stop_price
        if risk_per_share <= 0 or not entry_price:
            return 0

        risk_dollars = equity * self.cfg["account_risk_pct_per_trade"] / 100.0
        qty = math.floor(risk_dollars / risk_per_share)

        notional_cap = equity * self.cfg["max_position_notional_pct_of_equity"] / 100.0
        qty = min(qty, math.floor(notional_cap / entry_price))

        return qty if qty >= self.cfg["min_shares"] else 0

    # ------------------------------------------------------------------
    def enter_position(self, symbol: str, entry_price: float, stop_price: float,
                        reasons: list = None, setup: str = None, plan: dict = None) -> bool:
        if self.is_symbol_open(symbol) or not self.has_available_slot():
            return False

        account = self.client.get_account()
        equity = float(account.equity)
        qty = self.calculate_qty(equity, entry_price, stop_price)
        if qty <= 0:
            log.info(f"[ENTRY] {symbol} sized to 0 shares (equity=${equity:.2f}, "
                     f"entry=${entry_price:.2f}, stop=${stop_price:.2f}) -- skipped")
            return False

        try:
            order = self.client.submit_market_order(symbol, qty, "buy")
        except Exception as e:
            log.error(f"[ENTRY] {symbol} order submission failed: {e}")
            return False

        now = datetime.now(timezone.utc)
        self.positions[symbol] = {
            "symbol": symbol, "qty": qty, "entry_price": entry_price,
            "stop_price": stop_price, "entry_time": now.isoformat(),
            "entry_order_id": str(getattr(order, "id", "")),
            "reasons": reasons or [],
            # which rule module bought it, and whatever plan it attached
            # (target, levels...) -- exit_rules.py reads these back
            "setup": setup, "plan": plan or {},
        }
        self._save()
        log.info(f"[ENTRY] {symbol} BUY {qty} @ ~${entry_price:.2f} [{setup}] "
                 f"(stop ${stop_price:.2f}, reasons: {'; '.join(reasons or [])})")
        return True

    # ------------------------------------------------------------------
    def exit_position(self, symbol: str, exit_price: float, reason: str):
        p = self.positions.get(symbol)
        if not p:
            return

        try:
            self.client.close_position(symbol)
        except Exception as e:
            order_id = parse_wash_trade_error(e)
            if order_id:
                # [BUGFIX 2026-09-22] Used to assume the resting order was
                # a close and drop the position. On 2026-09-21 it was
                # UUUU's own still-unfilled entry BUY: the bot logged a
                # fake exit, the BUY then filled, and 164 shares sat
                # untracked overnight tying up ~$2k of buying power.
                if not self._resolve_wash_conflict(symbol, order_id):
                    return
            elif parse_position_not_found_error(e):
                if not self._entry_never_filled(symbol, p):
                    log.warning(f"[EXIT] {symbol} broker reports no position yet "
                                f"(entry order likely still settling) -- will retry next poll")
                return
            else:
                log.error(f"[EXIT] {symbol} close_position failed: {e}")
                return

        if p.get("cancelled_entry_order_id"):
            # Entry was cancelled mid-fill; P/L on what actually filled.
            order = self.client.get_order(p["cancelled_entry_order_id"])
            if order is not None and float(order.filled_qty or 0) > 0:
                p["qty"] = int(float(order.filled_qty))

        pl_pct = (exit_price - p["entry_price"]) / p["entry_price"] * 100.0 if p["entry_price"] else 0.0
        pl_dollars = (exit_price - p["entry_price"]) * p["qty"]
        trade = {
            **p, "exit_price": exit_price, "exit_time": datetime.now(timezone.utc).isoformat(),
            "exit_reason": reason, "status": "closed",
            "pl_pct": round(pl_pct, 3), "pl_dollars": round(pl_dollars, 2),
        }
        data_store.append_trade_record(trade)
        del self.positions[symbol]
        self._save()
        log.info(f"[EXIT] {symbol} SELL @ ~${exit_price:.2f} ({reason}) -- "
                 f"P/L {pl_pct:+.2f}% (${pl_dollars:+.2f})")

    # ------------------------------------------------------------------
    def _resolve_wash_conflict(self, symbol: str, order_id: str) -> bool:
        """close_position() was rejected because order_id is resting on
        the other side. True = the position is genuinely being closed
        (record the exit now); False = keep it tracked, retry next poll."""
        p = self.positions[symbol]
        order = self.client.get_order(order_id)
        if order is None:
            log.warning(f"[EXIT] {symbol} wash-trade conflict with {order_id} but couldn't "
                        f"fetch that order -- keeping position, will retry next poll")
            return False

        side = getattr(order.side, "value", str(order.side)).lower()
        status = getattr(order.status, "value", str(order.status)).lower()
        if side == "sell":
            log.warning(f"[EXIT] {symbol} a SELL ({order_id}, {status}) is already working; "
                        f"treating as being closed out")
            return True

        # The conflict is a BUY -- normally our own entry that hasn't
        # filled yet. Cancel it so the next poll's close goes through
        # (or finds nothing to close if it never filled at all).
        log.warning(f"[EXIT] {symbol} close blocked by our resting BUY {order_id} ({status}); "
                    f"cancelling it and keeping the position tracked")
        self.client.cancel_order(order_id)
        p["cancelled_entry_order_id"] = order_id
        self._save()
        return False

    def _entry_never_filled(self, symbol: str, p: dict) -> bool:
        """Broker says no position. If the entry order is dead with zero
        fill and the broker really holds nothing, stop tracking it (there
        was no trade); otherwise the entry is still settling."""
        order_id = p.get("cancelled_entry_order_id") or p.get("entry_order_id")
        order = self.client.get_order(order_id) if order_id else None
        if order is None:
            return False
        status = getattr(order.status, "value", str(order.status)).lower()
        filled = float(order.filled_qty or 0)
        if status not in ("canceled", "expired", "rejected") or filled > 0:
            return False
        if self.client.get_position_qty(symbol) != 0:
            return False
        log.warning(f"[EXIT] {symbol} entry order {order_id} {status} with no fill -- "
                    f"no trade happened, dropping position")
        del self.positions[symbol]
        self._save()
        return True

    def _save(self):
        data_store.save_positions(self.positions)
