"""Loss control that stands in front of every entry. Plain rules, no AI, no guessing.

Why each rule exists (all values can be changed in your strategy params; defaults shown):

  max_spread_pips        2.0  Skip when the spread is wider than this. A wide spread is a loss you pay before the trade starts.
  min_stop_spread_ratio  8    The stop must be at least 8x the spread. With an 8 pip stop and a 1.6 pip spread you start 20% of
                              the way to your stop, which turns a fair strategy into a losing one.
  min_stop_pips          0    Optional hard minimum stop size (0 = off).
  max_consec_losses      3    After this many losing trades in a row ...
  loss_pause_minutes     120  ... stop opening trades for this long. Losing streaks usually mean the market is not behaving
                              the way the strategy expects, so it waits instead of feeding it more trades.
  max_trades_per_day     12   Hard cap on trades per UTC day (0 = off).
  be_buffer_spread_mult  1.5  The break-even stop sits this many spreads beyond entry. A stop placed exactly at entry still
                              loses the spread when it triggers, so it is not really break-even.

Also here: a "one copy only" lock. Two Sailar-x programs on the same account fight each other (double entries, one closing
the other's trades, cooldowns that do not work because each copy has its own memory).
"""
import atexit
import json
import os
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd

FIAT = {"USD", "EUR", "GBP", "JPY", "CHF", "AUD", "NZD", "CAD", "SEK", "NOK", "DKK", "PLN", "CZK", "HUF", "TRY",
        "ZAR", "MXN", "SGD", "HKD", "CNH", "CNY"}


def is_forex(symbol):
    """EURUSD, USDJPY, EUR/USD... True. BTCUSD, ETH/USDT, XAUUSD (gold) ... False: pips mean nothing there."""
    s = "".join(ch for ch in symbol.upper() if ch.isalpha())[:6]
    return len(s) == 6 and s[:3] in FIAT and s[3:] in FIAT


DEFAULTS = {
    "max_spread_pct": 0.15,       # crypto / gold / indices: skip when the spread is more than this % of the price
    "max_spread_pips": 2.0,       # forex only
    "min_stop_spread_ratio": 8,
    "min_stop_pips": 0,
    "max_consec_losses": 3,
    "loss_pause_minutes": 120,
    "max_trades_per_day": 12,
    "be_buffer_spread_mult": 1.5,
}


