"""Paper trading auto daemon — runs on Phoenix (MST) timezone.

Watches the ET market schedule and automatically executes:

  07:00 ET  Pre-market pipeline  (filing_poller → label_generator, both conventions)
  09:35 ET  run-open             (exits + after-close entries at market open)
  15:45 ET  run-close            (same-day MOC entries before market close)
  16:30 ET  Post-close pipeline  (pending label regen + after-close labels)

Usage:
    python -m apps.paper_trader.auto --session v504_live
    python -m apps.paper_trader.auto            # all active sessions
    python -m apps.paper_trader.auto --dry-run  # print without executing
"""
from __future__ import annotations

import argparse
import datetime as dt
import json
import os
import subprocess
import sys
import threading
import time as _time
from dataclasses import dataclass, field
from zoneinfo import ZoneInfo

from rich import box as rbox
from rich.console import Console
from rich.panel import Panel
from rich.table import Table

_TZ_ET  = ZoneInfo("America/New_York")
_TZ_PHX = ZoneInfo("America/Phoenix")  # MST, UTC-7, no DST
_console = Console(width=120)

_DEFAULT_DB = os.environ.get("PAPER_TRADER_DB", "paper_trading.db")

# How often (seconds) to reprint the countdown while waiting
_STATUS_INTERVAL = 600  # 10 min


# ------------------------------------------------------------------ #
# Schedule definition
# ------------------------------------------------------------------ #

@dataclass
class ScheduledEvent:
    name: str
    et_hour: int
    et_min: int
    description: str
    kind: str  # pipeline_pre | run_open | run_close | pipeline_post


SCHEDULE: list[ScheduledEvent] = [
    ScheduledEvent("pipeline_pre",   7,  0,
                   "Pre-market pipeline  (poller → parser → label_gen both conventions)",
                   "pipeline_pre"),
    ScheduledEvent("run_open",       9, 35,
                   "run-open   — exits + after-close entries at market open",
                   "run_open"),
    ScheduledEvent("run_close",     15, 45,
                   "run-close  — same-day MOC entries (Alpaca cutoff 3:45 PM ET)",
                   "run_close"),
    ScheduledEvent("pipeline_post", 16, 30,
                   "Post-close pipeline  — pending label regen + after-close labels",
                   "pipeline_post"),
]

# Pipeline commands (in execution order)
_PIPELINE_CMDS = [
    ["python", "-m", "apps.pipeline.filing_poller.main"],
    ["python", "-m", "apps.pipeline.filing_fetcher.main"],
    ["python", "-m", "apps.pipeline.event_parser.main"],
    ["python", "-m", "apps.pipeline.feature_builder.main"],
    ["python", "-m", "apps.pipeline.label_generator.main"],
    ["python", "-m", "apps.pipeline.label_generator.main",
     "--entry-convention", "reaction_close"],
]

_POST_PIPELINE_CMDS = [
    ["python", "-m", "apps.pipeline.filing_poller.main"],
    ["python", "-m", "apps.pipeline.filing_fetcher.main"],
    ["python", "-m", "apps.pipeline.event_parser.main"],
    ["python", "-m", "apps.pipeline.feature_builder.main"],
    # reaction_close first (regenerates pending labels with final close price)
    ["python", "-m", "apps.pipeline.label_generator.main",
     "--entry-convention", "reaction_close"],
    # then next_open labels for today's after-close events
    ["python", "-m", "apps.pipeline.label_generator.main"],
]


# ------------------------------------------------------------------ #
# Time helpers
# ------------------------------------------------------------------ #

def _now_et() -> dt.datetime:
    return dt.datetime.now(tz=_TZ_ET)

def _now_phx() -> dt.datetime:
    return dt.datetime.now(tz=_TZ_PHX)

def _to_phx_str(et_dt: dt.datetime) -> str:
    return et_dt.astimezone(_TZ_PHX).strftime("%I:%M %p")

