"""ATM unified CLI entry point."""
from __future__ import annotations

import argparse
import asyncio
import contextlib
import os
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, tzinfo
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Protocol, cast

from atm.config import Config  # stdlib-only (tomllib); safe at module level
from atm.notifier import Alert
from atm.state_machine import State, StateMachine, Transition

if TYPE_CHECKING:
    from atm.state_machine import DotColor


# Snapshot closure: called by _handle_tick when an alert is about to fire.
# Takes (kind, label); returns path to annotated screenshot or None (gated off,
# cv2 missing, or write failure — in which case the alert still sends text-only).
Snapshot = Callable[[str, str], "Path | None"]


class _NotifierLike(Protocol):
    def send(self, alert: Alert) -> None: ...


class _AuditLike(Protocol):
    def log(self, event: dict) -> None: ...

# Module-level reference — set lazily by _cmd_dryrun; tests may monkeypatch it.
dryrun = None


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main(argv=None) -> None:
    parser = argparse.ArgumentParser(
        prog="atm",
        description="ATM trading monitor",
    )
    sub = parser.add_subparsers(dest="command", metavar="COMMAND")
    sub.required = True

    # calibrate
    p_cal = sub.add_parser("calibrate", help="Launch guided calibration wizard (Tk)")
    p_cal.add_argument(
        "--screenshot", type=Path, default=None, metavar="PATH",
        help="Use a saved screenshot instead of live window capture (dev/testing)",
    )
    p_cal.add_argument(
        "--delay", type=float, default=3.0, metavar="SEC",
        help="Countdown before desktop snapshot (bring TradeStation to front). Default 3.",
    )

    # label
    p_label = sub.add_parser("label", help="Label dot-colour samples (Tk)")
    p_label.add_argument("samples_dir", type=Path, metavar="SAMPLES_DIR")

    # dryrun
    p_dry = sub.add_parser("dryrun", help="Replay labeled samples; exit 0 on pass")
    p_dry.add_argument("samples_dir", type=Path, metavar="SAMPLES_DIR")

    # run
    p_run = sub.add_parser("run", help="Start live monitoring loop")
    p_run.add_argument(
        "--duration", type=float, metavar="HOURS", default=None,
        help="Run for HOURS hours then exit (omit for indefinite)",
    )
    p_run.add_argument(
        "--capture-stub", action="store_true",
        help="Use stub capture (reads PNGs from samples/); useful for smoke-testing on Linux",
    )
    p_run.add_argument(
        "--startup-delay", type=float, default=5.0, metavar="SEC",
        help="Seconds to wait before the loop starts (bring TradeStation to front). Default 5.",
    )
    p_run.add_argument(
        "--start-at", metavar="HH:MM", default=None,
        help="Sleep until local wall-clock time HH:MM before starting. If the "
             "time is in the past today, waits until tomorrow.",
    )
    p_run.add_argument(
        "--stop-at", metavar="HH:MM", default=None,
        help="Stop at local HH:MM (overrides --duration). If the time is in "
             "the past when the loop starts, rolls over to tomorrow.",
    )
    p_run.add_argument(
        "--tz", metavar="ZONE", default=None,
        help="Override operating_hours.timezone (e.g. America/New_York).",
    )
    p_run.add_argument(
        "--weekdays", metavar="DAYS", default=None,
        help="Override operating_hours.weekdays. Accepts comma list "
             "(MON,TUE) or range (MON-FRI).",
    )
    p_run.add_argument(
        "--oh-start", metavar="HH:MM", default=None,
        help="Override operating_hours.start_hhmm (exchange-local).",
    )
    p_run.add_argument(
        "--oh-stop", metavar="HH:MM", default=None,
        help="Override operating_hours.stop_hhmm (exchange-local).",
    )

    # journal
    p_journal = sub.add_parser("journal", help="Add a trade journal entry interactively")
    p_journal.add_argument(
        "--file", type=Path, default=Path("trades.jsonl"),
        metavar="PATH", help="Journal JSONL file (default: trades.jsonl)",
    )

    # debug
    p_debug = sub.add_parser("debug", help="Capture one frame, run detector, annotate + print result")
    p_debug.add_argument(
        "--delay", type=float, default=3.0, metavar="SEC",
        help="Seconds to wait before capture (bring TradeStation to front first). Default 3.",
    )
    p_debug.add_argument(
        "--canary", action="store_true",
        help="Diagnostic-only: capture canary ROI, compute phash distance vs baseline, save crop.",
    )

    # report
    p_report = sub.add_parser("report", help="Print weekly performance report")
    p_report.add_argument(
        "--week", default=None, metavar="YYYY-WW",
        help="ISO week to report (default: current week)",
    )
    p_report.add_argument(
        "--file", type=Path, default=Path("trades.jsonl"),
        metavar="PATH", help="Journal JSONL file (default: trades.jsonl)",
    )

    # validate-calibration
    p_valid = sub.add_parser(
        "validate-calibration",
        help="Offline: run Detector on labeled frames and report PASS/FAIL",
    )
    p_valid.add_argument(
        "label_file", type=Path, metavar="LABEL_FILE",
        help="JSON array with [{path, expected, note?}, ...] entries",
    )

    args = parser.parse_args(argv)

    _dispatch = {
        "calibrate": _cmd_calibrate,
        "label": _cmd_label,
        "dryrun": _cmd_dryrun,
        "run": _cmd_run,
        "debug": _cmd_debug,
        "journal": _cmd_journal,
        "report": _cmd_report,
        "validate-calibration": _cmd_validate_calibration,
    }
    _dispatch[args.command](args)


# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------

def _cmd_calibrate(args) -> None:
    try:
        from atm.calibrate import run_calibration
    except ImportError as exc:
        sys.exit(f"calibrate module not available: {exc}")
    run_calibration(Path("configs"), screenshot=args.screenshot, capture_delay=args.delay)


def _cmd_label(args) -> None:
    try:
        from atm.labeler import run_labeler
    except ImportError as exc:
        sys.exit(f"labeler module not available: {exc}")
    samples_dir = Path(args.samples_dir)
    run_labeler(samples_dir, samples_dir / "labels.json")


def _cmd_dryrun(args) -> None:
    global dryrun
    if dryrun is None:
        try:
            import atm.dryrun as _mod
        except ImportError as exc:
            sys.exit(f"dryrun module not available: {exc}")
        dryrun = _mod

    cfg = Config.load_current(Path("configs"))
    samples_dir = Path(args.samples_dir)
    result = dryrun.dryrun(samples_dir, samples_dir / "labels.json", cfg)
    dryrun.print_report(result)
    sys.exit(0 if result.acceptance_pass else 1)


def _focus_window_by_title(needle: str) -> str | None:
    """Adu în față cea mai mare fereastră al cărei titlu conține `needle`.

    Returnează titlul găsit sau None (niciun match, Win32 a refuzat activate,
    sau `pygetwindow` lipsește — caz normal pe Linux/WSL unde rulează doar testele).

    pygetwindow.activate() folosește SetForegroundWindow, care Windows îl
    refuză silent când procesul apelant nu e cel care deține focus-ul
    (protecția anti focus-stealing introdusă în Win2000). Trucul standard:
    trimiți un ALT press/release ca să resetezi lock-ul, apoi restore +
    SetForegroundWindow prin ctypes pe hwnd direct.

    Forțează maximize dacă fereastra nu e deja zoomed — `chart_window_region`
    din calibrare presupune fereastra fullscreen, orice altă dimensiune
    produce canary drift imediat.
    """
    try:
        import pygetwindow as gw  # type: ignore[import-untyped]
    except ImportError:
        return None
    needle_lc = needle.lower()
    matches = [w for w in gw.getAllWindows() if w.title and needle_lc in w.title.lower()]
    matches.sort(key=lambda w: (w.width or 0) * (w.height or 0), reverse=True)
    if not matches:
        return None
    win = matches[0]
    hwnd = getattr(win, "_hWnd", None)
    if hwnd is None:
        try:
            win.activate()
            return win.title
        except Exception:
            return None
    try:
        import ctypes
        user32 = ctypes.windll.user32
        # VK_MENU=0x12 (ALT), KEYEVENTF_KEYUP=0x02 — unlocks focus protection.
        user32.keybd_event(0x12, 0, 0, 0)
        user32.keybd_event(0x12, 0, 0x02, 0)
        # Dacă e minimizată o scoatem; apoi forțăm maximize necondiționat.
        # SW_RESTORE pe o fereastră maximizată o de-maximizează — niciodată nu-l
        # folosim aici (chart_window_region presupune fullscreen).
        if user32.IsIconic(hwnd):
            user32.ShowWindow(hwnd, 9)  # SW_RESTORE (doar unminimize)
        user32.ShowWindow(hwnd, 3)  # SW_SHOWMAXIMIZED
        user32.BringWindowToTop(hwnd)
        user32.SetForegroundWindow(hwnd)
        return win.title
    except Exception:
        return None


def _cmd_run(args) -> None:
    cfg = Config.load_current(Path("configs"))
    cfg = _apply_operating_hours_cli_overrides(cfg, args)
    capture_stub = args.capture_stub or bool(os.environ.get("ATM_STUB_CAPTURE"))

    # --start-at HH:MM: sleep until the next occurrence of that local wall-clock time
    if args.start_at:
        from datetime import datetime, timedelta
        hh, mm = (int(p) for p in args.start_at.split(":"))
        now = datetime.now()
        target = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
        if target <= now:
            target += timedelta(days=1)
        wait = (target - now).total_seconds()
        print(f"Waiting until {target:%Y-%m-%d %H:%M} (in {wait/60:.1f} min) before starting...", flush=True)
        time.sleep(wait)

    # --stop-at HH:MM: compute duration from NOW to the next HH:MM; overrides --duration
    duration_s = args.duration * 3600 if args.duration is not None else None
    if args.stop_at:
        from datetime import datetime, timedelta
        hh, mm = (int(p) for p in args.stop_at.split(":"))
        now = datetime.now()
        stop = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
        if stop <= now:
            stop += timedelta(days=1)
        duration_s = (stop - now).total_seconds()
        print(f"Will stop at {stop:%Y-%m-%d %H:%M} (duration {duration_s/3600:.2f}h)", flush=True)

    # Auto-focus TradeStation by title substring so user doesn't need to alt-tab
    if not capture_stub and cfg.window_title:
        title = _focus_window_by_title(cfg.window_title)
        if title:
            print(f"Focused window: {title!r}", flush=True)
        else:
            print(f"WARN: no window contains {cfg.window_title!r} — bring TradeStation to front manually", flush=True)

    delay = getattr(args, "startup_delay", 0.0)
    if delay > 0 and not capture_stub:
        print(f"Starting in {delay:.0f}s (TradeStation should be in front)...", flush=True)
        for i in range(int(delay), 0, -1):
            print(f"  {i}...", flush=True)
            time.sleep(1.0)
        print("RUN", flush=True)

    run_live(cfg, duration_s=duration_s, capture_stub=capture_stub)


_WEEKDAY_ORDER = ("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN")


def _parse_weekdays_arg(raw: str) -> tuple[str, ...]:
    """Accept 'MON,TUE,WED' or 'MON-FRI'. Case-insensitive."""
    txt = raw.strip().upper()
    if "-" in txt and "," not in txt:
        a, b = (p.strip() for p in txt.split("-", 1))
        if a not in _WEEKDAY_ORDER or b not in _WEEKDAY_ORDER:
            raise ValueError(f"unknown weekday(s) in range {raw!r}")
        i, j = _WEEKDAY_ORDER.index(a), _WEEKDAY_ORDER.index(b)
        if i > j:
            raise ValueError(f"weekday range reversed: {raw!r}")
        return tuple(_WEEKDAY_ORDER[i : j + 1])
    days = tuple(d.strip() for d in txt.split(",") if d.strip())
    for d in days:
        if d not in _WEEKDAY_ORDER:
            raise ValueError(f"unknown weekday {d!r} (valid: {_WEEKDAY_ORDER})")
    return days


