"""
cycle.py — autonomous trading cycle for IBKR paper-trading bot.
Runs every 5 minutes via Task Scheduler. Handles entries, stop management, force-close.
"""
import csv
import json
import os
import sys
import traceback
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import numpy as np

from dotenv import load_dotenv
load_dotenv(Path(".env"))

from src.ibkr_client import IBKRClient
from src.notify import notify
import strategy

ET = ZoneInfo("America/New_York")
LOG_PATH = Path("logs/safety-check-log.json")
ERROR_LOG_PATH = Path("logs/cycle_errors.log")
POSITIONS_PATH = Path("open_positions.json")
TRADES_PATH = Path("trades.csv")
WATCHLIST_PATH = Path("watchlist.txt")

LOG_PATH.parent.mkdir(exist_ok=True)
ERROR_LOG_PATH.parent.mkdir(exist_ok=True)

def cast_bool(v):
    """Cast numpy bool to Python bool."""
    if isinstance(v, (np.bool_, np.integer)):
        return bool(v)
    return v

def log_event(decision, data=None):
    """Append JSON event to safety-check-log.json."""
    event = {"timestamp": datetime.now(ET).isoformat(), "decision": decision}
    if data:
        event["data"] = {k: cast_bool(v) for k, v in data.items()}
    try:
        with open(LOG_PATH, "a") as f:
            f.write(json.dumps(event) + "\n")
    except Exception as e:
        print(f"[WARN] Could not write log: {e}")

def time_gate():
    """
    Returns one of: "weekend", "too_early", "closed", "manage_only", "force_close", "ok".
    """
    now = datetime.now(ET)
    dow = now.weekday()
    hour = now.hour
    minute = now.minute
    hm = hour * 100 + minute

    if dow >= 5:
        return "weekend"
    if hour < 9 or (hour == 9 and minute < 30) or hour >= 16:
        return "too_early" if (hour < 9 or (hour == 9 and minute < 30)) else "closed"
    if (hour == 9 and 30 <= minute < 35) or 1530 <= hm < 1551:
        return "manage_only"
    if 1551 <= hm < 1600:
        return "force_close"
    return "ok"

def log_trade(symbol, side, size, fill_price, order_id, status="Filled"):
    """Append a trade row to trades.csv."""
    write_header = not TRADES_PATH.exists()
    try:
        with open(TRADES_PATH, "a", newline="") as f:
            import csv as _csv
            writer = _csv.writer(f)
            if write_header:
                writer.writerow(["timestamp_iso", "symbol", "side", "size", "fill_price", "order_id", "status"])
            writer.writerow([datetime.now(ET).isoformat(), symbol, side, size, fill_price, order_id, status])
    except Exception as e:
        print(f"[WARN] Could not log trade: {e}")


def load_positions():
    """Load open_positions.json. Return [] if missing."""
    if not POSITIONS_PATH.exists():
        return []
    try:
        return json.loads(POSITIONS_PATH.read_text())
    except:
        return []

def save_positions(positions):
    """Atomic write to open_positions.json."""
    tmp = POSITIONS_PATH.with_suffix('.tmp')
    with open(tmp, 'w') as f:
        json.dump(positions, f, indent=2)
    os.replace(tmp, POSITIONS_PATH)

def check_stopouts(ibkr, positions):
    """
    Check fills() for stop-order executions. Remove stopped-out positions.
    CRITICAL: Match by stop_order_id only, NOT by quantity.
    """
    try:
        fills = list(ibkr.ib.fills())
    except:
        return positions

    stopped_out = set()
    for fill in fills:
        for i, pos in enumerate(positions):
            if fill.execution.orderId == pos.get("stop_order_id") and fill.execution.side == "SELL":
                stopped_out.add(i)
                log_event("stop_executed", {"symbol": pos["symbol"], "qty": pos["qty"], "stop_order_id": pos["stop_order_id"]})
                try:
                    fill_price = float(fill.execution.price)
                    entry_price = pos.get("entry_price", fill_price)
                    pnl = (fill_price - entry_price) * pos["qty"]
                    notify(f"STOP {pos['symbol']}", f"exit ${fill_price:.2f}, P&L ${pnl:+.2f}", "default")
                except:
                    pass

    return [p for i, p in enumerate(positions) if i not in stopped_out]