class Guard:
    def __init__(self, cfg, params):
        self.cfg, self.p = cfg, params

    def _g(self, key):
        return self.p.get(key, DEFAULTS[key])

    def entry_check(self, eng, sig, dist, now):
        """(True, "") to allow the entry, or (False, plain-language reason)."""
        blocked = getattr(eng, "_entry_blocked", None)
        if blocked:
            return False, blocked
        n = int(self._g("max_consec_losses"))
        if n > 0:
            last = eng.j.trades(eng.v, "closed")[-n:]
            if len(last) == n and all(t["pnl"] < 0 for t in last):
                pause = self._g("loss_pause_minutes")
                mins = (now - pd.Timestamp(last[-1]["exit_time"])).total_seconds() / 60
                if mins < pause:
                    return False, f"{n} losing trades in a row: paused for {int(pause - mins)} more minutes"
        cap = int(self._g("max_trades_per_day"))
        if cap > 0:
            today = str(now.date())
            if sum(1 for t in eng.j.trades(eng.v) if str(t["entry_time"])[:10] == today) >= cap:
                return False, f"daily limit of {cap} trades reached"
        try:
            sp, pip = eng.b.spread_pips(eng.symbol), eng.b.pip_size(eng.symbol)
        except Exception:
            sp = pip = None
        if sp is not None and pip:
            if is_forex(eng.symbol):
                mx = self._g("max_spread_pips")
                if mx and sp > mx:
                    return False, f"spread {sp:.1f} pips is above the {mx} pip limit"
            else:                                           # crypto / gold: judge the spread as a % of the price, never in "pips"
                price = getattr(sig, "price", None)
                mxp = self._g("max_spread_pct")
                if mxp and price and sp * pip / price * 100 > mxp:
                    return False, f"spread is {sp * pip / price * 100:.3f}% of the price (limit {mxp}%)"
            stop_pips, ratio = dist / pip, self._g("min_stop_spread_ratio")
            if ratio and sp > 0 and stop_pips < ratio * sp:
                return False, f"stop of {stop_pips:.1f} pips is too tight for a {sp:.1f} pip spread (needs {ratio * sp:.1f}+)"
            ms = self._g("min_stop_pips")
            if ms and stop_pips < ms:
                return False, f"stop of {stop_pips:.1f} pips is below the {ms} pip minimum"
        return True, ""

    def one_position(self, eng, now, msgs):
        """ONE trade at a time. The trade Sailar-x is managing stays. Anything else on this symbol is dealt with:

          - an extra position tagged as Sailar-x's own  -> closed at once ("the second one goes")
          - a position Sailar-x did NOT open (by hand, another program) -> `foreign_positions`:
                "warn" (default: logs a warning, never touches it) | "block" (no new trades while it exists) | "close"
        Switch the whole rule off with one_position_rule = false.
        """
        eng._entry_blocked = None
        if not self.p.get("one_position_rule", True) or not hasattr(eng.b, "list_positions"):
            return
        try:
            poss = eng.b.list_positions(eng.symbol)
        except Exception:
            return
        if poss is None:                                   # the broker could not answer: do nothing rather than guess
            return
        t = eng.j.current_trade(eng.v)
        keep = str(t["ticket"]) if t and t.get("ticket") else None
        ours = [q for q in poss if q["ours"]]
        if t and keep not in [str(q["ticket"]) for q in ours]:
            twin = [q for q in ours if abs(q["volume"] - t["qty"]) < 1e-6]
            if len(twin) == 1:                             # the journal had the wrong ticket number: repair it, do NOT close the real trade
                keep = str(twin[0]["ticket"])
                eng.j.db.execute("UPDATE trades SET ticket=? WHERE id=?", (keep, t["id"]))
                eng.j.db.commit()
                eng.j.event("WARN", f"repaired the position number of the open trade ({t['ticket']} -> {keep})", now)
        if not hasattr(eng, "_warned"):
            eng._warned = set()
        policy = self.p.get("foreign_positions", "warn")
        for q in poss:
            if str(q["ticket"]) == keep:
                continue
            since = datetime.fromtimestamp(q["time"], timezone.utc).strftime("%b %d %H:%M") if q.get("time") else "?"
            desc = (f"{q['side']} {q['volume']:g} lots #{q['ticket']} opened {since} UTC, "
                    f"{'NO stop loss' if not q['sl'] else 'stop ' + format(q['sl'], '.5g')}, floating {q['profit']:+.2f}")
            action = "close" if q["ours"] else policy
            if action == "close":
                try:
                    r = eng.b.close_position(eng.symbol, q["ticket"])
                    what = "EXTRA position of Sailar-x" if q["ours"] else "position NOT opened by Sailar-x"
                    eng.j.event("WARN", f"closed {what}: {desc} -> result {r['pnl']:+.2f}", now)
                    msgs.append(f"closed an extra position (#{q['ticket']}, {q['volume']:g} lots, {r['pnl']:+.2f}): only one trade at a time")
                except Exception as e:
                    eng._entry_blocked = f"cannot close the extra position #{q['ticket']} ({e}); no new trades until it is gone"
                    if q["ticket"] not in eng._warned:
                        eng._warned.add(q["ticket"])
                        eng.j.event("WARN", eng._entry_blocked, now)
            elif action == "block":
                eng._entry_blocked = (f"another position is open on {eng.symbol} ({desc}); close it, "
                                      "or set foreign_positions to 'warn'")
            elif q["ticket"] not in eng._warned:
                eng._warned.add(q["ticket"])
                eng.j.event("WARN", f"position NOT opened by Sailar-x is open on {eng.symbol}: {desc}. Sailar-x does not touch it. "
                                    "Close it by hand, or set foreign_positions to 'close'.", now)

    def watch(self, eng, t, df, price, now, msgs):
        """Lets the AI look at an OPEN trade once per candle. It may only PROTECT: tighten the stop or exit. True = trade closed."""
        brain = getattr(eng, "brain", None)
        if brain is None or not brain.enabled:
            return False
        mode = brain.c.get("watch", "shadow")
        if mode not in ("shadow", "active"):
            return False
        from .brain.watch import Watcher
        w = getattr(eng, "_watcher", None)
        if w is None:
            w = eng._watcher = Watcher(brain, self)
        return w.run(eng, t, df, price, now, msgs, mode)

    def be_level(self, eng, t):
        """Where to put the break-even stop: entry, plus a cushion that covers the spread."""
        sign = 1 if t["side"] == "BUY" else -1
        buf = 0.0
        try:
            sp, pip = eng.b.spread_pips(eng.symbol), eng.b.pip_size(eng.symbol)
            if sp is not None and pip:
                buf = self._g("be_buffer_spread_mult") * sp * pip
        except Exception:
            pass
        return t["entry"] + sign * buf