def _apply_operating_hours_cli_overrides(cfg, args):
    """Return cfg (possibly new) with operating_hours overridden by CLI flags.

    Config is a frozen dataclass, but operating_hours is non-frozen by design
    so we can tweak it in-place and recompute the tz cache. CLI flags implicitly
    enable operating_hours even if the TOML had it disabled.
    """
    import dataclasses as _dc
    from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

    oh = cfg.operating_hours
    any_override = any(
        getattr(args, k, None)
        for k in ("tz", "weekdays", "oh_start", "oh_stop")
    )
    if not any_override:
        return cfg

    new_tz = args.tz if args.tz else oh.timezone
    try:
        tz_cache = ZoneInfo(new_tz)
    except ZoneInfoNotFoundError as exc:
        sys.exit(f"--tz {new_tz!r} invalid: {exc}")

    new_weekdays = _parse_weekdays_arg(args.weekdays) if args.weekdays else oh.weekdays
    new_start = args.oh_start if args.oh_start else oh.start_hhmm
    new_stop = args.oh_stop if args.oh_stop else oh.stop_hhmm
    oh.enabled = True
    oh.timezone = new_tz
    oh.weekdays = new_weekdays
    oh.start_hhmm = new_start
    oh.stop_hhmm = new_stop
    oh._tz_cache = tz_cache
    # Config is frozen but operating_hours is a mutable field object —
    # mutating it in place is sufficient; no dataclasses.replace needed.
    _ = _dc  # keep import for future use
    return cfg


def _cmd_journal(args) -> None:
    try:
        from atm.journal import Journal, prompt_entry
    except ImportError as exc:
        sys.exit(f"journal module not available: {exc}")
    j = Journal(args.file)
    entry = prompt_entry()
    j.add(entry)