def get_5min_bars(symbol, lookback_bars=20, ibkr=None):
    """Fetch 5-min bars via IBKR historical data."""
    if ibkr is None:
        return None
    try:
        from ib_async import Stock
        contract = Stock(symbol, "SMART", "USD")
        bars = ibkr.ib.reqHistoricalData(
            contract,
            endDateTime="",
            durationStr="2 D",
            barSizeSetting="5 mins",
            whatToShow="TRADES",
            useRTH=True,
            formatDate=1,
        )
        if not bars or len(bars) < lookback_bars:
            return None
        import pandas as pd
        df = pd.DataFrame([{
            "High": b.high, "Low": b.low, "Close": b.close, "Open": b.open, "Volume": b.volume
        } for b in bars])
        return df
    except:
        return None

def find_swing_lows(bars):
    """Find bars whose low is lower than the 2 bars before AND 2 bars after."""
    if len(bars) < 5:
        return []
    swing_lows = []
    lows = bars["Low"].values
    for i in range(2, len(lows) - 2):
        if lows[i] < lows[i-1] and lows[i] < lows[i-2] and lows[i] < lows[i+1] and lows[i] < lows[i+2]:
            swing_lows.append(lows[i])
    return swing_lows

def manage_position(ibkr, position):
    """Manage a single position. Update state, ratchet stops, or take profits."""
    symbol = position["symbol"]
    try:
        ticker = ibkr.ib.qualifyContracts(ibkr.create_contract(symbol))[0]
        [ticker] = ibkr.ib.reqMktData(ticker, "", False, False)
        price = ticker.last if ticker.last > 0 else ticker.close
    except:
        return position

    state = position.get("state", "pre_breakeven")
    entry_price = position["entry_price"]
    qty = position["qty"]
    R = position.get("R", 0)
    if R == 0:
        return position

    if state == "pre_breakeven":
        if price >= entry_price + R:
            try:
                ibkr.ib.cancelOrder([o for o in ibkr.ib.openOrders() if o.orderId == position["stop_order_id"]][0])
            except:
                pass
            try:
                from ib_async import Order
                stop_order = Order()
                stop_order.action = "SELL"
                stop_order.orderType = "STP"
                stop_order.totalQuantity = qty
                stop_order.auxPrice = entry_price
                new_stop = ibkr.ib.placeOrder(ticker, stop_order)
                position["stop_order_id"] = new_stop.orderId
                position["state"] = "post_breakeven_no_partial"
                log_event("stop_breakeven", {"symbol": symbol, "new_stop": entry_price})
                try:
                    notify(f"BE {symbol}", f"stop -> ${entry_price:.2f}", "default")
                except:
                    pass
            except:
                pass
        elif price >= entry_price + 0.75 * R:
            try:
                ibkr.ib.cancelOrder([o for o in ibkr.ib.openOrders() if o.orderId == position["stop_order_id"]][0])
            except:
                pass
            qty_to_sell = int(np.ceil(qty / 3))
            try:
                from ib_async import Order
                sell_order = Order()
                sell_order.action = "SELL"
                sell_order.orderType = "MKT"
                sell_order.totalQuantity = qty_to_sell
                partial_trade = ibkr.ib.placeOrder(ticker, sell_order)
                log_trade(symbol, "SELL", qty_to_sell,
                          partial_trade.orderStatus.avgFillPrice or 0,
                          partial_trade.order.orderId)
                remaining_qty = qty - qty_to_sell
                new_stop_price = entry_price * 0.99
                stop_order = Order()
                stop_order.action = "SELL"
                stop_order.orderType = "STP"
                stop_order.totalQuantity = remaining_qty
                stop_order.auxPrice = new_stop_price
                new_stop = ibkr.ib.placeOrder(ticker, stop_order)
                position["stop_order_id"] = new_stop.orderId
                position["qty"] = remaining_qty
                position["state"] = "post_breakeven_partial_done"
                log_event("partial_profit", {"symbol": symbol, "sold": qty_to_sell, "remaining": remaining_qty})
                try:
                    notify(f"PARTIAL {symbol}", f"sold {qty_to_sell}/{qty} @ ${price:.2f}", "default")
                except:
                    pass
            except:
                pass

    elif state.startswith("post_breakeven"):
        bars = get_5min_bars(symbol, ibkr=ibkr)
        if bars is not None and len(bars) >= 5:
            swing_lows = find_swing_lows(bars)
            if swing_lows:
                newest_swing = swing_lows[-1]
                current_stop = position.get("initial_stop", entry_price * 0.99)
                if newest_swing > current_stop:
                    try:
                        ibkr.ib.cancelOrder([o for o in ibkr.ib.openOrders() if o.orderId == position["stop_order_id"]][0])
                    except:
                        pass
                    new_stop_price = newest_swing - 0.01
                    try:
                        from ib_async import Order
                        stop_order = Order()
                        stop_order.action = "SELL"
                        stop_order.orderType = "STP"
                        stop_order.totalQuantity = position["qty"]
                        stop_order.auxPrice = new_stop_price
                        new_stop = ibkr.ib.placeOrder(ticker, stop_order)
                        position["stop_order_id"] = new_stop.orderId
                        position["initial_stop"] = new_stop_price
                        log_event("ratchet_stop", {"symbol": symbol, "new_stop": new_stop_price})
                        try:
                            notify(f"TRAIL {symbol}", f"stop ${current_stop:.2f} -> ${new_stop_price:.2f}", "default")
                        except:
                            pass
                    except:
                        pass

    return position

