"""Paper trading in-process service layer.

Replaces subprocess-based task execution (paper_trading.py router).
Provides:
  - make_engine(): Engine factory (mirrors CLI _make_engine)
  - run_task(): Async task runner for engine operations
  - run_all_task(): Run daily for all active sessions
  - task registry: dict backed by asyncio.Lock
  - AutoScheduler: asyncio-based scheduler (mirrors auto.py)
"""
from __future__ import annotations

import asyncio
import datetime as dt
import json
import os
import sys
import traceback
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo

# ── Shared task registry ──────────────────────────────────────────────────────

_tasks: dict[str, dict[str, Any]] = {}
_tasks_lock = asyncio.Lock()

# Per-session locks prevent concurrent runs on the same session
_session_locks: dict[str, asyncio.Lock] = {}


def _get_session_lock(session_name: str) -> asyncio.Lock:
    if session_name not in _session_locks:
        _session_locks[session_name] = asyncio.Lock()
    return _session_locks[session_name]


# ── Engine factory ────────────────────────────────────────────────────────────

def make_engine(session: Any, db_path: str) -> Any:
    """Create a PaperTradingEngine for the given session.

    Mirrors CLI _make_engine(). Reads credentials from env vars.
    """
    from apps.paper_trader.alpaca_broker import AlpacaBroker
    from apps.paper_trader.engine import PaperTradingEngine
    from apps.paper_trader.event_detector import EventDetector
    from apps.paper_trader.state import StateManager
    from apps.backtester.run import load_manifest, resolve_config
    from apps.paper_trader.backtest_sim import load_snapshot_store_for_session

    broker = AlpacaBroker.from_env()
    oracle_url = (
        os.environ.get("ORACLE_URL")
        or os.environ.get("STOCK_ORACLE_URL", "http://localhost:8000")
    )
    db_dsn = os.environ.get("DB_DSN") or os.environ.get("POSTGRES_DSN", "")
    state = StateManager(db_path)
    detector = EventDetector(db_dsn=db_dsn, oracle_url=oracle_url)
    manifest = load_manifest(session.config_path)
    config = resolve_config(manifest)
    snapshot_store = load_snapshot_store_for_session(config, oracle_url, db_dsn)
    return PaperTradingEngine(
        session=session, broker=broker, state=state, event_detector=detector,
        snapshot_store=snapshot_store,
    )


# ── Log helpers ───────────────────────────────────────────────────────────────

def _fmt_header(operation: str, session_name: str) -> str:
    ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
    return f"[{ts}] {operation} — session: {session_name}\n{'─' * 60}\n"


def _fmt_summary(summary: dict[str, Any]) -> str:
    """Format engine return dict into human-readable text."""
    if not summary:
        return "[No summary returned]\n"
    lines: list[str] = []
    for key, value in summary.items():
        if value is None:
            continue
        if isinstance(value, (list, dict)) and not value:
            continue
        if key == "reconciliation":
            # ReconciliationReport dataclass
            for attr in ("orphaned_alpaca", "ghost_local", "reconciled_exits", "stale_orders_cancelled"):
                v = getattr(value, attr, None)
                if v:
                    lines.append(f"  {attr}: {v}")
            continue
        if isinstance(value, dt.date):
            lines.append(f"  {key}: {value.isoformat()}")
        else:
            lines.append(f"  {key}: {value}")
    return "\n".join(lines) + "\n" if lines else "[done]\n"


# ── In-process task runner ────────────────────────────────────────────────────

async def run_task(
    session: Any,
    operation: str,
    db_path: str,
    date: str | None = None,
    force: bool = False,
) -> dict[str, Any]:
    """Create a task and launch the engine operation in-process.

    Returns the initial task dict immediately; the engine runs as an
    asyncio background task.
    """
    task_id = str(uuid.uuid4())[:8]
    now = datetime.now(timezone.utc).isoformat()
    task: dict[str, Any] = {
        "task_id": task_id,
        "session_name": session.session_name,
        "operation": operation,
        "status": "running",
        "created_at": now,
        "started_at": now,
        "finished_at": None,
        "log": _fmt_header(operation, session.session_name),
        "error": None,
    }
    async with _tasks_lock:
        _tasks[task_id] = task

    asyncio.create_task(
        _execute_engine_task(task_id, session, operation, db_path, date, force)
    )
    return dict(task)