def _cmd_debug(args) -> None:
    """One-shot capture + detect. Saves annotated PNG. Prints classification."""
    from datetime import datetime
    try:
        from atm.detector import Detector
        from atm.vision import crop_roi
    except ImportError as exc:
        sys.exit(f"debug module deps missing: {exc}")

    cfg = Config.load_current(Path("configs"))
    capture = _build_capture(cfg)

    if cfg.window_title:
        title = _focus_window_by_title(cfg.window_title)
        if title:
            print(f"Focused window: {title!r}", flush=True)
        else:
            print(f"WARN: no window contains {cfg.window_title!r} — bring TradeStation to front manually", flush=True)

    delay = getattr(args, "delay", 0.0)
    if delay > 0:
        print(f"Capturing in {delay:.0f}s...", flush=True)
        for i in range(int(delay), 0, -1):
            print(f"  {i}...", flush=True)
            time.sleep(1.0)
        print("CAPTURE", flush=True)

    frame = capture()
    if frame is None:
        sys.exit("capture returned None (window not found or region invalid)")

    out_dir = Path("logs")
    out_dir.mkdir(exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")

    import cv2  # type: ignore[import-untyped]
    full_path = out_dir / f"debug_full_{ts}.png"
    cv2.imwrite(str(full_path), frame)
    print(f"captured frame: {frame.shape} → {full_path}")

    if getattr(args, "canary", False):
        from atm.vision import phash, hamming_hex
        try:
            canary_crop = crop_roi(frame, cfg.canary.roi)
        except Exception as exc:
            sys.exit(f"canary ROI crop failed: {exc}")
        canary_path = out_dir / f"debug_canary_{ts}.png"
        cv2.imwrite(str(canary_path), canary_crop)
        live_hash = phash(canary_crop)
        distance = hamming_hex(live_hash, cfg.canary.baseline_phash)
        threshold = cfg.canary.drift_threshold
        verdict = "DRIFT" if distance > threshold else "ok"
        roi = cfg.canary.roi
        print("---")
        print(f"canary ROI:   x={roi.x} y={roi.y} w={roi.w} h={roi.h}")
        print(f"crop:         {canary_crop.shape} → {canary_path}")
        print(f"baseline:     {cfg.canary.baseline_phash}")
        print(f"live phash:   {live_hash}")
        print(f"distance:     {distance}/{threshold}  → {verdict}")
        return

    # crop dot_roi + run detector
    try:
        dot_crop = crop_roi(frame, cfg.dot_roi)
        dot_path = out_dir / f"debug_dot_roi_{ts}.png"
        cv2.imwrite(str(dot_path), dot_crop)
        print(f"dot_roi crop: {dot_crop.shape} → {dot_path}")
    except Exception as exc:
        sys.exit(f"dot_roi crop failed: {exc}")

    det = Detector(cfg, capture)
    res = det.step(0.0)
    print("---")
    print(f"window_found: {res.window_found}")
    print(f"dot_found:    {res.dot_found}")
    if res.match is not None:
        print(f"rgb:          {res.rgb}")
        print(f"classified:   {res.match.name}  distance={res.match.distance:.1f}  confidence={res.match.confidence:.2f}")
    else:
        print("no match (dot not found or UNKNOWN classification)")
    print(f"accepted:     {res.accepted}  color={res.color}")

    # Annotate: draw dot_roi rect + mark detected dot
    annotated = frame.copy()
    x, y, w, h = cfg.dot_roi.x, cfg.dot_roi.y, cfg.dot_roi.w, cfg.dot_roi.h
    cv2.rectangle(annotated, (x, y), (x + w, y + h), (0, 255, 255), 2)
    if res.dot_found and res.match is not None:
        # re-find dot to know exact x,y for annotation
        from atm.vision import find_rightmost_dot
        bg_rgb = (18, 18, 18)
        if "background" in cfg.colors:
            bg_rgb = cfg.colors["background"].rgb
        dot_crop = crop_roi(frame, cfg.dot_roi)
        pos = find_rightmost_dot(dot_crop, bg_rgb)
        if pos is not None:
            dx, dy = pos
            cx, cy = x + dx, y + dy
            cv2.circle(annotated, (cx, cy), 12, (0, 0, 255), 3)
    annotated_path = out_dir / f"debug_annotated_{ts}.png"
    cv2.imwrite(str(annotated_path), annotated)
    print(f"annotated:    {annotated_path}")


def _cmd_report(args) -> None:
    try:
        from atm.journal import Journal
        from atm.report import weekly_report, iso_week
    except ImportError as exc:
        sys.exit(f"report/journal module not available: {exc}")

    j = Journal(args.file)
    entries = j.all()

    if args.week:
        week = args.week
    else:
        from datetime import datetime, timezone
        week = iso_week(datetime.now(timezone.utc).isoformat())

    rep = weekly_report(entries, week)
    print(
        f"Week {rep.week}: {rep.n_trades} trades, "
        f"{rep.n_wins}W/{rep.n_losses}L, "
        f"WR={rep.win_rate:.0%}, PnL={rep.pnl_r:.2f}R"
        + (f", slippage={rep.avg_slippage:.1f}s" if rep.avg_slippage is not None else "")
    )


def _cmd_validate_calibration(args) -> None:
    """Run offline calibration validation; exit 0 on 100% PASS, 1 otherwise."""
    try:
        from atm.validate import validate_calibration, ValidationError
    except ImportError as exc:
        sys.exit(f"validate module not available: {exc}")

    label_file = Path(args.label_file)
    try:
        cfg = Config.load_current(Path("configs"))
    except FileNotFoundError as exc:
        sys.exit(f"config not found: {exc}")

    try:
        config_name = ""
        cur_ptr = Path("configs") / "current.txt"
        if cur_ptr.exists():
            config_name = cur_ptr.read_text(encoding="utf-8").strip()
    except Exception:
        config_name = ""

    try:
        report = validate_calibration(label_file, cfg, config_name=config_name)
    except ValidationError as exc:
        print(f"error: {exc}", file=sys.stderr)
        sys.exit(2)

    print(report.render())
    sys.exit(0 if report.all_pass else 1)


# ---------------------------------------------------------------------------
# Live loop
# ---------------------------------------------------------------------------

def _fsm_step_label(fsm, now: float, recent_fire_s: float = 30.0) -> str:
    """Translate FSM into `step/total STATE` shorthand.

    M2D sequence to a fire: IDLE → ARMED_* (1/3) → PRIMED_* (2/3) → FIRE (3/3).
    FIRE nu e state persistent în FSM (întoarce în IDLE), așa că îl afișăm pe
    baza `_last_fire[direction]` când timestamp-ul e în ultimele `recent_fire_s`.
    """
    last_fires = getattr(fsm, "_last_fire", None)
    if isinstance(last_fires, dict) and last_fires:
        recent = [(ts, d) for d, ts in last_fires.items()
                  if isinstance(ts, (int, float)) and now - ts < recent_fire_s]
        if recent:
            _ts, direction = max(recent)
            return f"3/3 FIRE_{direction}"
    state_value = getattr(getattr(fsm, "state", None), "value", None)
    if isinstance(state_value, str):
        if state_value.startswith("ARMED_"):
            return f"1/3 {state_value}"
        if state_value.startswith("PRIMED_"):
            return f"2/3 {state_value}"
        return state_value
    return "—"


def _build_resume_info_text(was_drift: bool, skip_now: str | None) -> "tuple[str, str]":
    """Resume alert title/body for the three outcomes (market closed, drift, normal).

    Extracted from /resume dispatcher so we can reuse it around the inline screenshot.
    """
    if skip_now and skip_now.startswith("out_of_window"):
        return ("Pauză eliminată — piața e închisă acum",
                "Monitorizarea va porni la următoarea fereastră.")
    if was_drift:
        return ("Monitorizare reluată — drift-pause anulat",
                "Dacă driftul persistă, Canary va repauza la următoarea verificare.")
    return ("Monitorizare reluată", "")


def _draw_roi_cyan(annotated, roi) -> None:
    """Draw the given ROI as a cyan rectangle onto annotated. Shared by annotate helpers."""
    import cv2  # type: ignore[import-untyped]
    cv2.rectangle(annotated, (roi.x, roi.y), (roi.x + roi.w, roi.y + roi.h), (0, 255, 255), 2)


_BASELINE_PHASH_LINE = __import__("re").compile(
    r'^(\s*baseline_phash\s*=\s*")([0-9a-fA-F]+)(")[^\n]*$',
    __import__("re").MULTILINE,
)


def _rewrite_baseline_phash(config_path: Path, new_phash: str) -> str:
    """Replace baseline_phash in a TOML config file. Returns the old value.

    Uses regex line-match to preserve comments and surrounding formatting.
    Raises ValueError if the file has 0 or >1 baseline_phash lines.
    """
    text = config_path.read_text(encoding="utf-8")
    matches = list(_BASELINE_PHASH_LINE.finditer(text))
    if len(matches) != 1:
        raise ValueError(
            f"{config_path}: expected exactly 1 baseline_phash line, found {len(matches)}"
        )
    m = matches[0]
    old_phash = m.group(2)
    new_text = text[: m.start(2)] + new_phash + text[m.end(2) :]
    config_path.write_text(new_text, encoding="utf-8")
    return old_phash


def _auto_rebase_canary(
    ctx: "RunContext", frame, now: float
) -> "tuple[str, str] | None":
    """Compute new canary phash from frame, persist to TOML, mirror in-memory.

    Used by the tick loop when canary drift coincides with a strip-count change
    (legitimate layout transition like 2↔1 chart windows). Returns (old_phash,
    new_phash) on success. Returns None on TOML rewrite failure or when
    config_version is unknown — caller falls back to commit_pause().
    """
    from atm.vision import crop_roi, phash as _phash

    cfg = ctx.cfg
    config_name = getattr(cfg, "config_version", None) or "unknown"
    if config_name in ("", "unknown"):
        ctx.audit.log({
            "ts": now, "event": "auto_rebase_failed",
            "error": "config_version unknown",
        })
        return None
    config_path = Path("configs") / f"{config_name}.toml"
    try:
        new_phash = _phash(crop_roi(frame, cfg.canary.roi))
    except Exception as exc:
        ctx.audit.log({"ts": now, "event": "auto_rebase_failed",
                       "error": f"phash compute: {exc}"})
        return None
    try:
        old_phash = _rewrite_baseline_phash(config_path, new_phash)
    except Exception as exc:
        ctx.audit.log({"ts": now, "event": "auto_rebase_failed",
                       "error": str(exc), "config": str(config_path)})
        return None
    ctx.canary.rebase(new_phash)
    return old_phash, new_phash


def _save_rebase_frame(
    frame, cfg, fires_dir: Path, now: float, audit: "_AuditLike | None" = None,
) -> "Path | None":
    """Annotate frame with a red rectangle around canary.roi + save to fires_dir.

    Purpose: let the user eyeball exactly what pixels will be hashed before
    confirming /rebase. Fail-safe: any error → None (alert still sends text-only).
    """
    try:
        import cv2  # type: ignore[import-untyped]
    except ImportError as exc:
        if audit is not None:
            try:
                audit.log({"ts": now, "event": "snapshot_fail", "label": "rebase", "error": f"cv2 missing: {exc}"})
            except Exception:
                pass
        return None
    try:
        ts_str = datetime.fromtimestamp(now).strftime("%Y%m%d_%H%M%S")
        fpath = fires_dir / f"{ts_str}_rebase.png"
        annotated = frame.copy()
        r = cfg.canary.roi
        cv2.rectangle(annotated, (r.x, r.y), (r.x + r.w, r.y + r.h), (0, 0, 255), 2)
        cv2.imwrite(str(fpath), annotated)
        return fpath
    except Exception as exc:
        if audit is not None:
            try:
                audit.log({"ts": now, "event": "snapshot_fail", "label": "rebase", "error": str(exc)})
            except Exception:
                pass
        return None


def _save_inspect_frame(
    frame,
    cfg,
    fires_dir: Path,
    now: float,
    audit: "_AuditLike | None" = None,
    strips: "list | None" = None,
) -> "tuple[Path | None, list[dict]]":
    """Annotate frame with top-3 rightmost dots + classifications. Returns (path, detections).

    Per-strip detection when ``strips`` is provided (multi-chart split). Each strip
    runs its own find_top_dots; circles are offset by the strip origin. Detections
    carry a ``strip_idx`` so callers can distinguish which chart they came from.
    Single-chart fallback uses ``[cfg.dot_roi]``.
    """
    try:
        import cv2  # type: ignore[import-untyped]
    except ImportError as exc:
        if audit is not None:
            try:
                audit.log({"ts": now, "event": "snapshot_fail", "label": "inspect", "error": f"cv2 missing: {exc}"})
            except Exception:
                pass
        return None, []
    try:
        from atm.vision import crop_roi, find_top_dots, pixel_rgb, classify_pixel
        ts_str = datetime.fromtimestamp(now).strftime("%Y%m%d_%H%M%S")
        fpath = fires_dir / f"{ts_str}_inspect.png"
        annotated = frame.copy()

        bg_rgb = cfg.colors["background"].rgb if "background" in cfg.colors else (18, 18, 18)
        palette = {k: (v.rgb, v.tolerance) for k, v in cfg.colors.items() if k != "background"}

        strip_list = strips if strips else [cfg.dot_roi]

        detections: list[dict] = []
        for strip_idx, strip in enumerate(strip_list):
            _draw_roi_cyan(annotated, strip)
            dot_crop = crop_roi(frame, strip)
            # Cer N+spare buline ca să avem rezerve după filtrarea UNKNOWN-urilor
            # (text-artefacte / patch-uri întunecate adiacente bulinei rightmost).
            positions = find_top_dots(dot_crop, bg_rgb, n=8)
            kept = 0
            for cx, cy in positions:
                if kept >= 3:
                    break
                rgb = pixel_rgb(dot_crop, cx, cy)
                match = classify_pixel(rgb, palette)
                if match.name == "UNKNOWN":
                    continue
                pos_abs = (strip.x + cx, strip.y + cy)
                detections.append({
                    "idx": kept, "name": match.name, "rgb": rgb,
                    "distance": match.distance, "confidence": match.confidence,
                    "pos_abs": pos_abs, "strip_idx": strip_idx,
                })
                kept += 1

            # Markerii stau pe un rând SUB ROI, aliniat vertical cu bulina prin x.
            # Primary (FSM pick, rightmost) = tick vertical roșu peste markeri.
            marker_y = strip.y + strip.h + 8
            strip_dets = [d for d in detections if d["strip_idx"] == strip_idx]
            for det in reversed(strip_dets):
                pos_abs = det["pos_abs"]
                name = det["name"]
                if name in cfg.colors:
                    rgb_pal = cfg.colors[name].rgb
                    bgr = (int(rgb_pal[2]), int(rgb_pal[1]), int(rgb_pal[0]))
                else:
                    bgr = (128, 128, 128)
                marker_pos = (pos_abs[0], marker_y)
                cv2.circle(annotated, marker_pos, 7, bgr, -1)
                if det["idx"] == 0:
                    cv2.line(annotated, (pos_abs[0], marker_y - 7), (pos_abs[0], pos_abs[1] + 4), (0, 0, 255), 1)

        cv2.imwrite(str(fpath), annotated)
        return fpath, detections
    except Exception as exc:
        if audit is not None:
            try:
                audit.log({"ts": now, "event": "snapshot_fail", "label": "inspect", "error": str(exc)})
            except Exception:
                pass
        return None, []


_COLOR_EMOJI = {
    "turquoise": "🔵",
    "light_green": "🟢",
    "dark_green": "🟢",
    "light_red": "🔴",
    "dark_red": "🔴",
    "yellow": "🟡",
}


def _format_inspect_caption(detections: list[dict], cfg) -> str:
    """Compact caption: leftmost = c1, rightmost = cN (FSM pick).

    Single-strip: c1..cN flat list. Multi-strip: per-strip blocks cu header
    ("stânga"/"dreapta" pt 2 chart-uri, "chart N" pt 3+) și pick separat per strip.
    Emoji prefix = culoarea detectată (Telegram nu suportă text colorat în caption).
    """
    if not detections:
        return "nicio bulină detectată în ROI"

    # Group by strip_idx. Old detections (pre multi-chart) lack the field —
    # treat as single anonymous group.
    groups: dict[int, list[dict]] = {}
    for det in detections:
        groups.setdefault(det.get("strip_idx", 0), []).append(det)
    n_strips = len(groups)

    def _line(display_idx: int, det: dict, is_pick: bool) -> str:
        name = det["name"]
        emoji = _COLOR_EMOJI.get(name, "⚪")
        suffix = " ← pick" if is_pick else ""
        return f"{emoji} c{display_idx}: {name}{suffix}"

    def _strip_header(strip_idx: int) -> str:
        if n_strips == 2:
            return "stânga" if strip_idx == 0 else "dreapta"
        return f"chart {strip_idx + 1}"

    if n_strips <= 1:
        # Single-strip path preserved verbatim — leftmost=c1, rightmost=cN=pick.
        only = next(iter(groups.values()))
        ordered = list(reversed(only))
        total = len(ordered)
        return "\n".join(
            _line(i + 1, d, is_pick=(i + 1 == total))
            for i, d in enumerate(ordered)
        )

    # Multi-strip: one block per strip (sorted by strip_idx ascending).
    blocks: list[str] = []
    for sidx in sorted(groups):
        dets = groups[sidx]
        ordered = list(reversed(dets))  # leftmost first
        total = len(ordered)
        lines = [f"[{_strip_header(sidx)}]"]
        for i, det in enumerate(ordered):
            lines.append(_line(i + 1, det, is_pick=(i + 1 == total)))
        blocks.append("\n".join(lines))
    return "\n".join(blocks)


def _save_annotated_frame(
    frame,
    cfg,
    fires_dir: Path,
    label: str,
    now: float,
    audit: _AuditLike | None = None,
    dot_pos_abs: "tuple[int, int] | None" = None,
    canary_ok: bool = True,
    roi: "Any | None" = None,
) -> "Path | None":
    """Save BGR frame with cyan ROI rect to ``logs/fires/{ts}_{label}.png``.

    ``roi`` overrides ``cfg.dot_roi`` for the rectangle (used by multi-chart split
    so per-strip alerts highlight the correct chart). Falls back to cfg.dot_roi
    when None.

    Returns the path on success, ``None`` on any error. Never raises — snapshot
    is best-effort, the text alert must still go out.
    """
    try:
        import cv2  # type: ignore[import-untyped]  # noqa: F401
    except ImportError as exc:
        if audit is not None:
            try:
                audit.log({"ts": now, "event": "snapshot_fail", "label": label, "error": f"cv2 missing: {exc}"})
            except Exception:
                pass
        return None
    try:
        import cv2  # type: ignore[import-untyped]
        ts_str = datetime.fromtimestamp(now).strftime("%Y%m%d_%H%M%S")
        fpath = fires_dir / f"{ts_str}_{label}.png"
        annotated = frame.copy()
        roi_to_draw = roi if roi is not None else cfg.dot_roi
        _draw_roi_cyan(annotated, roi_to_draw)
        cv2.imwrite(str(fpath), annotated)
        return fpath
    except Exception as exc:
        if audit is not None:
            try:
                audit.log({"ts": now, "event": "snapshot_fail", "label": label, "error": str(exc)})
            except Exception:
                pass
        return None


def _build_heartbeat_alert(
    *,
    fsm_state: str,
    fire_count: int,
    uptime_h: float,
    canary_paused: bool,
    charts: "list | None" = None,
) -> "Alert":
    """Construct the periodic heartbeat Alert.

    Single-chart compat: when ``charts`` is None or has length <=1, body is
    ``"{state} | semnale: N | Hh"`` (drift-pause appended). Multi-chart split:
    one line per chart with ``[chart_id] STATE`` plus a tail signals/uptime line.
    """
    title = "⚠️ pauzat (drift)" if canary_paused else "activ"
    if charts and len(charts) >= 2:
        from atm.notifier import _alert_prefix as _prefix
        chart_lines = []
        for c in charts:
            label = _prefix(c.chart_id).strip()
            state_value = getattr(getattr(c.fsm, "state", None), "value", "—")
            chart_lines.append(f"{label} {state_value}".strip())
        tail = f"| semnale: {fire_count} | {uptime_h:.1f}h"
        if canary_paused:
            tail = f"| semnale: {fire_count} | {uptime_h:.1f}h [drift-pause]"
        body = "\n".join(chart_lines) + "\n" + tail
        return Alert(kind="heartbeat", title=title, body=body)
    state_label = f"{fsm_state} [drift-pause]" if canary_paused else fsm_state
    body = f"{state_label} | semnale: {fire_count} | {uptime_h:.1f}h"
    return Alert(kind="heartbeat", title=title, body=body)


def _emit_arm_alert(
    notifier: _NotifierLike,
    *,
    kind: str,
    direction: str,
    now: float,
    title: str,
    snap: Snapshot,
    snap_kind: str,
    snap_label: str,
) -> None:
    """Build + dispatch an arm-family Alert (arm / opposite_rearm / rearm).

    Keeps the three branches in _handle_tick DRY: they differ only in kind,
    title, and the snap() label used to save the annotated screenshot.
    """
    notifier.send(Alert(
        kind=kind,
        title=title,
        body=f"@ {datetime.fromtimestamp(now).isoformat(timespec='seconds')}",
        image_path=snap(snap_kind, snap_label),
        direction=direction,
    ))


def _handle_tick(
    fsm: StateMachine,
    color: str,
    now: float,
    notifier: _NotifierLike,
    audit: _AuditLike,
    first_accepted: bool,
    snapshot: Snapshot | None = None,
    cfg: Any = None,
) -> Transition | None:
    """Feed FSM for a single accepted color and dispatch arm/prime/late_start
    alerts. Returns the final Transition, or None when the color triggered a
    late-start short-circuit (FSM untouched; caller should skip FIRE handling).

    When ``snapshot`` is provided it is called with ``(kind, label)`` for each
    alert; it returns the PNG path to attach or None (gated off / write failed).
    Default None keeps test call sites numpy-free.

    Pure in the sense that all state lives in the arguments — safe to unit-test
    with a FakeNotifier and FakeAudit.
    """
    snap: Snapshot = snapshot or (lambda _k, _l: None)

    # Pornire târzie: prima culoare acceptată e deja în faza FIRE.
    # Utilizatorul a venit online după ce semnalul s-a declanșat — avertizare fără feed FSM.
    if first_accepted and color in ("light_green", "light_red") and fsm.state == State.IDLE:
        direction = "BUY" if color == "light_green" else "SELL"
        audit.log({
            "ts": now,
            "event": "late_start",
            "observed_color": color,
        })
        notifier.send(Alert(
            kind="late_start",
            title=f"ATM pornit târziu — {direction} deja declanșat",
            body=f"Detectat {color} la pornire. Verifică graficul manual.",
            image_path=snap("late_start", f"late_start_{direction.lower()}"),
            direction=direction,
        ))
        return None

    # Recuperare synth-arm: prima culoare acceptată e deja în faza PRIME.
    # Forțează FSM printr-un arm sintetic ca tranziția reală PRIME să emită
    # alertă normală mai jos. Intrarea audit e marcată catchup:true.
    #
    # Gardă contra punctelor dark_* reziduale post-FIRE: după ce light_green
    # declanșează ciclul, FSM revine la IDLE dar punctele dark_green continuă
    # restul ferestrei de 15m. Sunt zgomot, NU un semnal nou de prime.
    # Verificarea fired_in_session(direction) suprimă synth-arm în acest caz —
    # tick-ul trece la feed-ul normal _from_idle care îl clasifică drept zgomot.
    # (Un ciclu nou autentic vine întotdeauna cu turquoise/yellow proaspăt.)
    catchup = False
    if color in ("dark_green", "dark_red") and fsm.state == State.IDLE:
        direction = "BUY" if color == "dark_green" else "SELL"
        if not fsm.fired_in_session(direction):
            arm_color = "turquoise" if color == "dark_green" else "yellow"
            tr_synth = fsm.feed(cast("DotColor", arm_color), now)
            audit.log({
                "ts": now,
                "event": "tick",
                "color": arm_color,
                "state": tr_synth.next.value,
                "reason": tr_synth.reason,
                "catchup": True,
                "synthesized_from": color,
            })
            notifier.send(Alert(
                kind="arm",
                title=f"{direction} armat ({arm_color}) — recuperare",
                body=f"recuperare — sesiunea era deja armată la pornire "
                     f"@ {datetime.fromtimestamp(now).isoformat(timespec='seconds')}",
                image_path=snap("catchup", f"catchup_arm_{direction.lower()}"),
                direction=direction,
            ))
            catchup = True

    # Normal FSM feed
    tr = fsm.feed(cast("DotColor", color), now)
    tick_event: dict = {
        "ts": now,
        "event": "tick",
        "color": color,
        "state": tr.next.value,
        "reason": tr.reason,
    }
    if catchup:
        tick_event["catchup"] = True
    audit.log(tick_event)

    # ARM: turquoise (BUY) / yellow (SELL) — doar la tranziție nouă IDLE→ARMED
    if tr.reason == "arm":
        direction = "BUY" if tr.next == State.ARMED_BUY else "SELL"
        _emit_arm_alert(
            notifier,
            kind="arm",
            direction=direction,
            now=now,
            title=f"{direction} armat ({color})",
            snap=snap,
            snap_kind="arm",
            snap_label=f"arm_{direction.lower()}",
        )
    # OPPOSITE REARM: ciclul anterior abandonat de semnal opus
    # (PRIMED_BUY→ARMED_SELL pe yellow, sau flip direct ARMED→ARMED opus).
    # Setup nou în direcția opusă — alertă cu screenshot.
    elif tr.reason == "opposite_rearm":
        direction = "BUY" if tr.next == State.ARMED_BUY else "SELL"
        _emit_arm_alert(
            notifier,
            kind="opposite_rearm",
            direction=direction,
            now=now,
            title=f"{direction} re-armat ({color}) — ciclu opus",
            snap=snap,
            snap_kind="opposite_rearm",
            snap_label=f"opposite_rearm_{direction.lower()}",
        )
    # REARM: PRIMED_* → ARMED_* aceeași direcție (arm-color peste prime).
    # Ciclul s-a resetat — prime_ts invalidat, armare proaspătă.
    elif tr.reason == "rearm":
        direction = "BUY" if tr.next == State.ARMED_BUY else "SELL"
        _emit_arm_alert(
            notifier,
            kind="rearm",
            direction=direction,
            now=now,
            title=f"{direction} re-armat ({color}) — ciclu reluat",
            snap=snap,
            snap_kind="rearm",
            snap_label=f"rearm_{direction.lower()}",
        )
    # PRIME: dark_green (BUY) / dark_red (SELL) — doar la ARMED→PRIMED
    elif tr.reason == "prime":
        direction = "BUY" if tr.next == State.PRIMED_BUY else "SELL"
        suffix = " — recuperare" if catchup else ""
        prime_kind = "catchup" if catchup else "prime"
        prime_label = f"prime_{direction.lower()}_catchup" if catchup else f"prime_{direction.lower()}"
        notifier.send(Alert(
            kind="prime",
            title=f"{direction} pregătit ({color}){suffix}",
            body=f"@ {datetime.fromtimestamp(now).isoformat(timespec='seconds')}",
            image_path=snap(prime_kind, prime_label),
            direction=direction,
        ))
    # PHASE_SKIP fire backstop: ARMED→light_{green,red} directly (dark was missed).
    # Emits a fire-equivalent alert when cfg.alerts.fire_on_phase_skip (default True).
    # Uses public FSM lockout API (is_locked/record_fire) to reuse the standard
    # 240s dedupe window so bouncing detectors do not spam the user.
    elif tr.reason == "phase_skip" and color in ("light_green", "light_red"):
        flag_on = True
        if cfg is not None:
            alerts_cfg = getattr(cfg, "alerts", None)
            if alerts_cfg is not None:
                flag_on = bool(getattr(alerts_cfg, "fire_on_phase_skip", True))
        if flag_on:
            direction = "BUY" if color == "light_green" else "SELL"
            if not fsm.is_locked(direction, now):
                fsm.record_fire(direction, now)
                dark_name = "dark_green" if direction == "BUY" else "dark_red"
                notifier.send(Alert(
                    kind="phase_skip_fire",
                    title=f"PHASE SKIP {direction} — {dark_name} nu a fost detectat",
                    body=(
                        "Verifică chart-ul manual. Posibil necalibrare culoare "
                        f"(observat {color} direct după armare)."
                    ),
                    image_path=snap("phase_skip", f"phase_skip_{direction.lower()}"),
                    direction=direction,
                ))
    return tr


@dataclass
class _TickSyncResult:
    frame: Any = None
    res: Any = None       # DetectionResult | None
    tr: Any = None        # Transition | None
    first_consumed: bool = False
    late_start: bool = False
    new_color: str | None = None  # corpus sample color when changed
    chart_id: str = ""    # multi-chart split: which chart produced this result


@dataclass
class ChartState:
    """Per-chart split-workspace state (FSM + Detector + sub-ROI).

    chart_id="" → single-chart mode (no Alert prefix). "left"/"right" for n=2.
    "chart_0", "chart_1", ... for n>2. Each chart has its own debounce + FSM
    independent of siblings.
    """
    chart_id: str
    sub_roi: Any
    detector: Any
    fsm: Any
    first_accepted: bool = True
    last_saved_color: "str | None" = None


@dataclass
class RunContext:
    """Dependencies passed to module-scope detection-loop helpers.

    Keeps `_run_tick`, `_handle_fsm_result`, `_drain_cmd_queue`, and
    `_dispatch_command` at module scope so they are directly unit-testable
    without reconstructing `run_live_async`.
    """
    cfg: Any
    capture: Callable
    canary: Any
    detector: Any
    fsm: Any
    notifier: _NotifierLike
    audit: _AuditLike
    detection_log: _AuditLike
    scheduler: Any
    samples_dir: Path
    fires_dir: Path
    cmd_queue: Any  # asyncio.Queue[Command]
    state: Any      # carries first_accepted, last_saved_color, levels_extractor, fire_count, start
    levels_extractor_factory: Callable  # builds LevelsExtractor(cfg, trigger, now)
    lifecycle: Any = None  # LifecycleState — window + user_paused tracking
    # Pending /rebase proposal: (proposed_ts, new_phash, config_path) or None.
    # One slot; a second /rebase overwrites. `/rebase confirm` applies if within TTL.
    pending_rebase: Any = None
    charts: list = field(default_factory=list)  # list[ChartState] — multi-chart split


@dataclass
class _LoopState:
    """Per-loop mutable state (previously closure nonlocals)."""
    first_accepted: bool = True
    last_saved_color: str | None = None
    levels_extractor: Any = None
    fire_count: int = 0
    start: float = 0.0
    n_primed_global: int = 0


@dataclass
class LifecycleState:
    """Tracks user-pause / out-of-window state across detection ticks.

    last_window_state: None at startup so _maybe_log_transition can seed it
    without emitting a spurious market_open alert on the first in-window tick.
    """
    user_paused: bool = False
    last_window_state: str | None = None  # "open" / "closed" / None (uninitialized)
    # Local wall-clock (start_hhmm, stop_hhmm) set via /window; None = no window.
    # ANDed with operating_hours: detection runs only when inside BOTH.
    session_window: tuple[str, str] | None = None


# Locale-independent weekday names; index matches datetime.weekday() (MON=0).
_WEEKDAY_NAMES: tuple[str, ...] = ("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN")


def _in_trading_window(now_ts: float, cfg) -> bool:
    """True when operating_hours disabled or we are currently inside the window."""
    oh = getattr(cfg, "operating_hours", None)
    if oh is None or not oh.enabled:
        return True
    tz = getattr(oh, "_tz_cache", None)
    if not isinstance(tz, tzinfo):
        return True  # fail-open
    now_exchange = datetime.fromtimestamp(now_ts, tz=tz)
    if _WEEKDAY_NAMES[now_exchange.weekday()] not in oh.weekdays:
        return False
    hhmm = now_exchange.strftime("%H:%M")
    return oh.start_hhmm <= hhmm < oh.stop_hhmm


def _should_skip(now_ts: float, state: LifecycleState, cfg, canary) -> str | None:
    """Return a reason string if detection should be skipped, else None.

    Order: user_paused > canary drift > operating-hours window > session window.
    Uses the ZoneInfo cached on cfg.operating_hours._tz_cache (populated at
    config load) to avoid per-tick tz lookup cost. The session window (set via
    /window) is local wall-clock and ANDed with operating_hours: outside EITHER
    window detection is skipped.
    """
    if state.user_paused:
        return "user_paused"
    if getattr(canary, "is_paused", False):
        return "drift_paused"
    oh = getattr(cfg, "operating_hours", None)
    if oh is not None and oh.enabled:
        tz = getattr(oh, "_tz_cache", None)
        # Enabled but no tz resolved (or mock cfg in tests) — skip the oh check
        # rather than crash; the session window below may still apply.
        if isinstance(tz, tzinfo):
            now_exchange = datetime.fromtimestamp(now_ts, tz=tz)
            # weekday() = 0..6 (MON..SUN). Locale-free; strftime('%a') is not.
            if _WEEKDAY_NAMES[now_exchange.weekday()] not in oh.weekdays:
                return "out_of_window_weekend"
            hhmm = now_exchange.strftime("%H:%M")
            if hhmm < oh.start_hhmm or hhmm >= oh.stop_hhmm:
                return "out_of_window_hours"
    sw = getattr(state, "session_window", None)
    if sw is not None:
        # Local wall-clock (no tz) — matches --start-at/--stop-at convention.
        hhmm_local = datetime.fromtimestamp(now_ts).strftime("%H:%M")
        if hhmm_local < sw[0] or hhmm_local >= sw[1]:
            return "out_of_window_hours"
    return None


def _maybe_log_transition(
    reason: str | None,
    state: LifecycleState,
    now: float,
    audit: _AuditLike,
    notifier: _NotifierLike,
    status_body: str = "",
) -> str | None:
    """Log market_open / market_closed exactly once per transition.

    Returns the event name emitted (``"market_open"`` / ``"market_closed"``)
    or ``None`` when no transition happened. Callers that need to react to
    the opening (e.g. bring TradeStation to the foreground) use this value.

    Startup guard (R2): when last_window_state is None we just seed it; no
    alert/audit event is emitted for the initial evaluation. This prevents a
    spurious market_open alert when run_live_async starts in-window.
    """
    if reason is None:
        window_reason = "open"
    elif reason.startswith("out_of_window"):
        window_reason = "closed"
    else:
        # user_paused / drift_paused don't change market window state
        return None

    if window_reason == state.last_window_state:
        return None

    if state.last_window_state is None:
        state.last_window_state = window_reason
        return None

    event_name = "market_open" if window_reason == "open" else "market_closed"
    audit.log({"ts": now, "event": event_name, "reason": reason})
    if event_name == "market_closed":
        body = "Piața închisă — monitorizare pauzată până la următoarea deschidere."
    else:
        body = "Piața deschisă — monitorizare reluată."
    if status_body:
        body = f"{body}\n{status_body}"
    notifier.send(Alert(
        kind="status",
        title="Piața deschisă" if event_name == "market_open" else "Piața închisă",
        body=body,
    ))
    state.last_window_state = window_reason
    return event_name


def _sync_detection_tick(
    capture: Callable,
    canary: Any,
    cfg: Any,
    detector: Any,
    fsm: Any,
    notifier: _NotifierLike,
    audit: _AuditLike,
    detection_log: _AuditLike,
    fires_dir: Path,
    first_accepted: bool,
    last_saved_color: "str | None",
    now: float,
    samples_dir: Path,
    chart_id: str = "",
    sub_roi: Any = None,
    frame: Any = None,
) -> _TickSyncResult:
    """One full detection tick (blocking I/O). Runs in asyncio.to_thread.

    When ``frame`` is provided, capture/canary work is skipped (caller already
    did them — multi-chart orchestrator). ``chart_id`` is propagated to all
    Alerts emitted by this tick. ``sub_roi`` overrides cfg.dot_roi for the
    cyan rect on saved screenshots (per-chart ROI).
    """
    if frame is None:
        frame = capture()
        if frame is None:
            audit.log({"ts": now, "event": "window_lost"})
            return _TickSyncResult(chart_id=chart_id)

        cr = canary.check(frame)
        if canary.is_paused:
            audit.log({"ts": now, "event": "paused", "drift": cr.distance})
            return _TickSyncResult(frame=frame, chart_id=chart_id)

    res = detector.step(now, frame)
    detection_log.log({
        "ts": now, "event": "frame",
        "chart_id": chart_id,
        "window_found": res.window_found,
        "dot_found": res.dot_found,
        "rgb": list(res.rgb) if res.rgb is not None else None,
        "match_name": res.match.name if res.match is not None else None,
        "distance": round(res.match.distance, 2) if res.match is not None else None,
        "confidence": round(res.match.confidence, 3) if res.match is not None else None,
        "accepted": res.accepted,
        "color": res.color,
    })

    if not (res.accepted and res.color):
        return _TickSyncResult(frame=frame, res=res, chart_id=chart_id)

    is_first = first_accepted
    roi_for_draw = sub_roi if sub_roi is not None else cfg.dot_roi

    def _snapshot(kind: str, label: str) -> "Path | None":
        if not getattr(cfg.attach_screenshots, kind, True):
            return None
        return _save_annotated_frame(
            frame, cfg, fires_dir, label, now, audit=audit,
            dot_pos_abs=getattr(res, "dot_pos_abs", None),
            canary_ok=True, roi=roi_for_draw,
        )

    # Wrap notifier so every Alert from _handle_tick carries chart_id.
    if chart_id:
        class _ChartNotifier:
            def send(self, alert: Alert) -> None:
                alert.chart_id = chart_id
                notifier.send(alert)
        wrapped_notifier: _NotifierLike = _ChartNotifier()
    else:
        wrapped_notifier = notifier

    tr = _handle_tick(fsm, res.color, now, wrapped_notifier, audit, is_first, snapshot=_snapshot, cfg=cfg)

    if tr is None:
        return _TickSyncResult(frame=frame, res=res, first_consumed=is_first, late_start=True, chart_id=chart_id)

    new_color: str | None = None
    if res.color != last_saved_color:
        ts_str = datetime.fromtimestamp(now).strftime("%Y%m%d_%H%M%S")
        suffix = f"_{chart_id}" if chart_id else ""
        sample_path = samples_dir / f"{ts_str}_{res.color}{suffix}.png"
        try:
            import cv2  # type: ignore[import-untyped]
            cv2.imwrite(str(sample_path), frame)
        except Exception as exc:
            audit.log({"ts": now, "event": "sample_save_failed", "error": str(exc)})
        new_color = res.color

    if tr.trigger and not tr.locked:
        fire_path: "Path | None" = None
        if cfg.attach_screenshots.trigger:
            fire_path = _save_annotated_frame(
                frame, cfg, fires_dir, tr.trigger, now, audit=audit,
                dot_pos_abs=getattr(res, "dot_pos_abs", None),
                canary_ok=True, roi=roi_for_draw,
            )
        notifier.send(Alert(
            kind="trigger",
            title=f"Semnal {tr.trigger}",
            body=f"@ {datetime.fromtimestamp(now).isoformat(timespec='seconds')}",
            image_path=fire_path,
            direction=tr.trigger,
            chart_id=chart_id,
        ))

    return _TickSyncResult(
        frame=frame, res=res, tr=tr,
        first_consumed=is_first, new_color=new_color, chart_id=chart_id,
    )


def _brief_status(ctx) -> str:
    h = (time.monotonic() - ctx.state.start) / 3600
    fsm_state = ctx.charts[0].fsm.state.value if ctx.charts else ctx.fsm.state.value
    return f"{fsm_state} | semnale: {ctx.state.fire_count} | {h:.1f}h"


async def _run_tick(ctx: RunContext) -> _TickSyncResult:
    """Execute one `_sync_detection_tick` in a thread; returns result or empty.

    Lifecycle gating (user pause / operating hours / drift) happens here, not
    inside the sync tick, so the async loop can still drain commands and emit
    market_open / market_closed transitions even when the heavy detection
    work is skipped.
    """
    now = time.time()
    if ctx.lifecycle is not None:
        skip = _should_skip(now, ctx.lifecycle, ctx.cfg, ctx.canary)
        sb = _brief_status(ctx)
        transition = _maybe_log_transition(
            skip, ctx.lifecycle, now, ctx.audit, ctx.notifier, status_body=sb,
        )
        if transition == "market_open" and ctx.cfg.window_title:
            title = await asyncio.to_thread(_focus_window_by_title, ctx.cfg.window_title)
            ctx.audit.log({"ts": now, "event": "window_focused", "command": "market_open", "title": title})
            await asyncio.sleep(0.15)
        elif transition == "market_closed":
            _handle_market_closed(ctx, now)
        if skip is not None:
            # No detection this tick. Empty result → _handle_fsm_result no-op.
            return _TickSyncResult()
    return await asyncio.to_thread(
        _sync_detection_tick,
        ctx.capture, ctx.canary, ctx.cfg, ctx.detector, ctx.fsm,
        ctx.notifier, ctx.audit, ctx.detection_log,
        ctx.fires_dir, ctx.state.first_accepted, ctx.state.last_saved_color,
        now, ctx.samples_dir,
    )


def _chart_id_for_index(i: int, n: int) -> str:
    """Map (idx, total) → chart_id. n=1 → "" ; n=2 → "left"/"right" ; n>2 → "chart_N"."""
    if n == 1:
        return ""
    if n == 2:
        return ("left", "right")[i]
    return f"chart_{i}"


def _commit_layout_change(
    ctx: RunContext,
    new_strips: list,
    now: float,
    suppress_alert: bool = False,
) -> None:
    """Replace ctx.charts with fresh ChartState objects for new_strips.

    Resets per-chart FSM (StateMachine(lockout_s)) and Detector (with
    dot_roi_override=strip), zeroes n_primed_global, stops scheduler if running,
    audits the layout_change event, sends a status Alert.

    ``suppress_alert=True`` skips the status Alert (caller will send a combined
    one — used by the auto-rebase path so the user gets a single notification
    covering both the layout change and the canary rebase).
    """
    from atm.detector import Detector
    from atm.state_machine import StateMachine

    cfg = ctx.cfg
    old_n = len(ctx.charts)
    new_n = len(new_strips)

    new_charts: list = []
    for i, strip in enumerate(new_strips):
        chart_id = _chart_id_for_index(i, new_n)
        det = Detector(cfg, ctx.capture, dot_roi_override=strip)
        fsm = StateMachine(lockout_s=cfg.lockout_s)
        new_charts.append(ChartState(
            chart_id=chart_id, sub_roi=strip, detector=det, fsm=fsm,
            first_accepted=True, last_saved_color=None,
        ))

    ctx.state.n_primed_global = 0
    if getattr(ctx.scheduler, "is_running", False):
        ctx.scheduler.stop()
        ctx.audit.log({"ts": now, "event": "scheduler_stopped", "reason": "layout_change"})

    ctx.audit.log({
        "ts": now,
        "event": "layout_change",
        "old_n": old_n,
        "new_n": new_n,
        "strips": [{"x": s.x, "w": s.w} for s in new_strips],
    })
    # Suppress notification on first-ever detection (old_n=0 = startup bootstrap,
    # not a real layout change). Also suppress when caller (auto-rebase path)
    # will send a combined alert.
    if old_n > 0 and not suppress_alert:
        ctx.notifier.send(Alert(
            kind="status",
            title=f"🔄 Layout TS schimbat: {old_n} → {new_n} ferestre. FSM resetat.",
            body="",
            silent=True,
        ))
    ctx.charts = new_charts
    # Keep ctx.fsm / ctx.detector in sync with charts[0] so legacy code paths
    # (brief_status, /ss state_hdr, /status) continue to work after layout change.
    if new_charts:
        ctx.fsm = new_charts[0].fsm
        ctx.detector = new_charts[0].detector


def _reset_charts_fsm(ctx: RunContext) -> None:
    """Reset every chart's FSM to IDLE and clear the primed counter.

    Used on the market_closed transition so a stale PRIMED dot (dark_green/
    dark_red) doesn't keep the screenshot scheduler alive past close — and so
    the 0→1 primed edge can re-arm the scheduler cleanly in the next session.
    """
    from atm.state_machine import StateMachine

    for c in ctx.charts:
        c.fsm = StateMachine(lockout_s=ctx.cfg.lockout_s)
    ctx.state.n_primed_global = 0
    if ctx.charts:
        ctx.fsm = ctx.charts[0].fsm
    else:
        # Single-chart / legacy path keeps the FSM on ctx.fsm directly.
        ctx.fsm = StateMachine(lockout_s=ctx.cfg.lockout_s)


def _handle_market_closed(ctx: RunContext, now: float) -> None:
    """Stop periodic screenshots and reset FSM when the market window closes."""
    if getattr(ctx.scheduler, "is_running", False):
        ctx.scheduler.stop()
        ctx.audit.log({"ts": now, "event": "scheduler_stopped", "reason": "market_closed"})
    _reset_charts_fsm(ctx)


def _detect_strips_for_ctx(ctx: RunContext, frame: Any) -> list:
    """Lazy strip detection for ctx.cfg + frame. Empty list when no vivid pixels."""
    from atm.layout import detect_strips
    from atm.vision import crop_roi
    cfg = ctx.cfg
    strip_h = cfg.dot_roi.h
    min_strip_px = max(150, strip_h * 8)
    min_gap_px = max(20, int(strip_h * 0.8))
    palette = {
        name: (spec.rgb, spec.tolerance)
        for name, spec in cfg.colors.items()
        if name != "background"
    }
    full_crop = crop_roi(frame, cfg.dot_roi)
    raw = detect_strips(full_crop, palette, min_gap_px, min_strip_px)
    # detect_strips returns ROIs relative to the cropped region. Translate
    # back into frame coordinates by adding cfg.dot_roi origin so per-strip
    # detector crops can use the absolute ROI.
    from atm.config import ROI
    return [
        ROI(x=cfg.dot_roi.x + r.x, y=cfg.dot_roi.y + r.y, w=r.w, h=r.h)
        for r in raw
    ]


async def _run_multi_tick(ctx: RunContext) -> "list[_TickSyncResult]":
    """Multi-chart orchestrator: capture once, detect strips, run per-chart ticks.

    Returns a list of _TickSyncResult — one per chart (empty list if capture
    failed or canary paused). Layout management is in-process: when strips
    don't match current ctx.charts, _commit_layout_change is invoked before
    feeding ticks to the new charts.
    """
    now = time.time()
    if ctx.lifecycle is not None:
        skip = _should_skip(now, ctx.lifecycle, ctx.cfg, ctx.canary)
        sb = _brief_status(ctx)
        transition = _maybe_log_transition(
            skip, ctx.lifecycle, now, ctx.audit, ctx.notifier, status_body=sb,
        )
        if transition == "market_open" and ctx.cfg.window_title:
            title = await asyncio.to_thread(_focus_window_by_title, ctx.cfg.window_title)
            ctx.audit.log({"ts": now, "event": "window_focused", "command": "market_open", "title": title})
            await asyncio.sleep(0.15)
        elif transition == "market_closed":
            _handle_market_closed(ctx, now)
        if skip is not None:
            return []

    frame = await asyncio.to_thread(ctx.capture)
    if frame is None:
        ctx.audit.log({"ts": now, "event": "window_lost"})
        return []

    cr = ctx.canary.check(frame)
    if ctx.canary.is_paused:
        # Already paused from a prior tick — short-circuit. Auto-rebase only
        # runs on the not_paused→drifted edge; once paused, only /resume or
        # /rebase confirm clears it.
        ctx.audit.log({"ts": now, "event": "paused", "drift": cr.distance})
        return []

    new_strips = await asyncio.to_thread(_detect_strips_for_ctx, ctx, frame)
    current_strips = [c.sub_roi for c in ctx.charts]

    if cr.drifted:
        # Two-signal gate: drift ALONE could be a real anomaly (window occluded,
        # screen blackout) OR a layout transition that moved the canary anchor
        # within the frame (e.g. 2→1 chart split shifts the menu chrome). We
        # disambiguate by checking strip count on the same frame. If strip count
        # changed, treat as legitimate layout change → silently rebase canary +
        # commit layout change + single combined alert. Otherwise pause as before.
        count_changed = bool(new_strips) and len(new_strips) != len(current_strips)
        if count_changed:
            rebase_result = _auto_rebase_canary(ctx, frame, now)
            if rebase_result is not None:
                old_phash, new_phash = rebase_result
                old_n = len(current_strips)
                new_n = len(new_strips)
                _commit_layout_change(ctx, new_strips, now, suppress_alert=True)
                ctx.audit.log({
                    "ts": now, "event": "layout_change_with_rebase",
                    "old_n": old_n, "new_n": new_n,
                    "old_phash": old_phash, "new_phash": new_phash,
                    "distance": cr.distance,
                })
                shot = await asyncio.to_thread(
                    _save_rebase_frame, frame, ctx.cfg, ctx.fires_dir, now, ctx.audit,
                )
                ctx.notifier.send(Alert(
                    kind="status",
                    title=f"🔄 Layout TS schimbat: {old_n}→{new_n} ferestre + canary auto-rebased",
                    body=f"distance={cr.distance}, old={old_phash[:12]}…, new={new_phash[:12]}…",
                    image_path=shot,
                    silent=True,
                ))
                # Fall through to per-chart tick loop with new charts.
            else:
                # Rebase failed (TOML malformed / config_version unknown) →
                # fall back to standard drift-pause behavior.
                ctx.canary.commit_pause(cr.distance)
                ctx.audit.log({"ts": now, "event": "paused", "drift": cr.distance,
                               "reason": "auto_rebase_failed"})
                return []
        else:
            # Drift without strip-count change → real drift. Pause.
            ctx.canary.commit_pause(cr.distance)
            ctx.audit.log({"ts": now, "event": "paused", "drift": cr.distance})
            return []
    elif new_strips:
        if len(new_strips) != len(current_strips):
            # Real count change (1↔2 ferestre) → rebuild charts.
            _commit_layout_change(ctx, new_strips, now)
        else:
            # Same count → silent sub_roi update regardless of position/width jitter.
            # Width pulsations (e.g. 792↔810px from antialiased edges) must not
            # reset FSM or emit alerts — that path produced 500+ false alerts/day.
            for c, ns in zip(ctx.charts, new_strips):
                c.sub_roi = ns
                if hasattr(c.detector, "update_dot_roi"):
                    c.detector.update_dot_roi(ns)

    results: list[_TickSyncResult] = []
    for c in ctx.charts:
        result = await asyncio.to_thread(
            _sync_detection_tick,
            ctx.capture, ctx.canary, ctx.cfg, c.detector, c.fsm,
            ctx.notifier, ctx.audit, ctx.detection_log,
            ctx.fires_dir, c.first_accepted, c.last_saved_color,
            now, ctx.samples_dir,
            c.chart_id, c.sub_roi, frame,
        )
        results.append(result)
    return results


async def _handle_fsm_result(ctx: RunContext, result: "Any") -> None:
    """Scheduler start/stop + levels extraction.

    Accepts either a single ``_TickSyncResult`` (legacy single-chart path) or a
    ``list[_TickSyncResult]`` (multi-chart split). For multi-chart, scheduler
    arbitration uses ``ctx.state.n_primed_global`` (start when 0→1, stop when
    1→0). Levels extraction is disabled when ``len(ctx.charts) >= 2`` since the
    extractor reads the full window y-axis which is per-chart-private in split.
    """
    results: list[_TickSyncResult]
    if isinstance(result, list):
        results = result
    else:
        results = [result]

    if not results:
        return

    multi_chart = len(ctx.charts) >= 2

    for r in results:
        if r.first_consumed:
            if multi_chart:
                for c in ctx.charts:
                    if c.chart_id == r.chart_id:
                        c.first_accepted = False
                        break
            else:
                ctx.state.first_accepted = False
                if ctx.charts:
                    ctx.charts[0].first_accepted = False
        if r.new_color is not None:
            if multi_chart:
                for c in ctx.charts:
                    if c.chart_id == r.chart_id:
                        c.last_saved_color = r.new_color
                        break
            else:
                ctx.state.last_saved_color = r.new_color
                if ctx.charts:
                    ctx.charts[0].last_saved_color = r.new_color

    if not multi_chart:
        # Legacy single-chart path — preserve scheduler+levels semantics exactly.
        single = results[0]
        tr = single.tr
        res = single.res

        if single.late_start or res is None:
            return

        if tr is not None and getattr(res, "accepted", False) and getattr(res, "color", None):
            if tr.reason == "prime" and not ctx.scheduler.is_running:
                ctx.scheduler.start(ctx.cfg.telegram.auto_poll_interval_s)
                ctx.audit.log({"ts": time.time(), "event": "scheduler_started", "reason": "primed"})
            elif tr.reason in ("fire", "cooled", "phase_skip", "opposite_rearm") and ctx.scheduler.is_running:
                ctx.scheduler.stop()
                ctx.audit.log({"ts": time.time(), "event": "scheduler_stopped", "reason": tr.reason})

        if tr is not None and tr.trigger and not tr.locked:
            ctx.state.fire_count += 1
            if ctx.scheduler.is_running:
                ctx.scheduler.stop()
                ctx.audit.log({"ts": time.time(), "event": "scheduler_stopped", "reason": "fire"})
            ctx.state.levels_extractor = ctx.levels_extractor_factory(ctx.cfg, tr.trigger, time.time())

        if ctx.state.levels_extractor is not None and single.frame is not None:
            lr = ctx.state.levels_extractor.step(single.frame, time.time())
            if lr.status in ("complete", "timeout"):
                if lr.status == "complete" and lr.levels:
                    ctx.notifier.send(Alert(
                        kind="levels",
                        title="Niveluri",
                        body=(
                            f"SL={lr.levels.sl} "
                            f"TP1={lr.levels.tp1} "
                            f"TP2={lr.levels.tp2}"
                        ),
                    ))
                ctx.state.levels_extractor = None
        return

    # Multi-chart path: aggregate scheduler arbitration over all results.
    for r in results:
        tr = r.tr
        res = r.res
        if r.late_start or res is None or tr is None:
            continue
        if not (getattr(res, "accepted", False) and getattr(res, "color", None)):
            continue

        if tr.reason == "prime":
            ctx.state.n_primed_global += 1
            if ctx.state.n_primed_global == 1 and not ctx.scheduler.is_running:
                ctx.scheduler.start(ctx.cfg.telegram.auto_poll_interval_s)
                ctx.audit.log({"ts": time.time(), "event": "scheduler_started", "reason": "primed"})
        elif tr.reason in ("fire", "cooled", "phase_skip", "opposite_rearm"):
            if ctx.state.n_primed_global > 0:
                ctx.state.n_primed_global -= 1
            if ctx.state.n_primed_global == 0 and ctx.scheduler.is_running:
                ctx.scheduler.stop()
                ctx.audit.log({"ts": time.time(), "event": "scheduler_stopped", "reason": tr.reason})

        if tr.trigger and not tr.locked:
            ctx.state.fire_count += 1
            if ctx.state.n_primed_global == 0 and ctx.scheduler.is_running:
                ctx.scheduler.stop()
                ctx.audit.log({"ts": time.time(), "event": "scheduler_stopped", "reason": "fire"})
            # Levels disabled in split (per-chart y-axis would need its own calib).
            ctx.audit.log({"ts": time.time(), "event": "levels_skipped_split"})


async def _dispatch_command(ctx: RunContext, cmd) -> None:
    """Process a single Command. Exceptions bubble — caller wraps in try/except."""
    cfg = ctx.cfg
    if cmd.action == "set_interval":
        if getattr(ctx.canary, "is_paused", False):
            ctx.notifier.send(Alert(
                kind="warn",
                title="⚠️ Canary drift activ — /set_interval ignorat",
                body="Trimite /resume pentru a relua detecția.",
            ))
            return
        secs = cmd.value or cfg.telegram.auto_poll_interval_s
        ctx.scheduler.start(secs)
        ctx.audit.log({"ts": time.time(), "event": "scheduler_started", "reason": "set_interval", "interval_s": secs})
        ctx.notifier.send(Alert(kind="status", title=f"Polling activ — interval {secs // 60} min", body=""))
    elif cmd.action == "stop":
        if ctx.scheduler.is_running:
            ctx.scheduler.stop()
            ctx.audit.log({"ts": time.time(), "event": "scheduler_stopped", "reason": "command_stop"})
            ctx.notifier.send(Alert(kind="status", title="Polling oprit", body=""))
        else:
            ctx.notifier.send(Alert(kind="status", title="Polling nu este activ", body=""))
    elif cmd.action == "status":
        uptime_h = (time.monotonic() - ctx.state.start) / 3600
        last_roll = ctx.detector.rolling[-1] if ctx.detector.rolling else None
        last_conf = f"{last_roll.match.confidence:.2f}" if last_roll and last_roll.match else "—"
        last_color = (last_roll.color or last_roll.match.name) if last_roll and last_roll.match else "—"
        sched_info = (
            f"{ctx.scheduler.interval_s // 60}min" if ctx.scheduler.interval_s else "activ"
        ) if ctx.scheduler.is_running else "oprit"

        _PAUSE_LABELS = {
            "user_paused": "pauză manuală",
            "drift_paused": "pauză (ecran schimbat)",
            "out_of_window_hours": "în afara orelor",
            "out_of_window_weekend": "weekend",
        }
        skip = _should_skip(time.time(), ctx.lifecycle, ctx.cfg, ctx.canary) if ctx.lifecycle else None
        line1_state = _PAUSE_LABELS.get(skip, ctx.fsm.state.value) if skip else ctx.fsm.state.value

        lines = [
            f"{line1_state} | semnale: {ctx.state.fire_count} | {uptime_h:.1f}h",
            f"{last_color} ({last_conf}) | poller: {sched_info}",
        ]
        oh = getattr(ctx.cfg, "operating_hours", None)
        if oh is not None and oh.enabled:
            window_val = ctx.lifecycle.last_window_state if ctx.lifecycle else "—"
            window_ro = {"open": "deschisă", "closed": "închisă"}.get(window_val or "", window_val or "—")
            lines.append(f"fereastră: {window_ro}")

        cfg_name = getattr(ctx.cfg, "config_version", None) or getattr(ctx.cfg, "version", None)
        if isinstance(cfg_name, str) and cfg_name not in ("", "unknown"):
            lines.append(f"config: {cfg_name}")

        ctx.notifier.send(Alert(kind="status", title="ATM Status", body="\n".join(lines)))
    elif cmd.action == "ss":
        now_ss = time.time()
        if cfg.window_title:
            title = await asyncio.to_thread(_focus_window_by_title, cfg.window_title)
            ctx.audit.log({"ts": now_ss, "event": "window_focused", "command": "ss", "title": title})
            await asyncio.sleep(0.15)
        frame_ss = await asyncio.to_thread(ctx.capture)
        if frame_ss is None:
            ctx.notifier.send(Alert(
                kind="warn",
                title="Captură eșuată — verificați fereastra TradeStation",
                body="",
            ))
            return
        # Always re-detect strips fresh — out-of-window or pre-first-tick,
        # ctx.charts is empty (lopul de detection nu rulează când e closed),
        # dar /ss trebuie să arate corect oricând. Fallback pe ctx.charts
        # dacă detecția eșuează, apoi pe cfg.dot_roi via ss_strips=None.
        ss_strips: "list | None" = None
        try:
            fresh = await asyncio.to_thread(_detect_strips_for_ctx, ctx, frame_ss)
        except Exception:
            fresh = []
        if fresh:
            ss_strips = fresh
        elif ctx.charts:
            ss_strips = [c.sub_roi for c in ctx.charts]
        path_ss, detections_ss = await asyncio.to_thread(
            _save_inspect_frame, frame_ss, ctx.cfg, ctx.fires_dir, now_ss, ctx.audit, ss_strips,
        )
        caption_ss = _format_inspect_caption(detections_ss, ctx.cfg)
        state_hdr = _fsm_step_label(ctx.fsm, now_ss)
        warn_parts = [state_hdr]
        if getattr(ctx.canary, "is_paused", False):
            warn_parts.append("⚠️ DETECȚIE OPRITĂ (drift) — trimite /resume")
        warn_parts.append(caption_ss)
        ctx.audit.log({
            "ts": now_ss, "event": "screenshot_sent",
            "path": str(path_ss) if path_ss else None,
            "n_dots": len(detections_ss),
        })
        ctx.notifier.send(Alert(
            kind="screenshot", title="Screenshot manual",
            body="\n\n".join(warn_parts), image_path=path_ss,
        ))
    elif cmd.action == "pause":
        # User manually stops monitoring. Canary drift state is untouched.
        if ctx.lifecycle is not None:
            ctx.lifecycle.user_paused = True
        ctx.audit.log({"ts": time.time(), "event": "user_paused"})
        ctx.notifier.send(Alert(
            kind="status",
            title="Monitorizare oprită manual",
            body="Folosește /resume pentru a relua.",
        ))
    elif cmd.action == "resume":
        # /resume clears BOTH user_paused AND canary drift in one shot.
        # /resume force rămâne acceptat ca alias (legacy muscle memory);
        # câmpul `force` e păstrat în audit pentru schema compat.
        # Capture+annotate BEFORE clearing state to avoid race with a concurrent
        # FSM tick firing between clear and screenshot.
        now_r = time.time()
        path_r: "Path | None" = None
        detections_r: list[dict] = []
        capture_failed = False
        if cfg.window_title:
            title = await asyncio.to_thread(_focus_window_by_title, cfg.window_title)
            ctx.audit.log({"ts": now_r, "event": "window_focused", "command": "resume", "title": title})
            await asyncio.sleep(0.15)
        frame_r = await asyncio.to_thread(ctx.capture)
        if frame_r is None:
            capture_failed = True
        else:
            # Same fresh-detect logic ca /ss: out-of-window ctx.charts e gol,
            # dar /resume tot trebuie să arate corect ambele chart-uri.
            r_strips: "list | None" = None
            try:
                fresh_r = await asyncio.to_thread(_detect_strips_for_ctx, ctx, frame_r)
            except Exception:
                fresh_r = []
            if fresh_r:
                r_strips = fresh_r
            elif ctx.charts:
                r_strips = [c.sub_roi for c in ctx.charts]
            path_r, detections_r = await asyncio.to_thread(
                _save_inspect_frame, frame_r, ctx.cfg, ctx.fires_dir, now_r, ctx.audit, r_strips,
            )

        was_drift = bool(getattr(ctx.canary, "is_paused", False))
        was_user = bool(ctx.lifecycle.user_paused) if ctx.lifecycle is not None else False
        force = cmd.value == 1
        if ctx.lifecycle is not None:
            ctx.lifecycle.user_paused = False
        if was_drift:
            ctx.canary.resume()
        ctx.audit.log({
            "ts": time.time(), "event": "user_resumed",
            "was_drift": was_drift, "was_user": was_user, "force": force,
            "screenshot_path": str(path_r) if path_r else None,
            "n_dots": len(detections_r),
        })

        skip_now = None
        if ctx.lifecycle is not None:
            skip_now = _should_skip(time.time(), ctx.lifecycle, ctx.cfg, ctx.canary)
        title_r, body_r = _build_resume_info_text(was_drift, skip_now)
        if capture_failed:
            title_r = f"{title_r} — ⚠️ captură eșuată"
        state_hdr_r = _fsm_step_label(ctx.fsm, now_r)
        body_parts = [state_hdr_r]
        if body_r:
            body_parts.append(body_r)
        if not capture_failed:
            body_parts.append(_format_inspect_caption(detections_r, ctx.cfg))
        ctx.notifier.send(Alert(
            kind="screenshot", title=title_r,
            body="\n\n".join(body_parts), image_path=path_r,
        ))
    elif cmd.action == "window":
        if ctx.lifecycle is None:
            return
        if cmd.window is None:
            ctx.lifecycle.session_window = None
            ctx.audit.log({"ts": time.time(), "event": "session_window_cleared"})
            ctx.notifier.send(Alert(
                kind="status",
                title="Fereastră de monitorizare dezactivată",
                body="Monitorizez conform operating_hours (sau continuu).",
            ))
        else:
            start_w, stop_w = cmd.window
            ctx.lifecycle.session_window = (start_w, stop_w)
            ctx.audit.log({
                "ts": time.time(), "event": "session_window_set",
                "start": start_w, "stop": stop_w,
            })
            ctx.notifier.send(Alert(
                kind="status",
                title=f"Fereastră monitorizare: {start_w}–{stop_w} (ora locală, zilnic)",
                body="În afara intervalului monitorizarea se pauzează automat.",
            ))
    elif cmd.action == "rebase":
        await _dispatch_rebase(ctx, cmd)
    elif cmd.action == "help":
        body = (
            "/status — stare FSM, uptime, ultima detecție\n"
            "/ss — screenshot acum\n"
            "/pause — oprește detecția (heartbeat continuă)\n"
            "/resume — reia detecția (șterge user-pause și drift-pause)\n"
            "/rebase — propune phash nou pentru canary (confirm cu /rebase confirm)\n"
            "/3 — screenshot automat la fiecare 3 min (sau orice număr)\n"
            "/stop — oprește screenshot-urile automate\n"
            "/window 19:40-21:45 — monitorizează doar în interval (ora locală, zilnic)\n"
            "/window off — dezactivează fereastra\n"
            "/h — acest mesaj"
        )
        ctx.notifier.send(Alert(kind="status", title="Comenzi ATM", body=body))


_REBASE_TTL_S = 180.0


async def _dispatch_rebase(ctx: RunContext, cmd) -> None:
    """/rebase flow: propose (no value) captures+hashes, /rebase confirm (value=1) applies.

    Propose stores (ts, new_phash, config_path) on ctx.pending_rebase. Confirm
    rewrites the active config's baseline_phash, clears user + drift pauses,
    mirrors the new phash into the in-memory cfg. TTL = 180s.
    """
    cfg = ctx.cfg
    confirm = cmd.value == 1

    if not confirm:
        now_r = time.time()
        if cfg.window_title:
            title = await asyncio.to_thread(_focus_window_by_title, cfg.window_title)
            ctx.audit.log({"ts": now_r, "event": "window_focused", "command": "rebase", "title": title})
            await asyncio.sleep(0.15)
        frame = await asyncio.to_thread(ctx.capture)
        if frame is None:
            ctx.notifier.send(Alert(
                kind="warn",
                title="Captură eșuată — /rebase anulat",
                body="Adu TradeStation în față și reîncearcă.",
            ))
            return

        from atm.vision import crop_roi, hamming_hex, phash as _phash
        try:
            roi_crop = crop_roi(frame, cfg.canary.roi)
        except Exception as exc:
            ctx.notifier.send(Alert(
                kind="warn",
                title="Crop canary ROI eșuat",
                body=f"{exc}",
            ))
            return
        new_phash = _phash(roi_crop)
        old_phash = cfg.canary.baseline_phash
        distance = hamming_hex(old_phash, new_phash)

        config_name = getattr(cfg, "config_version", None) or "unknown"
        if config_name in ("", "unknown"):
            ctx.notifier.send(Alert(
                kind="warn",
                title="Rebase imposibil — config necunoscut",
                body="cfg.config_version lipsă; rulează `atm calibrate` în loc.",
            ))
            return
        config_path = Path("configs") / f"{config_name}.toml"

        ctx.pending_rebase = (now_r, new_phash, config_path)
        path_r = await asyncio.to_thread(
            _save_rebase_frame, frame, cfg, ctx.fires_dir, now_r, ctx.audit,
        )
        ctx.audit.log({
            "ts": now_r, "event": "rebase_proposed",
            "old_phash": old_phash, "new_phash": new_phash,
            "distance": distance, "config": config_name,
            "screenshot_path": str(path_r) if path_r else None,
        })
        body = (
            f"old: {old_phash[:16]}…\n"
            f"new: {new_phash[:16]}…\n"
            f"distance: {distance} (threshold={cfg.canary.drift_threshold})\n"
            f"config: {config_name}\n\n"
            f"Trimite /rebase confirm în {int(_REBASE_TTL_S)}s pentru a aplica."
        )
        ctx.notifier.send(Alert(
            kind="screenshot",
            title="Canary rebase — confirmi?",
            body=body,
            image_path=path_r,
        ))
        return

    # confirm path
    pending = ctx.pending_rebase
    now_r = time.time()
    if pending is None:
        ctx.notifier.send(Alert(
            kind="warn",
            title="Nimic de confirmat",
            body="Trimite mai întâi /rebase pentru a propune un phash nou.",
        ))
        return
    proposed_ts, new_phash, config_path = pending
    if now_r - proposed_ts > _REBASE_TTL_S:
        ctx.pending_rebase = None
        ctx.notifier.send(Alert(
            kind="warn",
            title="Rebase expirat",
            body=f"Propunerea a depășit {int(_REBASE_TTL_S)}s. Trimite /rebase din nou.",
        ))
        return

    try:
        old_phash = await asyncio.to_thread(_rewrite_baseline_phash, config_path, new_phash)
    except Exception as exc:
        ctx.audit.log({
            "ts": now_r, "event": "rebase_failed",
            "error": str(exc), "config": str(config_path),
        })
        ctx.notifier.send(Alert(
            kind="warn",
            title="Rebase eșuat — config nemodificat",
            body=f"{exc}",
        ))
        ctx.pending_rebase = None
        return

    # CanaryRegion is a frozen dataclass — bypass the freeze to mirror the new
    # hash into the live cfg so the very next canary.check() uses it. Without
    # this, the TOML is updated but in-memory canary.baseline_phash stays stale
    # and drift re-pauses immediately after /rebase confirm.
    object.__setattr__(cfg.canary, "baseline_phash", new_phash)
    was_drift = bool(getattr(ctx.canary, "is_paused", False))
    was_user = bool(ctx.lifecycle.user_paused) if ctx.lifecycle is not None else False
    if ctx.lifecycle is not None:
        ctx.lifecycle.user_paused = False
    if was_drift:
        ctx.canary.resume()
    ctx.pending_rebase = None

    ctx.audit.log({
        "ts": now_r, "event": "rebase_applied",
        "old_phash": old_phash, "new_phash": new_phash,
        "config": str(config_path),
        "was_drift": was_drift, "was_user": was_user,
    })
    ctx.notifier.send(Alert(
        kind="status",
        title="Canary rebase aplicat",
        body=(
            f"new: {new_phash[:16]}…\n"
            f"config: {config_path.name}\n"
            f"Detecția repornită."
        ),
    ))


async def _drain_cmd_queue(ctx: RunContext) -> None:
    """Drain all pending commands, isolating each dispatch in try/except.

    CRITICAL: this MUST run every loop iteration, unconditionally, even when
    the detection tick returned nothing (canary paused, out-of-window, etc.).
    Prior bug: the main loop `continue`'d past this drain when res=None,
    causing commands to accumulate indefinitely while canary was drifted.
    """
    while True:
        try:
            cmd = ctx.cmd_queue.get_nowait()
        except asyncio.QueueEmpty:
            return
        try:
            await _dispatch_command(ctx, cmd)
        except Exception as exc:
            ctx.audit.log({
                "ts": time.time(), "event": "command_error",
                "action": cmd.action, "error": str(exc),
            })
            print(f"ERR command_dispatch /{cmd.action}: {exc}", flush=True)
            ctx.notifier.send(Alert(kind="warn", title=f"Eroare comandă /{cmd.action}", body=str(exc)))


def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
    """Sync entry point — delegates to asyncio event loop."""
    asyncio.run(run_live_async(cfg, duration_s=duration_s, capture_stub=capture_stub))


async def run_live_async(cfg, duration_s=None, capture_stub: bool = False) -> None:
    """Main live monitoring loop. Imports are lazy to keep --help fast."""
    try:
        from atm.detector import Detector
        from atm.canary import Canary
        from atm.levels import LevelsExtractor
        from atm.notifier.fanout import FanoutNotifier
        from atm.notifier.discord import DiscordNotifier
        from atm.notifier.telegram import TelegramNotifier
        from atm.audit import AuditLog
        from atm.commands import TelegramPoller, Command
        from atm.scheduler import ScreenshotScheduler
    except ImportError as exc:
        sys.exit(f"run-loop dependencies not available: {exc}")

    capture = _build_capture(cfg, capture_stub=capture_stub)
    detector = Detector(cfg, capture)
    fsm = StateMachine(lockout_s=cfg.lockout_s)
    audit = AuditLog(Path("logs"))

    # Forward-declare notifier so the canary pause callback can close over it.
    # The notifier is constructed a few lines below once backends exist.
    _notifier_ref: dict = {}

    def _on_canary_pause(distance: int) -> None:
        audit.log({"ts": time.time(), "event": "canary_drift_paused", "distance": distance})
        n = _notifier_ref.get("n")
        if n is not None:
            n.send(Alert(
                kind="warn",
                title=f"Canary drift={distance} — monitorizare pauzată",
                body=(
                    "Fereastra/paleta s-a schimbat. Trimite /resume pentru a relua "
                    "sau recalibrează."
                ),
            ))

    canary = Canary(
        cfg,
        pause_flag_path=Path("logs/pause.flag"),
        on_pause_callback=_on_canary_pause,
    )
    detection_log = AuditLog(Path("logs/detections"))
    backends = [
        DiscordNotifier(cfg.discord.webhook_url),
        TelegramNotifier(cfg.telegram.bot_token, cfg.telegram.chat_id),
    ]

    def _on_drop(backend_name: str, dropped: Alert) -> None:
        audit.log({
            "ts": time.time(),
            "event": "queue_overflow_drop",
            "backend": backend_name,
            "dropped_title": dropped.title,
            "dropped_kind": dropped.kind,
        })

    notifier = FanoutNotifier(backends, Path(cfg.dead_letter_path), on_drop=_on_drop)
    _notifier_ref["n"] = notifier

    # Initial frame + canary check
    first_frame = capture()
    if first_frame is None:
        print("WARN: first capture returned None — window/region missing", flush=True)
        canary_status = "capture_failed"
        senzor_info = "senzor: captură eșuată"
    else:
        first_check = canary.check(first_frame)
        canary_status = f"drift={first_check.distance}/{cfg.canary.drift_threshold}"
        if first_check.drifted:
            print(f"WARN: canary drift at startup ({canary_status}). Wrong window in front?", flush=True)
            # check() no longer auto-pauses; resume() kept defensively in case
            # an earlier startup path or a future change pauses before this.
            canary.resume()
        senzor_label = "deviat" if first_check.drifted else "ok"
        senzor_info = f"senzor: {senzor_label} ({canary_status.replace('drift=', '')})"

    dur_note = " dur=∞" if duration_s is None else f" dur={duration_s/3600:.2f}h"
    notifier.send(Alert(
        kind="heartbeat",
        title="ATM pornit",
        body=(
            f"cfg={cfg.config_version}{dur_note} @ {datetime.now().isoformat(timespec='seconds')}\n"
            f"{senzor_info}"
        ),
    ))
    audit.log({"event": "started", "config": cfg.config_version, "canary": canary_status})

    start = time.monotonic()
    heartbeat_due = time.monotonic() + cfg.heartbeat_min * 60
    # Dump raw frames direct în corpusul de calibrare — fără pas manual
    # de copy-paste din samples/. calibration_labels.json rămâne manual
    # (filename-ul folosește culoarea detectată, care poate fi greșită;
    # curarea labels.json e ce face validate-calibration truthful).
    samples_dir = Path("calibration") / "frames"
    samples_dir.mkdir(parents=True, exist_ok=True)
    fires_dir = Path("logs") / "fires"
    fires_dir.mkdir(parents=True, exist_ok=True)
    try:
        import cv2  # noqa: F401  fail fast if cv2 is missing  # type: ignore[import-untyped]
    except ImportError:
        pass

    cmd_queue: asyncio.Queue[Command] = asyncio.Queue()
    loop_state = _LoopState(first_accepted=True, last_saved_color=None,
                            levels_extractor=None, fire_count=0, start=start)

    def _bound_save(frame: Any, label: str, now: float) -> "Path | None":
        return _save_annotated_frame(frame, cfg, fires_dir, label, now, audit=audit)

    scheduler = ScreenshotScheduler(
        capture=capture,
        save_fn=_bound_save,
        notifier=notifier,
        audit=audit,
    )
    poller = TelegramPoller(cfg.telegram, cmd_queue, audit)

    lifecycle = LifecycleState()
    # Seed lifecycle.last_window_state with the current status so we don't emit
    # a spurious market_open alert on the very first tick (R2).
    _pre_skip = _should_skip(time.time(), lifecycle, cfg, canary)
    _maybe_log_transition(_pre_skip, lifecycle, time.time(), audit, notifier)

    ctx = RunContext(
        cfg=cfg, capture=capture, canary=canary, detector=detector, fsm=fsm,
        notifier=notifier, audit=audit, detection_log=detection_log,
        scheduler=scheduler, samples_dir=samples_dir, fires_dir=fires_dir,
        cmd_queue=cmd_queue, state=loop_state,
        levels_extractor_factory=lambda _cfg, trigger, now_ts: LevelsExtractor(_cfg, trigger, now_ts),
        lifecycle=lifecycle,
    )
    # ctx.charts is populated on the first detection tick by _run_multi_tick
    # which calls detect_strips → _commit_layout_change. Starting empty means
    # the real strip geometry is used (not a hardcoded cfg.dot_roi placeholder)
    # and avoids spurious layout-change alerts.
    ctx.charts = []

    # ------------------------------------------------------------------
    # Nested async coroutines — heartbeat captures notifier + heartbeat_due
    # ------------------------------------------------------------------

    async def _heartbeat_loop() -> None:
        nonlocal heartbeat_due
        while True:
            await asyncio.sleep(60)
            if time.monotonic() > heartbeat_due:
                in_window = _in_trading_window(time.time(), cfg)
                sw = ctx.lifecycle.session_window if ctx.lifecycle else None
                if sw is not None:
                    hhmm_local = datetime.fromtimestamp(time.time()).strftime("%H:%M")
                    if hhmm_local < sw[0] or hhmm_local >= sw[1]:
                        in_window = False
                if not in_window:
                    heartbeat_due = time.monotonic() + cfg.heartbeat_min * 60
                    continue
                uptime_h = (time.monotonic() - start) / 3600
                paused = bool(getattr(canary, "is_paused", False))
                notifier.send(_build_heartbeat_alert(
                    fsm_state=ctx.fsm.state.value,
                    fire_count=ctx.state.fire_count,
                    uptime_h=uptime_h,
                    canary_paused=paused,
                    charts=ctx.charts if len(ctx.charts) >= 2 else None,
                ))
                heartbeat_due = time.monotonic() + cfg.heartbeat_min * 60

    async def _detection_loop() -> None:
        while True:
            if duration_s is not None and (time.monotonic() - start) >= duration_s:
                break
            # Always use _run_multi_tick: it detects strips each tick,
            # manages layout changes (1→2 or 2→1), and handles single-chart
            # mode identically to the old _run_tick path.
            results = await _run_multi_tick(ctx)
            await _handle_fsm_result(ctx, results)
            await _drain_cmd_queue(ctx)  # UNCONDITIONAL — fix for command hang
            await asyncio.sleep(cfg.loop_interval_s)

    # Launch background tasks
    t_scheduler = asyncio.create_task(scheduler.run(), name="scheduler")
    t_poller = asyncio.create_task(poller.run(), name="poller")
    t_heartbeat = asyncio.create_task(_heartbeat_loop(), name="heartbeat")

    try:
        await _detection_loop()
    finally:
        # 7-step graceful shutdown
        # 1. cancel scheduler
        t_scheduler.cancel()
        with contextlib.suppress(asyncio.CancelledError, Exception):
            await t_scheduler
        # 2. cancel poller
        t_poller.cancel()
        with contextlib.suppress(asyncio.CancelledError, Exception):
            await t_poller
        # 3. cancel heartbeat
        t_heartbeat.cancel()
        with contextlib.suppress(asyncio.CancelledError, Exception):
            await t_heartbeat
        # 4. drain detection — complete (we awaited _detection_loop directly)
        # 5. send shutdown alert
        try:
            notifier.send(Alert(
                kind="heartbeat",
                title="ATM oprit",
                body=f"durată: {(time.monotonic()-start)/3600:.1f}h | semnale: {loop_state.fire_count}",
            ))
        except Exception:
            pass
        # 6. notifier.stop() — flush + join FanoutNotifier threads
        notifier.stop()
        # 7. audit.close()
        audit.close()
        detection_log.close()


def _build_capture(cfg, capture_stub: bool = False):
    """Return a capture callable ``() -> ndarray | None``."""
    use_stub = capture_stub or bool(os.environ.get("ATM_STUB_CAPTURE"))

    if use_stub:
        import itertools
        samples_dir = Path("calibration") / "frames"
        pngs = sorted(samples_dir.glob("*.png")) if samples_dir.exists() else []
        _cycle = itertools.cycle(pngs) if pngs else None

        def _stub_capture():
            if _cycle is None:
                return None
            p = next(_cycle)
            try:
                import cv2  # type: ignore[import-untyped]
                return cv2.imread(str(p))
            except ImportError:
                import numpy as np
                return np.zeros((100, 100, 3), dtype=np.uint8)

        return _stub_capture

    # Live path — prefer region-based capture if calibrate saved one
    try:
        import mss  # type: ignore[import-untyped]
    except ImportError as exc:
        sys.exit(
            f"Live screen capture requires 'mss' (Windows). "
            f"Use --capture-stub or set ATM_STUB_CAPTURE=1 for testing on Linux. "
            f"Missing: {exc}"
        )

    if cfg.chart_window_region is not None:
        reg = cfg.chart_window_region

        def _region_capture():
            with mss.mss() as sct:
                import cv2  # type: ignore[import-untyped]
                import numpy as np
                mon = {"top": reg.y, "left": reg.x, "width": reg.w, "height": reg.h}
                img = sct.grab(mon)
                frame = np.array(img)
                return cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR)

        return _region_capture

    # Legacy: window-title lookup via pygetwindow (unreliable for GPU-rendered apps;
    # kept as fallback when calibrate was run without region-select).
    try:
        import pygetwindow as gw  # type: ignore[import-untyped]
    except ImportError as exc:
        sys.exit(f"Fallback window capture needs pygetwindow: {exc}")

    def _live_capture():
        wins = gw.getWindowsWithTitle(cfg.window_title)
        if not wins:
            return None
        win = wins[0]
        with mss.mss() as sct:
            import cv2  # type: ignore[import-untyped]
            import numpy as np
            mon = {
                "top": win.top, "left": win.left,
                "width": win.width, "height": win.height,
            }
            img = sct.grab(mon)
            frame = np.array(img)
            return cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR)

    return _live_capture