def _fmt_countdown(seconds: float) -> str:
    if seconds <= 0:
        return "now"
    h = int(seconds // 3600)
    m = int((seconds % 3600) // 60)
    s = int(seconds % 60)
    if h > 0:
        return f"{h}h {m:02d}m"
    if m > 0:
        return f"{m}m {s:02d}s"
    return f"{s}s"

def _et_dt_for(date: dt.date, event: ScheduledEvent) -> dt.datetime:
    return dt.datetime(date.year, date.month, date.day,
                       event.et_hour, event.et_min, tzinfo=_TZ_ET)

def _is_trading_day(date: dt.date) -> bool:
    try:
        from libs.common.time_utils import is_trading_day
        return is_trading_day(date)
    except Exception:
        # Fallback: Mon–Fri excluding obvious US holidays
        return date.weekday() < 5

def _next_trading_day(from_date: dt.date) -> dt.date:
    check = from_date + dt.timedelta(days=1)
    for _ in range(14):
        if _is_trading_day(check):
            return check
        check += dt.timedelta(days=1)
    raise RuntimeError("No trading day found within 14 days")

def _prev_trading_day(from_date: dt.date) -> dt.date:
    check = from_date - dt.timedelta(days=1)
    for _ in range(14):
        if _is_trading_day(check):
            return check
        check -= dt.timedelta(days=1)
    raise RuntimeError("No previous trading day found within 14 days")


# ------------------------------------------------------------------ #
# Catch-up: run missed pipeline steps on startup
# ------------------------------------------------------------------ #

def _run_catchup(now_et: dt.datetime, dry_run: bool) -> None:
    """자동으로 놓친 파이프라인 단계를 시작 시 실행.

    파이프라인은 멱등성이 있어 재실행 안전 (기존 레코드는 skip).
    트레이딩 커맨드(run-open/run-close)는 타이밍이 중요해 catch-up 제외.
    """
    today = now_et.date()

    catchup_items: list[tuple[str, str, list[list[str]]]] = []
    # (label, reason, cmds)

    # 1. 직전 거래일의 post-close pipeline (16:30 ET)이 안 돌았을 가능성
    #    → 오늘 장마감 후 이벤트 라벨링이 안 됨 → 다음 run-open 준비 안 됨
    prev_td = _prev_trading_day(today)
    prev_post_close_et = _et_dt_for(prev_td, SCHEDULE[3])  # pipeline_post = 16:30
    if prev_post_close_et < now_et:
        catchup_items.append((
            f"Post-close pipeline ({prev_td})",
            f"직전 거래일 16:30 ET 파이프라인 — 오늘 run-open 후보 준비",
            _POST_PIPELINE_CMDS,
        ))

    # 2. 오늘이 거래일이고 pre-market pipeline (07:00 ET) 시간이 지났으면
    if _is_trading_day(today):
        pre_market_et = _et_dt_for(today, SCHEDULE[0])  # pipeline_pre = 07:00
        if pre_market_et < now_et:
            catchup_items.append((
                f"Pre-market pipeline ({today})",
                f"오늘 07:00 ET 파이프라인 — 새벽 공시 라벨링",
                _PIPELINE_CMDS,
            ))

    if not catchup_items:
        return

    _console.print("\n[bold yellow]━━━ Catch-up: 놓친 파이프라인 실행 ━━━[/]")
    for label, reason, cmds in catchup_items:
        _console.print(f"\n[yellow]▶ {label}[/]  [dim]{reason}[/]")
        _run_pipeline(cmds, dry_run)
    _console.print("[bold yellow]━━━ Catch-up 완료 ━━━[/]\n")


# ------------------------------------------------------------------ #
# Subprocess helpers
# ------------------------------------------------------------------ #

def _run_cmd(cmd: list[str], dry_run: bool, pipeline: bool = False) -> bool:
    label = " ".join(cmd[2:] if cmd[:2] == ["python", "-m"] else cmd)
    if dry_run:
        _console.print(f"  [dim][DRY] {label}[/]")
        return True
    _console.print(f"  [dim]▶ {label}[/]", end="")
    try:
        result = subprocess.run(
            cmd, check=False,
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
        )
        ok = result.returncode == 0

        if pipeline:
            # Pipeline commands: parse JSON logs, show only summary + errors
            summary_parts: list[str] = []
            error_lines: list[str] = []
            for line in (result.stdout or "").splitlines():
                line = line.strip()
                if not line:
                    continue
                try:
                    rec = json.loads(line)
                    level = rec.get("level", "")
                    event = rec.get("event", "")
                    if level in ("error", "critical"):
                        error_lines.append(f"[red]    {event}: {rec}[/]")
                    elif event.endswith("_done"):
                        # Extract key counts from the summary event
                        parts = [f"{k}={v}" for k, v in rec.items()
                                 if k not in ("event", "level", "timestamp", "job_run_id")]
                        summary_parts.append(f"{event}({', '.join(parts)})")
                except (json.JSONDecodeError, ValueError):
                    pass  # httpx HTTP Request lines and other non-JSON: silently skip

            _console.print(f"  {'[green]OK[/]' if ok else '[red]FAILED[/]'}")
            if summary_parts:
                _console.print(f"    [dim]{' | '.join(summary_parts)}[/]")
            for err in error_lines:
                _console.print(err)
        else:
            # Non-pipeline commands (paper run-open, run-close): pass through output
            _console.print(f"  {'[green]OK[/]' if ok else '[red]FAILED[/]'}")
            if result.stdout:
                _console.print(result.stdout.rstrip())

        if not ok:
            _console.print(f"  [red]  ↳ FAILED (exit {result.returncode})[/]")
        return ok
    except Exception as exc:
        _console.print(f"\n  [red]  ↳ ERROR: {exc}[/]")
        return False


def _run_pipeline(cmds: list[list[str]], dry_run: bool) -> None:
    for cmd in cmds:
        _run_cmd(cmd, dry_run, pipeline=True)


def _run_paper(command: str, sessions: list[str], db: str, dry_run: bool) -> None:
    for session in sessions:
        cmd = ["python", "-m", "apps.paper_trader.cli", command,
               "--session", session, "--db", db]
        _run_cmd(cmd, dry_run)


# ------------------------------------------------------------------ #
# Display
# ------------------------------------------------------------------ #

def _print_header(sessions: list[str]) -> None:
    now_phx = _now_phx()
    now_et  = _now_et()
    _console.print()
    _console.print(Panel(
        f"[bold cyan]fithia2 paper auto[/]  |  "
        f"PHX [bold]{now_phx.strftime('%I:%M %p MST')}[/]  "
        f"ET [bold]{now_et.strftime('%I:%M %p %Z')}[/]\n"
        f"[dim]Sessions: {', '.join(sessions)}[/]",
        border_style="cyan",
        padding=(0, 2),
    ))


def _print_schedule(now_et: dt.datetime, completed: set[str]) -> None:
    today = now_et.date()
    is_td = _is_trading_day(today)

    tbl = Table(box=rbox.SIMPLE, show_header=True, header_style="bold yellow",
                padding=(0, 2), expand=False)
    tbl.add_column("ET",    style="bold", no_wrap=True)
    tbl.add_column("PHX",   style="dim",  no_wrap=True)
    tbl.add_column("Action")
    tbl.add_column("",      no_wrap=True)

    for ev in SCHEDULE:
        et_dt  = _et_dt_for(today, ev)
        phx_str = _to_phx_str(et_dt)
        et_str  = et_dt.strftime("%I:%M %p")

        if ev.name in completed:
            marker = "[green]✓ done[/]"
        elif et_dt <= now_et:
            marker = "[dim]skipped[/]"
        else:
            secs = (et_dt - now_et).total_seconds()
            marker = f"[dim]in {_fmt_countdown(secs)}[/]"

        tbl.add_row(et_str, phx_str, ev.description, marker)

    day_str = f"[bold]{today.strftime('%a %Y-%m-%d')}[/]"
    td_str  = "[green]Trading Day[/]" if is_td else "[red]Non-Trading Day[/]"
    _console.print(f"\nSchedule  {day_str}  {td_str}")
    _console.print(tbl)


def _log(msg: str) -> None:
    ts = _now_phx().strftime("%H:%M PHX")
    _console.print(f"[dim]{ts}[/]  {msg}")


# ------------------------------------------------------------------ #
# Main daemon loop
# ------------------------------------------------------------------ #

def _get_active_sessions(db: str) -> list[str]:
    try:
        from apps.paper_trader.state import StateManager
        return [s.session_name for s in StateManager(db).list_sessions()
                if s.status == "active"]
    except Exception:
        return []


def run_auto(sessions: list[str], db: str, dry_run: bool) -> None:
    resolved = sessions or _get_active_sessions(db)
    if not resolved:
        _console.print(f"[red]No active sessions in '{db}'. Use --session NAME or create a session first.[/]")
        sys.exit(1)

    _console.print(f"\n[bold cyan]fithia2 paper auto[/]  sessions: [bold]{', '.join(resolved)}[/]")
    if dry_run:
        _console.print("[yellow]DRY RUN — commands will not execute[/]")
    _console.print("Ctrl+C to stop.\n")

    # 시작 시 놓친 파이프라인 자동 catch-up (background — 메인 스케줄 루프 블로킹 방지)
    _catchup_thread = threading.Thread(
        target=_run_catchup, args=(_now_et(), dry_run), daemon=True, name="catchup"
    )
    _catchup_thread.start()

    completed: set[str] = set()
    last_schedule_date: dt.date | None = None
    last_status_print: float = 0.0

    try:
        while True:
            now_et  = _now_et()
            today   = now_et.date()

            # New day → reset
            if last_schedule_date != today:
                completed.clear()
                last_schedule_date = today
                last_status_print = 0.0
                _print_header(resolved)

                if not _is_trading_day(today):
                    next_td = _next_trading_day(today)
                    _log(f"Non-trading day. Next trading day: [bold]{next_td}[/]")
                else:
                    # Mark events that already passed when starting mid-day as skipped
                    for ev in SCHEDULE:
                        if _et_dt_for(today, ev) <= now_et:
                            completed.add(ev.name)
                            _log(f"[dim]Skipping past event: {ev.description}[/]")

                _print_schedule(now_et, completed)

            if not _is_trading_day(today):
                _time.sleep(1800)  # 30 min; loop will recheck
                continue

            # Find next pending event
            pending = [ev for ev in SCHEDULE if ev.name not in completed]

            if not pending:
                # All done today → sleep until tomorrow's first event
                next_td = _next_trading_day(today)
                first   = SCHEDULE[0]
                wake_et = _et_dt_for(next_td, first)
                wait    = (wake_et - now_et).total_seconds()
                _log(f"[green]All done for today.[/] Sleeping until [bold]{wake_et.strftime('%I:%M %p ET')} "
                     f"({_to_phx_str(wake_et)} PHX)[/] on {next_td} "
                     f"— {_fmt_countdown(wait)}")
                _time.sleep(min(wait, 3600))
                continue

            next_ev = pending[0]
            next_et = _et_dt_for(today, next_ev)
            wait    = (next_et - now_et).total_seconds()

            if wait > 90:
                # Periodic status line every _STATUS_INTERVAL seconds
                now_mono = _time.monotonic()
                if now_mono - last_status_print >= _STATUS_INTERVAL:
                    _log(f"Next: [bold]{next_ev.description}[/]  "
                         f"at {next_et.strftime('%I:%M %p ET')} ({_to_phx_str(next_et)} PHX)  "
                         f"— {_fmt_countdown(wait)}")
                    last_status_print = now_mono
                _time.sleep(min(wait - 60, _STATUS_INTERVAL))
                continue

            # ≤ 90s away — wait out remainder
            if wait > 0:
                _log(f"[yellow]Firing in {_fmt_countdown(wait)}: {next_ev.description}[/]")
                _time.sleep(wait)

            # ── Execute ──────────────────────────────────────────────
            now_phx_str = _now_phx().strftime("%H:%M PHX")
            _console.print(f"\n{'─'*60}")
            _console.print(f"[bold green]{now_phx_str}  ▶ {next_ev.description}[/]")
            _console.print(f"{'─'*60}")

            if next_ev.kind == "pipeline_pre":
                _run_pipeline(_PIPELINE_CMDS, dry_run)

            elif next_ev.kind == "run_open":
                _run_paper("run-open", resolved, db, dry_run)

            elif next_ev.kind == "run_close":
                _run_paper("run-close", resolved, db, dry_run)

            elif next_ev.kind == "pipeline_post":
                _run_pipeline(_POST_PIPELINE_CMDS, dry_run)

            completed.add(next_ev.name)
            last_status_print = 0.0  # reprint schedule next status line
            done_str = _now_phx().strftime("%H:%M PHX")
            _console.print(f"[green]✓ Done ({done_str})[/]")
            _print_schedule(_now_et(), completed)

    except KeyboardInterrupt:
        _console.print("\n[yellow]Auto daemon stopped.[/]")


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

def main() -> None:
    parser = argparse.ArgumentParser(
        prog="fithia2-paper-auto",
        description="Paper trading auto daemon (Phoenix/MST timezone)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
ET schedule (runs every trading day):
  07:00  Pre-market pipeline   (filing_poller → label_generator, both conventions)
  09:35  run-open              (exits + after-close entries at market open)
  15:45  run-close             (same-day MOC entries, Alpaca cutoff 3:45 PM ET)
  16:30  Post-close pipeline   (pending label regen + after-close labels)

Examples:
  python -m apps.paper_trader.auto --session v504_live
  python -m apps.paper_trader.auto                    # all active sessions
  python -m apps.paper_trader.auto --dry-run --session v504_live
""",
    )
    parser.add_argument(
        "--session", "-s", nargs="*", dest="session", default=[],
        metavar="NAME",
        help="Session name(s). Default: all active sessions.",
    )
    parser.add_argument(
        "--db", default=_DEFAULT_DB, metavar="PATH",
        help=f"SQLite DB path (default: {_DEFAULT_DB} or $PAPER_TRADER_DB)",
    )
    parser.add_argument(
        "--dry-run", action="store_true",
        help="Show what would run without executing.",
    )
    args = parser.parse_args()
    run_auto(sessions=args.session, db=args.db, dry_run=args.dry_run)


if __name__ == "__main__":
    main()