async def run_all_task(sessions: list[Any], db_path: str) -> dict[str, Any]:
    """Run run-daily for every given session as a single task."""
    task_id = str(uuid.uuid4())[:8]
    now = datetime.now(timezone.utc).isoformat()
    now_short = now[:19]
    task: dict[str, Any] = {
        "task_id": task_id,
        "session_name": "all",
        "operation": "run-all",
        "status": "running",
        "created_at": now,
        "started_at": now,
        "finished_at": None,
        "log": f"[{now_short} UTC] run-all — {len(sessions)} session(s)\n{'─' * 60}\n",
        "error": None,
    }
    async with _tasks_lock:
        _tasks[task_id] = task

    asyncio.create_task(_execute_run_all_task(task_id, sessions, db_path))
    return dict(task)


async def _execute_engine_task(
    task_id: str,
    session: Any,
    operation: str,
    db_path: str,
    date: str | None,
    force: bool,
) -> None:
    """Background coroutine: acquire session lock, run engine, update task."""
    target_date = dt.date.fromisoformat(date) if date else None

    async with _get_session_lock(session.session_name):
        extra_log, error = await asyncio.to_thread(
            _run_engine_sync, session, operation, db_path, target_date, force
        )

    finished = datetime.now(timezone.utc).isoformat()
    async with _tasks_lock:
        t = _tasks.get(task_id)
        if t:
            t["log"] = t.get("log", "") + extra_log
            t["finished_at"] = finished
            t["status"] = "failed" if error else "completed"
            t["error"] = error


def _run_engine_sync(
    session: Any,
    operation: str,
    db_path: str,
    target_date: dt.date | None,
    force: bool,
) -> tuple[str, str | None]:
    """Run the engine in a thread with its own event loop.

    Returns (log_text, error_message_or_None).
    """
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    try:
        engine = make_engine(session, db_path)
        if operation == "run":
            coro = engine.run_daily(target_date=target_date, force=force)
        elif operation == "run-close":
            coro = engine.run_reaction_close(target_date=target_date, force=force)
        elif operation == "run-open":
            coro = engine.run_next_open(target_date=target_date, force=force)
        else:
            return f"Unknown operation: {operation}\n", f"Unknown operation: {operation}"
        summary = loop.run_until_complete(coro)
        return _fmt_summary(summary), None
    except Exception as exc:
        tb = traceback.format_exc()
        return f"\nERROR: {type(exc).__name__}: {exc}\n{tb}\n", str(exc)
    finally:
        loop.close()


async def _execute_run_all_task(
    task_id: str,
    sessions: list[Any],
    db_path: str,
) -> None:
    """Background coroutine: run daily for each session sequentially."""
    any_error: str | None = None

    for session in sessions:
        header = f"\n▶ {session.session_name}\n"

        async with _tasks_lock:
            t = _tasks.get(task_id)
            if t:
                t["log"] = t.get("log", "") + header

        extra_log, error = await asyncio.to_thread(
            _run_engine_sync, session, "run", db_path, None, False
        )
        if error:
            any_error = error

        async with _tasks_lock:
            t = _tasks.get(task_id)
            if t:
                t["log"] = t.get("log", "") + extra_log

    finished = datetime.now(timezone.utc).isoformat()
    async with _tasks_lock:
        t = _tasks.get(task_id)
        if t:
            t["finished_at"] = finished
            t["status"] = "failed" if any_error else "completed"
            t["error"] = any_error


# ── Task registry accessors ───────────────────────────────────────────────────

def get_task(task_id: str) -> dict[str, Any] | None:
    return _tasks.get(task_id)


def get_task_log(task_id: str) -> dict[str, Any] | None:
    t = _tasks.get(task_id)
    if t is None:
        return None
    return {"log": t.get("log", ""), "status": t["status"]}


def list_tasks(session_name: str | None = None) -> list[dict[str, Any]]:
    tasks = list(_tasks.values())
    if session_name:
        tasks = [t for t in tasks if t.get("session_name") == session_name]
    tasks.sort(key=lambda t: t.get("created_at", ""), reverse=True)
    return tasks


# ── AutoScheduler ─────────────────────────────────────────────────────────────

_TZ_ET = ZoneInfo("America/New_York")