# ---------------------------------------------------------------------------------- one copy only
def _alive(pid):
    if pid == os.getpid():
        return False
    if os.name == "nt":      # never os.kill() on Windows: it would TERMINATE the process
        try:
            out = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"], capture_output=True, text=True, timeout=10).stdout.lower()
            return str(pid) in out and "python" in out
        except Exception:
            return False
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        return False
    except PermissionError:
        return True
    return True


def other_sailar_processes():
    """Other Sailar-x 'run' programs on this computer, from ANY folder. [(pid, command line)]"""
    found = []
    try:
        if os.name == "nt":
            cmd = ["powershell", "-NoProfile", "-Command",
                   "Get-CimInstance Win32_Process -Filter \"name like 'python%'\" | Select-Object ProcessId,CommandLine | ConvertTo-Json"]
            raw = subprocess.run(cmd, capture_output=True, text=True, timeout=20).stdout.strip()
            rows = json.loads(raw) if raw else []
            rows = [rows] if isinstance(rows, dict) else rows
            pairs = [(r["ProcessId"], r.get("CommandLine") or "") for r in rows]
        else:
            out = subprocess.run(["ps", "-eo", "pid,args"], capture_output=True, text=True, timeout=10).stdout.splitlines()[1:]
            pairs = [(int(l.split(None, 1)[0]), l.split(None, 1)[1]) for l in out if l.strip()]
    except Exception:
        return []
    for pid, line in pairs:
        parts = line.replace('"', " ").split()
        idx = [i for i, p in enumerate(parts) if p.endswith("run.py")]
        if pid != os.getpid() and idx and "run" in parts[idx[0] + 1:]:      # "run.py ... run": the trading program, not the dashboard/check
            found.append((pid, line.strip()))
    return found


def _config_name(parts):
    """The --config file name used on a command line (just the file name), or None."""
    for i, p in enumerate(parts):
        if p == "--config" and i + 1 < len(parts):
            return os.path.basename(parts[i + 1].replace("\\", "/")).lower()
        if p.startswith("--config="):
            return os.path.basename(p.split("=", 1)[1].replace("\\", "/")).lower()
    return None


def acquire_lock(path):
    """Refuse to start if the SAME Sailar-x (same config) is already running. A forex copy and a crypto copy
    (different config files, different folders) may run side by side."""
    path = Path(path)
    if path.exists():
        try:
            pid = int(path.read_text().split()[0])
        except Exception:
            pid = 0
        if pid and _alive(pid):
            raise SystemExit(f"Sailar-x is ALREADY RUNNING in this folder (process {pid}). Two copies in one folder share one "
                             f"journal and would trade against each other. Close the other window first. "
                             f"(If you are sure nothing is running, delete {path}.)")
    mine = _config_name([a for a in __import__("sys").argv])
    others = [(pid, line) for pid, line in other_sailar_processes() if _config_name(line.replace('"', " ").split()) == mine]
    if others:
        lines = "\n".join(f"   process {pid}: {line[:150]}" for pid, line in others)
        raise SystemExit("Another Sailar-x with the SAME config is already running on this computer:\n" + lines +
                         "\nTwo copies on one account and one market fight each other. Close it (Task Manager > python.exe) and start again.\n"
                         "(To run a second market, use a separate folder and a different --config file.)")
    path.write_text(f"{os.getpid()} {int(time.time())}")
    atexit.register(_release, path)


def _release(path):
    try:
        if Path(path).exists() and Path(path).read_text().split()[0] == str(os.getpid()):
            Path(path).unlink()
    except Exception:
        pass