def main():
    try:
        gate = time_gate()
        now = datetime.now(ET)
        log_event("cycle_start", {"time_gate": gate})

        if gate in ("weekend", "too_early", "closed"):
            log_event("early_exit", {"reason": gate})
            return

        positions = load_positions()

        host = os.getenv("IBKR_HOST", "127.0.0.1")
        port = int(os.getenv("IBKR_PORT", 7497))
        client_id = int(os.getenv("IBKR_CLIENT_ID", 2))

        ibkr = None
        try:
            ibkr = IBKRClient(host, port, client_id)
        except:
            try:
                import time
                time.sleep(5)
                ibkr = IBKRClient(host, port, client_id)
            except:
                log_event("ibkr_connect_failed")
                sys.exit(1)

        try:
            positions = check_stopouts(ibkr, positions)

            for i, pos in enumerate(positions):
                positions[i] = manage_position(ibkr, pos)

            save_positions(positions)

            if gate == "force_close":
                log_event("force_close_start")
                try:
                    notify("EOD Force Close", f"flattening {len(positions)} positions", "high")
                except:
                    pass
                held_symbols = {p["symbol"] for p in positions}
                for symbol in held_symbols:
                    try:
                        contract = ibkr.create_contract(symbol)
                        for order in ibkr.ib.openOrders():
                            if order.contract.symbol == symbol:
                                ibkr.ib.cancelOrder(order)
                        for pos in positions:
                            if pos["symbol"] == symbol:
                                from ib_async import Order
                                sell_order = Order()
                                sell_order.action = "SELL"
                                sell_order.orderType = "MKT"
                                sell_order.totalQuantity = pos["qty"]
                                fc_trade = ibkr.ib.placeOrder(ibkr.ib.qualifyContracts(contract)[0], sell_order)
                                log_trade(symbol, "SELL", pos["qty"],
                                          fc_trade.orderStatus.avgFillPrice or 0,
                                          fc_trade.order.orderId)
                    except:
                        pass
                POSITIONS_PATH.unlink(missing_ok=True)
                log_event("force_close_done")
                return

            if gate == "manage_only":
                log_event("manage_only_exit")
                return

            if gate == "ok":
                portfolio = float(os.getenv("PORTFOLIO_VALUE_USD", 2000))
                max_risk_pct = float(os.getenv("MAX_RISK_PER_TRADE_PCT", 2.0))
                max_exposure_usd = portfolio * (float(os.getenv("MAX_DAILY_EXPOSURE_PCT", 80)) / 100.0)

                # Live concurrent exposure: sum entry_price * qty across all currently open positions.
                # Resets naturally as positions close — no daily cumulative limit.
                live_exposure = sum(p.get("entry_price", 0) * p.get("qty", 0) for p in positions)
                if live_exposure >= max_exposure_usd:
                    log_event("exposure_limit_reached", {"live_exposure_usd": live_exposure, "limit_usd": max_exposure_usd})
                    return

                held_symbols = {p["symbol"] for p in positions}
                try:
                    ibkr_positions = ibkr.ib.positions()
                    for ibkr_pos in ibkr_positions:
                        held_symbols.add(ibkr_pos.contract.symbol)
                except:
                    pass

                watchlist = []
                if WATCHLIST_PATH.exists():
                    with open(WATCHLIST_PATH) as f:
                        for line in f:
                            line = line.strip()
                            if line and not line.startswith("#"):
                                symbol = line.split()[0].upper()
                                if symbol not in held_symbols:
                                    watchlist.append(symbol)

                for symbol in watchlist[:5]:
                    try:
                        result = strategy.evaluate(symbol, ibkr.ib)
                        if not result["pass"]:
                            log_event("entry_skip", {"symbol": symbol, "reasons": result["reasons"]})
                            continue

                        price = result["price"]
                        lod = result.get("lod", price * 0.99)
                        initial_stop = lod * 0.99
                        R = price - initial_stop

                        if R <= 0:
                            log_event("entry_skip", {"symbol": symbol, "reasons": ["negative_R"]})
                            continue

                        risk_dollars = portfolio * (max_risk_pct / 100.0)
                        # Cap new position so total live exposure stays within $1,600 limit
                        remaining_capacity = max_exposure_usd - live_exposure
                        size = min(
                            int(risk_dollars / R),
                            int(remaining_capacity / price)
                        )

                        if size < 1:
                            log_event("entry_skip", {"symbol": symbol, "reasons": ["size_too_small"]})
                            continue

                        import subprocess
                        cmd = [sys.executable, "trade.py", "--symbol", symbol, "--side", "BUY", "--size", str(size)]
                        try:
                            proc = subprocess.run(cmd, timeout=30, capture_output=True, text=True)
                            if proc.returncode == 0:
                                positions.append({
                                    "symbol": symbol,
                                    "entry_price": price,
                                    "entry_time_iso": now.isoformat(),
                                    "qty": size,
                                    "initial_stop": initial_stop,
                                    "stop_order_id": None,
                                    "state": "pre_breakeven",
                                    "R": R
                                })
                                live_exposure += price * size  # update cap tracker
                                log_event("entry_placed", {"symbol": symbol, "size": size, "price": price, "stop": initial_stop})
                                try:
                                    notify(f"BUY {symbol}", f"@ ${price:.2f}, stop ${initial_stop:.2f}, qty {size}", "default")
                                except:
                                    pass
                                try:
                                    contract = ibkr.create_contract(symbol)
                                    ticker = ibkr.ib.qualifyContracts(contract)[0]
                                    from ib_async import Order
                                    stop_order = Order()
                                    stop_order.action = "SELL"
                                    stop_order.orderType = "STP"
                                    stop_order.totalQuantity = size
                                    stop_order.auxPrice = initial_stop
                                    placed_stop = ibkr.ib.placeOrder(ticker, stop_order)
                                    for p in positions:
                                        if p["symbol"] == symbol and p["entry_time_iso"] == now.isoformat():
                                            p["stop_order_id"] = placed_stop.orderId
                                except:
                                    pass
                            else:
                                log_event("entry_failed", {"symbol": symbol, "error": proc.stderr[:100]})
                        except subprocess.TimeoutExpired:
                            log_event("entry_timeout", {"symbol": symbol})
                    except Exception as e:
                        log_event("entry_exception", {"symbol": symbol, "error": str(e)[:100]})

                save_positions(positions)

        finally:
            if ibkr:
                ibkr.disconnect()

    except Exception as e:
        ERROR_LOG_PATH.parent.mkdir(exist_ok=True)
        with open(ERROR_LOG_PATH, "a") as f:
            f.write(f"{datetime.now(ET).isoformat()}\n{traceback.format_exc()}\n\n")
        try:
            notify("Cycle CRASHED", str(e)[:500], "high")
        except:
            pass
        sys.exit(1)

if __name__ == "__main__":
    main()