_SCHEDULE: list[dict[str, Any]] = [
    {"name": "pipeline_pre",  "et_hour": 7,  "et_min": 0,  "kind": "pipeline_pre",
     "label": "Pre-market pipeline"},
    {"name": "run_open",      "et_hour": 9,  "et_min": 35, "kind": "run_open",
     "label": "run-open (장 시작 직후)"},
    {"name": "run_close",     "et_hour": 15, "et_min": 45, "kind": "run_close",
     "label": "run-close (장 마감 직전)"},
    {"name": "pipeline_post", "et_hour": 16, "et_min": 30, "kind": "pipeline_post",
     "label": "Post-close pipeline"},
]

_PIPELINE_CMDS: list[list[str]] = [
    [sys.executable, "-m", "apps.pipeline.filing_poller.main"],
    [sys.executable, "-m", "apps.pipeline.filing_fetcher.main"],
    [sys.executable, "-m", "apps.pipeline.event_parser.main"],
    [sys.executable, "-m", "apps.pipeline.feature_builder.main"],
    [sys.executable, "-m", "apps.pipeline.label_generator.main"],
    [sys.executable, "-m", "apps.pipeline.label_generator.main",
     "--entry-convention", "reaction_close"],
]

_POST_PIPELINE_CMDS: list[list[str]] = [
    [sys.executable, "-m", "apps.pipeline.filing_poller.main"],
    [sys.executable, "-m", "apps.pipeline.filing_fetcher.main"],
    [sys.executable, "-m", "apps.pipeline.event_parser.main"],
    [sys.executable, "-m", "apps.pipeline.feature_builder.main"],
    [sys.executable, "-m", "apps.pipeline.label_generator.main",
     "--entry-convention", "reaction_close"],
    [sys.executable, "-m", "apps.pipeline.label_generator.main"],
]


def _state_file_path(db_path: str) -> Path:
    """Return path to the auto scheduler persistence file."""
    return Path(db_path).parent / ".paper_auto_state.json"


def load_saved_state(db_path: str) -> "dict[str, Any] | None":
    """Load previously persisted scheduler state. Returns None if absent/invalid."""
    sf = _state_file_path(db_path)
    if sf.exists():
        try:
            return json.loads(sf.read_text())
        except Exception:
            pass
    return None


