"""The trading engine. The SAME code runs in backtests, sim, demo and live."""
import pandas as pd
from .broker import OrderRejected
from .signals import analyze_mtf
from .brain.context import build_context
from .guard import Guard
from .util import tf_minutes


class Engine:
    def __init__(self, cfg, broker, journal, risk, version, params):
        self.cfg, self.b, self.j, self.risk = cfg, broker, journal, risk
        self.v, self.p = version, params
        self.symbol = cfg["symbol"]
        self.tfmin = tf_minutes(cfg["primary_timeframe"])
        self.last_bar = None
        self.last_signal = None
        self.last_loss_time = None
        self.brain = None            # set by the runner: the AI second-opinion analyst
        self.guard = Guard(cfg, params)   # loss control: spread, losing-streak pause, daily cap, cost-aware break-even
        eq = self.b.equity()
        self.peak = max(eq, max(self.j.equity_series(version) or [eq]))
        self.day, self.day_start = None, eq

    # ------------------------------------------------------------------ one step
    def step(self, df, now, bar_high=None, bar_low=None, price=None):
        msgs = []
        price = float(df.close.iloc[-1]) if price is None else float(price)
        hi = price if bar_high is None else float(bar_high)
        lo = price if bar_low is None else float(bar_low)
        self.b.set_price(price)
        eq = self.b.equity()
        self._roll_day(now, eq)
        self.peak = max(self.peak, eq)

        t = self.j.current_trade(self.v)
        if t is not None:
            # Did the broker close it for us (its own stop loss / take profit)?
            held = self.b.open_qty(self.symbol)
            if held is not None and held <= 0:
                info = self.b.closed_info(self.symbol, t)
                if info is None:
                    return msgs + ["position is closed at the broker; waiting for its deal history"]
                why_closed = "EXTERNAL" if info["reason"] == "CLOSED" else info["reason"]
                if why_closed == "EXTERNAL":
                    self.j.event("WARN", "a trade was closed OUTSIDE Sailar-x (by hand in MT5 or by another program)", now)
                msgs.append(self._record_close(t, info["price"], why_closed, now, info["pnl"], info["fees"]))
                self.last_bar = df.index[-1]
                t = None

        self.guard.one_position(self, now, msgs)      # ONE trade at a time: anything extra is dealt with
        t = self.j.current_trade(self.v)
        if t is not None:
            # ── Break-even trailing ───────────────────────────────────────────
            # Move SL to entry once profit >= be_trigger_r × risk distance.
            # This ensures we NEVER lose on a trade that was already +0.8R in profit.
            be_msg = self._maybe_move_to_breakeven(t, price)
            if be_msg:
                msgs.append(be_msg)
                t = self.j.current_trade(self.v)  # re-fetch updated SL

            # ── Trend reversal early exit ─────────────────────────────────────
            # When both Supertrend + EMA have fully flipped against the trade,
            # close immediately rather than waiting for the full stop loss to be hit.
            sig_now = analyze_mtf(df, self.p)
            self.last_signal = sig_now
            if self._trend_reversed(t, sig_now):
                try:
                    msgs.append(self._close(t, price, "TREND_FLIP", now))
                except OrderRejected as e:
                    msgs.append(f"trend-flip close failed, will retry: {e}")
                self.last_bar = df.index[-1]
                return msgs

            # ── Normal SL / TP / TIME exit ────────────────────────────────────
            level, reason = self._exit_level(t, hi, lo, price, now)
            if level is None:
                return msgs
            try:
                msgs.append(self._close(t, level, reason, now))
            except OrderRejected as e:
                return msgs + [f"close failed, will retry: {e}"]
            self.last_bar = df.index[-1]

        # ── Look for a new entry ──────────────────────────────────────────────
        bar = df.index[-1]
        sig = analyze_mtf(df, self.p)
        self.last_signal = sig
        if sig.side == "NEUTRAL" or bar == self.last_bar:
            return msgs
        self.last_bar = bar  # at most one entry decision per closed candle
        eq = self.b.equity()
        ok, why = self.risk.allowed(eq, self.day_start, now, self.last_loss_time)
        if not ok:
            msgs.append(f"{sig.side} signal skipped: {why}")
            return msgs

        dist = sig.atr * self.p["atr_stop_mult"]
        side = "buy" if sig.side == "BUY" else "sell"
        ok, why = self.guard.entry_check(self, sig, dist, now)
        if not ok:
            msgs.append(f"{sig.side} signal skipped by the guard: {why}")
            return msgs
        qty, unit_loss = self.risk.size(eq, sig.price, dist, self.p, self.b, self.symbol, side)
        qty = self.b.normalize_qty(self.symbol, qty)
        if qty <= 0:
            msgs.append(f"{sig.side} signal skipped: position size rounds to zero")
            return msgs

        # ── AI brain: second opinion. shadow = record only, active = may skip or shrink (never enlarge) ──
        brain_log_id = None
        if self.brain is not None and self.brain.enabled:
            vd = self.brain.review(build_context(self, sig, side, dist, qty, unit_loss, eq, df, now), now, self.v)
            brain_log_id = vd.log_id
            if vd.applied_skip:
                msgs.append(f"{sig.side} skipped by the AI brain: {vd.apply_reason}")
                return msgs
            if vd.size_mult < 1.0:
                qty = self.b.normalize_qty(self.symbol, qty * vd.size_mult)
                if qty <= 0:
                    msgs.append(f"{sig.side} skipped: size after the AI brain's reduction rounds to zero")
                    return msgs
                msgs.append(f"AI brain reduced the size to {vd.size_mult:.2f}x")
        try:
            fill = self.b.market_order(self.symbol, side, qty)
        except OrderRejected as e:
            self.j.event("WARN", f"order rejected: {e}", now)
            msgs.append(f"{sig.side} order rejected: {e}")
            return msgs
        sign = 1 if side == "buy" else -1
        sl = fill.price - sign * dist
        tp = fill.price + sign * dist * self.p["rr"]
        tid = self.j.add_trade(self.v, sig.side, now, fill.price, fill.qty, sl, tp, fill.qty * unit_loss,
                               sig.confidence, {"adx": sig.adx, "rsi": sig.rsi, **sig.factors}, fill.fee, fill.ticket)
        if brain_log_id:
            self.j.attach_brain_trade(brain_log_id, tid)
        msgs.append(f"OPEN {sig.side} {fill.qty:g} @ {fill.price:.5g}  SL {sl:.5g}  TP {tp:.5g}  conf {sig.confidence}%")
        try:
            self.b.protect(self.symbol, fill.ticket, sl, tp)
        except OrderRejected as e:
            msgs.append(f"could not place the stop loss ({e}); closing the position for safety")
            try:
                msgs.append(self._close(self.j.current_trade(self.v), fill.price, "PROTECT_FAIL", now))
            except OrderRejected as e2:
                self.j.event("CRASH", f"position open WITHOUT a stop loss and could not be closed: {e2}", now)
        return msgs

    # ------------------------------------------------------------------ helpers
    def _roll_day(self, now, eq):
        d = now.date()
        if d != self.day:
            self.day, self.day_start = d, eq

    # ── Break-even trailing ───────────────────────────────────────────────────
    def _maybe_move_to_breakeven(self, t, price):
        """Move SL to entry once the trade profits by ``be_trigger_r`` × stop distance.

        ``be_trigger_r = 0`` disables this feature. The SL is only moved once
        (we detect it is already at BE by checking it is no longer below/above entry).
        Returns a log message string when the SL is moved, else ``None``.
        """
        be_r = self.p.get("be_trigger_r", 0.8)
        if not be_r:
            return None
        if not t["sl"]:
            return None
        sign = 1 if t["side"] == "BUY" else -1
        dist = abs(t["entry"] - t["sl"])
        if dist < 1e-10:
            return None
        profit_r = sign * (price - t["entry"]) / dist
        # Guard: only move once – skip when SL has already reached or passed entry
        sl_below_entry = sign * (t["sl"] - t["entry"]) < -1e-8
        if profit_r >= be_r and sl_below_entry:
            new_sl = self.guard.be_level(self, t)   # entry + a cushion for the spread
            self.j.update_trade_sl(t["id"], new_sl)
            try:
                self.b.protect(self.symbol, t["ticket"], new_sl, t["tp"])
            except OrderRejected as e:
                return f"BE: broker SL move failed ({e}); journal updated to {new_sl:.5g}"
            return f"BE: SL moved to entry {new_sl:.5g} (trade at +{profit_r:.2f}R)"
        return None

    # ── Trend reversal detection ──────────────────────────────────────────────
    def _trend_reversed(self, t, sig):
        """Return True when the signal has fully flipped to the *opposite* side.

        We require the overall ``sig.side`` (which already requires Supertrend + EMA
        + confidence threshold) to be opposite to the trade direction. This avoids
        closing on short-lived noise because ``analyze_mtf`` already demands a
        minimum confidence. Set ``"trend_exit": false`` in strategy params to disable.
        """
        if not self.p.get("trend_exit", True):
            return False
        if t["side"] == "BUY" and sig.side == "SELL":
            return True
        if t["side"] == "SELL" and sig.side == "BUY":
            return True
        return False

    def _exit_level(self, t, hi, lo, price, now):
        if not self.b.server_side_stops:   # when the broker holds the stops, we only manage the time exit
            if t["side"] == "BUY":
                if lo <= t["sl"]:
                    return t["sl"], "SL"
                if hi >= t["tp"]:
                    return t["tp"], "TP"
            else:
                if hi >= t["sl"]:
                    return t["sl"], "SL"  # stop wins if both are touched in one candle (conservative)
                if lo <= t["tp"]:
                    return t["tp"], "TP"
        held = (now - pd.Timestamp(t["entry_time"])).total_seconds() / 60 / self.tfmin
        if held >= self.p["max_bars_held"]:
            return price, "TIME"
        return None, None

    def _record_close(self, t, price, reason, now, pnl, fees):
        bars = int((now - pd.Timestamp(t["entry_time"])).total_seconds() / 60 / self.tfmin)
        self.j.close_trade(t["id"], now, price, pnl, fees, reason, bars)
        if self.brain is not None and self.brain.enabled:     # the AI writes a short post-mortem in the background
            self.brain.postmortem_async({**t, "exit": price, "exit_reason": reason, "pnl": pnl, "bars_held": bars}, self.v, now)
        if pnl < 0:
            self.last_loss_time = now
        return f"CLOSE {t['side']} @ {price:.5g} ({reason})  pnl {pnl:+.2f}"

    def _close(self, t, level, reason, now):
        self.b.set_price(level)
        side = "sell" if t["side"] == "BUY" else "buy"
        kw = {"ticket": t.get("ticket")} if getattr(self.b, "server_side_stops", False) else {}
        fill = self.b.market_order(self.symbol, side, t["qty"], reduce_only=True, **kw)
        info = self.b.closed_info(self.symbol, t)   # broker-reported result when available (MT5)
        if info:
            return self._record_close(t, info["price"], reason, now, info["pnl"], info["fees"])
        sign = 1 if t["side"] == "BUY" else -1
        fees = (t["fees"] or 0) + fill.fee
        pnl = (fill.price - t["entry"]) * fill.qty * sign - fees
        return self._record_close(t, fill.price, reason, now, pnl, fees)

    def close_all(self, reason, now):
        t = self.j.current_trade(self.v)
        if t is None:
            return None
        return self._close(t, self.b.price(self.symbol) or t["entry"], reason, now)

    def drawdown_pct(self):
        eq = self.b.equity()
        return max(0.0, (self.peak - eq) / self.peak * 100) if self.peak else 0.0
