"""
test_wash_trade_exit_bugfix.py

[BUGFIX 2026-09-02] Regression coverage for two related fixes made after
reviewing 2026-09-02's live session logs:

1. exit_position()'s wash-trade handler used to adopt ANY existing_order_id
   Alpaca handed back in a wash-trade rejection as if it were a resting
   close (SELL) order, with no check that it actually was one. Live logs
   showed the rejection can instead name the position's own still-
   unsettled entry (BUY) order -- adopting that meant _poll_pending_exit()
   later saw the BUY fill and _finalize_exit() recorded it as the exit,
   marking the position "closed" locally (with a fabricated exit price)
   while the real shares stayed open, unmanaged, at the broker indefinitely
   (confirmed live for OWL and NVAX on 2026-09-02: RECONCILE flagged both
   as broker-side orphans from shortly after entry through market close).

2. reconcile_with_broker() previously had no interval throttle of its own
   and fired on every 5s main-loop cycle.

No real Alpaca calls are made anywhere in this file -- self.client is a
fake with hand-scripted responses.
"""

import sys
import os
import shutil
import tempfile
import unittest
from datetime import datetime, timedelta, timezone

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))


class _FakeOrder:
    def __init__(self, id, symbol, side, status, filled_avg_price=None):
        self.id = id
        self.symbol = symbol
        self.side = side
        self.status = status
        self.filled_avg_price = filled_avg_price


class _WashTradeError(Exception):
    def __init__(self, existing_order_id):
        super().__init__(
            '{"code":40310000,"existing_order_id":"%s","message":'
            '"potential wash trade detected. use complex orders",'
            '"reject_reason":"opposite side market/stop order exists"}'
            % existing_order_id
        )


class _FakeClient:
    """Hand-scripted stand-in for alpaca_client.AlpacaClient."""

    def __init__(self):
        self.orders_by_id = {}
        self.close_position_calls = 0
        self.close_position_side_effect = None
        self.open_positions = []
        self.get_open_positions_calls = 0

    def close_position(self, symbol):
        self.close_position_calls += 1
        if self.close_position_side_effect is not None:
            raise self.close_position_side_effect
        raise AssertionError("test must set close_position_side_effect")

    def get_order(self, order_id):
        return self.orders_by_id.get(order_id)

    def get_open_positions(self):
        self.get_open_positions_calls += 1
        return self.open_positions


class WashTradeExitBugfixTests(unittest.TestCase):
    def setUp(self):
        self.tmpdir = tempfile.mkdtemp()
        self._orig_cwd = os.getcwd()
        os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

        import config_loader
        config_loader.get_config.cache_clear()

        global data_store, position_manager
        import data_store
        import position_manager

        self._orig_dirs = data_store._dirs

        def fake_dirs():
            from pathlib import Path
            base = self.tmpdir
            out = {}
            for name in ("premarket", "intraday", "trades", "state"):
                p = Path(base) / name
                p.mkdir(parents=True, exist_ok=True)
                out[name] = p
            return out

        data_store._dirs = fake_dirs
        data_store.ensure_dirs()

        # simulation=True skips the real get_client()/env validation in
        # __init__; we then swap in a fake client and flip simulation off
        # so exit_position()/reconcile_with_broker() take the live-broker
        # code paths against the fake instead.
        self.pm = position_manager.PositionManager(simulation=True)
        self.pm.simulation = False
        self.fake_client = _FakeClient()
        self.pm.client = self.fake_client

        self.pm.positions["OWL"] = {
            "symbol": "OWL", "entry_price": 11.65, "entry_time": datetime.now(timezone.utc).isoformat(),
            "initial_stop": 11.54, "current_stop": 11.59, "highest_price": 11.69,
            "current_price": 11.59, "trailing_distance": 0.11, "shares": 152,
            "current_pl": 0.0, "mfe": 6.23, "exit_price": None, "exit_reason": None,
            "status": "open", "order_id": "ENTRY-OWL-1", "entry_reason": "test",
            "fade_grace_active": 0.0, "grace_extensions_used": 1,
            "exit_pending": False, "exit_order_id": None, "exit_reason_pending": None,
            "exit_retry_after": None,
        }

    def tearDown(self):
        shutil.rmtree(self.tmpdir, ignore_errors=True)
        data_store._dirs = self._orig_dirs
        os.chdir(self._orig_cwd)

    # ------------------------------------------------------------------
    def test_wash_trade_naming_own_entry_order_is_not_adopted(self):
        """The exact 2026-09-02 OWL/NVAX failure: the wash-trade rejection
        names the position's own still-open BUY order. That must NOT be
        adopted as the exit -- the position should stay 'open' (so the
        next cycle retries a clean close) rather than being marked
        'closed' off of a buy fill."""
        from alpaca.trading.enums import OrderSide

        p = self.pm.positions["OWL"]
        self.fake_client.close_position_side_effect = _WashTradeError("ENTRY-OWL-1")
        # The named order is genuinely OWL's own entry: same id, BUY side,
        # already filled (this is what made the original bug's symptom --
        # an immediate "exit fill" -- possible).
        self.fake_client.orders_by_id["ENTRY-OWL-1"] = _FakeOrder(
            "ENTRY-OWL-1", "OWL", OrderSide.BUY, "filled", filled_avg_price=11.65
        )

        result = self.pm.exit_position("OWL", 11.59, "TRAILING_STOP")

        self.assertIsNone(result)
        self.assertEqual(p["status"], "open",
                          "position must NOT be marked closed/closing off an adopted BUY order")
        self.assertIsNone(p["exit_order_id"])
        self.assertNotEqual(p.get("current_pl", 0.0), -2.49,
                             "must not fabricate the historical bad exit price")

    def test_wash_trade_naming_a_real_resting_sell_is_still_adopted(self):
        """Sanity check the fix doesn't break the legitimate case the
        2026-08-18 bugfix was written for: a genuine resting close/SELL
        order should still be adopted and polled to completion."""
        from alpaca.trading.enums import OrderSide

        p = self.pm.positions["OWL"]
        self.fake_client.close_position_side_effect = _WashTradeError("REAL-CLOSE-1")
        self.fake_client.orders_by_id["REAL-CLOSE-1"] = _FakeOrder(
            "REAL-CLOSE-1", "OWL", OrderSide.SELL, "filled", filled_avg_price=11.60
        )

        result = self.pm.exit_position("OWL", 11.59, "TRAILING_STOP")

        self.assertIsNotNone(result)
        self.assertEqual(p["status"], "closed")
        self.assertEqual(p["exit_order_id"], "REAL-CLOSE-1")
        self.assertEqual(p["exit_price"], 11.60)

    def test_reconcile_with_broker_is_throttled(self):
        """reconcile_with_broker() must not hit the broker on every call --
        only once per schedule.reconcile_interval_seconds."""
        self.fake_client.open_positions = []

        self.pm.reconcile_with_broker()
        self.pm.reconcile_with_broker()
        self.pm.reconcile_with_broker()
        self.assertEqual(self.fake_client.get_open_positions_calls, 1,
                          "back-to-back calls within the throttle window must be no-ops")

        # Force the throttle window to have elapsed and confirm it fires again.
        self.pm._last_reconcile = datetime.now(timezone.utc) - timedelta(seconds=9999)
        self.pm.reconcile_with_broker()
        self.assertEqual(self.fake_client.get_open_positions_calls, 2)


if __name__ == "__main__":
    unittest.main()