class AutoScheduler:
    """In-process auto scheduler. Runs inside FastAPI; no separate daemon process needed.

    Pipeline steps remain subprocesses (via asyncio.create_subprocess_exec).
    Trading operations use PaperTradingEngine in-process.
    State is persisted to disk so FastAPI restart can auto-resume.
    """

    def __init__(self) -> None:
        self._task: asyncio.Task | None = None  # type: ignore[type-arg]
        self._sessions: list[str] = []
        self._dry_run: bool = False
        self._db_path: str = ""
        self._log_lines: list[str] = []
        self._completed: set[str] = set()

    # ── Public interface ───────────────────────────────────────────────────────

    @property
    def running(self) -> bool:
        return self._task is not None and not self._task.done()

    def start(self, sessions: list[str], dry_run: bool, db_path: str) -> None:
        if self.running:
            raise RuntimeError("AutoScheduler already running")
        self._sessions = sessions
        self._dry_run = dry_run
        self._db_path = db_path
        self._log_lines = []
        self._completed = set()
        self._save_state()
        self._task = asyncio.create_task(self._run_loop())

    def stop(self) -> None:
        """User-initiated stop. Cancels task and clears persisted state."""
        if self._task and not self._task.done():
            self._task.cancel()
        self._clear_state()

    def shutdown(self) -> None:
        """Server shutdown stop. Cancels task but keeps persisted state for auto-restart."""
        if self._task and not self._task.done():
            self._task.cancel()

    def _save_state(self) -> None:
        if not self._db_path:
            return
        try:
            _state_file_path(self._db_path).write_text(json.dumps({
                "running": True,
                "sessions": self._sessions,
                "dry_run": self._dry_run,
                "db_path": self._db_path,
            }))
        except Exception:
            pass

    def _clear_state(self) -> None:
        if not self._db_path:
            return
        try:
            sf = _state_file_path(self._db_path)
            if sf.exists():
                sf.unlink()
        except Exception:
            pass

    def get_log(self, lines: int = 200) -> str:
        return "\n".join(self._log_lines[-lines:])

    def get_log_tail(self, lines: int = 80) -> list[str]:
        return self._log_lines[-lines:]

    @property
    def log_line_count(self) -> int:
        return len(self._log_lines)

    # ── Helpers ────────────────────────────────────────────────────────────────

    def _log(self, msg: str) -> None:
        ts = datetime.now(timezone.utc).strftime("%H:%M UTC")
        self._log_lines.append(f"{ts}  {msg}")

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

    def _et_dt_for(self, date: dt.date, ev: dict[str, Any]) -> dt.datetime:
        return dt.datetime(
            date.year, date.month, date.day,
            ev["et_hour"], ev["et_min"], tzinfo=_TZ_ET,
        )

    def _is_trading_day(self, date: dt.date) -> bool:
        try:
            from libs.common.time_utils import is_trading_day
            return is_trading_day(date)
        except Exception:
            return date.weekday() < 5  # Mon–Fri fallback

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

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

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

    @staticmethod
    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"

    # ── Pipeline execution (subprocess) ───────────────────────────────────────

    async def _run_pipeline(self, cmds: list[list[str]]) -> None:
        project_root = str(Path(__file__).parent.parent.parent)
        for cmd in cmds:
            label = " ".join(cmd[2:] if cmd[:2] == [sys.executable, "-m"] else cmd)
            if self._dry_run:
                self._log(f"[DRY] {label}")
                continue
            self._log(f"  ▶ {label}")
            try:
                proc = await asyncio.create_subprocess_exec(
                    *cmd,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.STDOUT,
                    cwd=project_root,
                )
                stdout, _ = await proc.communicate()
                ok = proc.returncode == 0
                self._log(f"  {'OK' if ok else 'FAILED'} (exit {proc.returncode})")
                # Surface error-level log entries from pipeline JSON output
                for line in (stdout or b"").decode("utf-8", errors="replace").splitlines():
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        rec = json.loads(line)
                        if rec.get("level") in ("error", "critical"):
                            self._log(f"  ERROR: {rec.get('event', '')} {rec}")
                    except Exception:
                        pass
            except Exception as exc:
                self._log(f"  ERROR running {label}: {exc}")

    # ── Trading execution (in-process) ────────────────────────────────────────

    async def _run_trading(self, operation: str, sessions: list[str]) -> None:
        from apps.paper_trader.state import StateManager

        state_mgr = StateManager(self._db_path)
        for session_name in sessions:
            session = state_mgr.get_session(session_name)
            if session is None or session.status != "active":
                self._log(f"  {session_name}: skipped (not active)")
                continue
            if self._dry_run:
                self._log(f"  [DRY] {operation} — {session_name}")
                continue
            self._log(f"  ▶ {operation} — {session_name}")
            try:
                def _run_sync(s: Any = session) -> dict[str, Any]:
                    loop = asyncio.new_event_loop()
                    asyncio.set_event_loop(loop)
                    try:
                        engine = make_engine(s, self._db_path)
                        if operation == "run-open":
                            coro = engine.run_next_open()
                        elif operation == "run-close":
                            coro = engine.run_reaction_close()
                        else:
                            raise ValueError(f"Unknown operation: {operation}")
                        return loop.run_until_complete(coro)
                    finally:
                        loop.close()

                summary = await asyncio.to_thread(_run_sync)
                status = summary.get("status", "?")
                self._log(f"  ✓ {session_name}: {status}")
            except Exception as exc:
                self._log(f"  ERROR {session_name}: {type(exc).__name__}: {exc}")

    # ── Catch-up on startup ───────────────────────────────────────────────────

    async def _run_catchup(self) -> None:
        """Run missed pipeline steps on startup. Mirrors auto.py _run_catchup()."""
        now_et = self._now_et()
        today = now_et.date()
        catchup_items: list[tuple[str, list[list[str]]]] = []

        prev_td = self._prev_trading_day(today)
        prev_post_close_et = self._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})", _POST_PIPELINE_CMDS))

        if self._is_trading_day(today):
            pre_market_et = self._et_dt_for(today, _SCHEDULE[0])  # pipeline_pre 07:00
            if pre_market_et < now_et:
                catchup_items.append((f"Pre-market pipeline ({today})", _PIPELINE_CMDS))

        if not catchup_items:
            return

        self._log("━━━ Catch-up: 놓친 파이프라인 실행 ━━━")
        for label, cmds in catchup_items:
            self._log(f"▶ {label}")
            await self._run_pipeline(cmds)
        self._log("━━━ Catch-up 완료 ━━━")

    # ── Main scheduler loop ───────────────────────────────────────────────────

    async def _run_loop(self) -> None:
        """Main scheduler loop. Mirrors auto.py run_auto()."""
        resolved = self._sessions or self._get_active_sessions()
        if not resolved:
            self._log("No active sessions found. Stopping.")
            return

        self._log(f"Auto scheduler started — sessions: {', '.join(resolved)}")
        if self._dry_run:
            self._log("DRY RUN — commands will not execute")

        try:
            await self._run_catchup()
        except Exception as exc:
            self._log(f"Catch-up error: {exc}")

        last_schedule_date: dt.date | None = None

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

                # New day → reset completed set and refresh session list
                if last_schedule_date != today:
                    self._completed.clear()
                    last_schedule_date = today

                    # Re-read active sessions from DB so new sessions are picked up
                    # and removed/closed sessions are dropped automatically.
                    if not self._sessions:  # only auto-refresh if not pinned via --session
                        fresh = self._get_active_sessions()
                        if fresh != resolved:
                            added = set(fresh) - set(resolved)
                            removed = set(resolved) - set(fresh)
                            if added:
                                self._log(f"Sessions added: {', '.join(sorted(added))}")
                            if removed:
                                self._log(f"Sessions removed: {', '.join(sorted(removed))}")
                            resolved = fresh
                        if not resolved:
                            self._log("No active sessions. Waiting for next day.")

                    self._log(f"━━━ {today.strftime('%a %Y-%m-%d')} — sessions: {', '.join(resolved) or 'none'} ━━━")

                    if not self._is_trading_day(today):
                        next_td = self._next_trading_day(today)
                        self._log(f"Non-trading day. Next trading day: {next_td}")
                    else:
                        # Mark already-past events as skipped
                        for ev in _SCHEDULE:
                            if self._et_dt_for(today, ev) <= now_et:
                                self._completed.add(ev["name"])
                                self._log(f"Skipping past event: {ev['label']}")

                if not self._is_trading_day(today):
                    await asyncio.sleep(1800)
                    continue

                pending = [ev for ev in _SCHEDULE if ev["name"] not in self._completed]
                if not pending:
                    next_td = self._next_trading_day(today)
                    first = _SCHEDULE[0]
                    wake_et = self._et_dt_for(next_td, first)
                    wait = (wake_et - now_et).total_seconds()
                    self._log(
                        f"All done today. Sleeping until "
                        f"{wake_et.strftime('%I:%M %p ET')} on {next_td} "
                        f"— {self._fmt_countdown(wait)}"
                    )
                    await asyncio.sleep(min(wait, 3600))
                    continue

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

                if wait > 90:
                    self._log(
                        f"Next: {next_ev['label']} at "
                        f"{next_et.strftime('%I:%M %p ET')} "
                        f"— {self._fmt_countdown(wait)}"
                    )
                    # Re-check every 10 min at most
                    await asyncio.sleep(min(wait - 60, 600))
                    continue

                if wait > 0:
                    self._log(
                        f"Firing in {self._fmt_countdown(wait)}: {next_ev['label']}"
                    )
                    await asyncio.sleep(wait)

                # ── Execute event ──────────────────────────────────────────
                self._log(f"▶ {next_ev['label']}")
                try:
                    kind = next_ev["kind"]
                    if kind == "pipeline_pre":
                        await self._run_pipeline(_PIPELINE_CMDS)
                    elif kind == "run_open":
                        await self._run_trading("run-open", resolved)
                    elif kind == "run_close":
                        await self._run_trading("run-close", resolved)
                    elif kind == "pipeline_post":
                        await self._run_pipeline(_POST_PIPELINE_CMDS)
                except Exception as exc:
                    self._log(f"ERROR executing {next_ev['name']}: {exc}")

                self._completed.add(next_ev["name"])
                self._log(f"✓ Done: {next_ev['label']}")

        except asyncio.CancelledError:
            self._log("Auto scheduler stopped.")
            raise


# ── Module-level singleton ────────────────────────────────────────────────────

auto_scheduler = AutoScheduler()
