#!/usr/bin/env python3
"""
journalctl / 로그 파일에서 조건검색 유니버스 스냅샷을 재구축해
``target_candidates_history`` 를 덮어씁니다.

배경:
  - ``ranking_manager`` 와 ``condition_manager`` 가 같은 strategy_id 로 저장하면서
    실매(cond)와 다른 유니버스가 DB에 섞임.
  - 저장 시 ``sorted(codes)`` 로 HTS 응답 순서가 깨짐.

사용:
  # 6/24 MOMENTUM cond 로그만 재구축 (dry-run)
  python -m kis_trader.scripts.rebuild_universe_history_from_logs \\
      --start 2026-06-24 --end 2026-06-24 --strategy MOMENTUM --dry-run

  # 실제 반영
  python -m kis_trader.scripts.rebuild_universe_history_from_logs \\
      --start 2026-06-24 --end 2026-06-24 --strategy MOMENTUM --apply

  # 로그 파일 지정 (journal 대신)
  python -m kis_trader.scripts.rebuild_universe_history_from_logs \\
      --log-file /path/to.log --start 2026-06-24 --strategy MOMENTUM --apply
"""
from __future__ import annotations

import argparse
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Dict, Iterable, List, Optional, Sequence, Tuple

# 패키지 실행 보정
if __package__ in (None, ""):
    import os
    _ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    if _ROOT not in sys.path:
        sys.path.insert(0, _ROOT)


JOURNAL_LINE_RE = re.compile(
    r"^(?P<month>\w{3})\s+(?P<day>\d{1,2})\s+"
    r"(?P<hm>\d{2}:\d{2}:\d{2})\s+"
)
CHANGE_RE = re.compile(
    r"\[(?P<hm>\d{2}:\d{2}:\d{2})\]\s+"
    r"\[(?P<logger>kis_trader\.(?:cond|rank))\]\s+"
    r"🔄\s+\[(?P<sid>[A-Z_]+)\]\s+"
    r"\+(?P<enter_n>\d+)\s+/\s+-(?P<exit_n>\d+)\s+"
    r"\(현재\s+(?P<count>\d+)종목"
)
ENTER_RE = re.compile(r"ENTER:\s*(.+?)\s*$")
EXIT_RE = re.compile(r"EXIT\s*:\s*(.+?)\s*$")
CODE_NAME_RE = re.compile(r"(\d{6})\(([^)]*)\)")
CODE_ONLY_RE = re.compile(r"\b(\d{6})\b")


@dataclass
class UniverseEvent:
    event_time: str  # YYYY-MM-DD HH:MM:SS
    logger: str
    strategy_id: str
    enter_n: int
    exit_n: int
    count: int
    enter_codes: List[Tuple[str, str]] = field(default_factory=list)
    exit_codes: List[str] = field(default_factory=list)
    enter_partial: bool = False
    exit_partial: bool = False


def _month_map() -> Dict[str, int]:
    return {
        "Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6,
        "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12,
    }


def _journal_ts(year: int, month_s: str, day: int, hm: str) -> str:
    mo = _month_map().get(month_s, 1)
    return f"{year:04d}-{mo:02d}-{int(day):02d} {hm}"


def _parse_code_tokens(fragment: str) -> Tuple[List[Tuple[str, str]], List[str], bool]:
    """ENTER 줄 파싱 → (code,name), exit codes, partial."""
    partial = "…" in fragment or "..." in fragment
    fragment = fragment.replace("…", "").replace("...", "").strip().rstrip(",")
    named: List[Tuple[str, str]] = []
    for m in CODE_NAME_RE.finditer(fragment):
        named.append((m.group(1), m.group(2) or m.group(1)))
    codes_only: List[str] = []
    if not named:
        for m in CODE_ONLY_RE.finditer(fragment):
            codes_only.append(m.group(1))
    return named, codes_only, partial


def _parse_exit_tokens(fragment: str) -> Tuple[List[str], bool]:
    partial = "…" in fragment or "..." in fragment
    fragment = fragment.replace("…", "").replace("...", "").strip().rstrip(",")
    codes = CODE_ONLY_RE.findall(fragment)
    return codes, partial


def iter_log_lines(
    *,
    start: str,
    end: str,
    log_file: Optional[str],
) -> Iterable[Tuple[str, str]]:
    """(event_time_str, message_body)"""
    if log_file:
        with open(log_file, "r", encoding="utf-8", errors="replace") as f:
            for raw in f:
                yield "", raw.rstrip("\n")
        return

    start_dt = f"{start} 00:00:00"
    end_dt = (datetime.strptime(end, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d 00:00:00")
    cmd = [
        "journalctl",
        "--since", start_dt,
        "--until", end_dt,
        "-o", "short-iso",
        "--no-pager",
    ]
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    assert proc.stdout is not None
    year = int(start[:4])
    for line in proc.stdout:
        line = line.rstrip("\n")
        if not line:
            continue
        # ISO: 2026-06-24T09:00:12+0900 ...
        if line[0:4].isdigit() and "T" in line[:20]:
            try:
                iso = line.split(" ", 1)[0]
                dt_part = iso.replace("T", " ")[:19]
                msg = line.split(" ", 1)[1] if " " in line else ""
                yield dt_part, msg
                continue
            except Exception:
                pass
        m = JOURNAL_LINE_RE.match(line)
        if not m:
            continue
        hm = m.group("hm")
        ts = _journal_ts(year, m.group("month"), int(m.group("day")), hm)
        msg = line[m.end():].strip()
        yield ts, msg
    proc.wait()


def parse_cond_events(
    lines: Iterable[Tuple[str, str]],
    *,
    strategy_id: str,
    logger_filter: str = "kis_trader.cond",
) -> List[UniverseEvent]:
    sid = strategy_id.upper()
    events: List[UniverseEvent] = []
    cur: Optional[UniverseEvent] = None

    def flush() -> None:
        nonlocal cur
        if cur is not None:
            events.append(cur)
            cur = None

    for ts_journal, msg in lines:
        ch = CHANGE_RE.search(msg)
        if ch:
            if ch.group("sid").upper() != sid:
                continue
            logger = ch.group("logger")
            if logger != logger_filter:
                continue
            flush()
            et = ts_journal or ch.group("hm")
            if len(et) <= 8:
                et = f"{datetime.now():%Y-%m-%d} {et}"
            elif "T" in et:
                et = et.replace("T", " ")[:19]
            cur = UniverseEvent(
                event_time=et[:19],
                logger=logger,
                strategy_id=sid,
                enter_n=int(ch.group("enter_n")),
                exit_n=int(ch.group("exit_n")),
                count=int(ch.group("count")),
            )
            continue

        if cur is None:
            continue
        if f"[{logger_filter}]" not in msg and "kis_trader.cond" not in msg:
            continue

        em = ENTER_RE.search(msg)
        if em:
            named, codes_only, partial = _parse_code_tokens(em.group(1))
            for c, n in named:
                cur.enter_codes.append((c, n))
            for c in codes_only:
                cur.enter_codes.append((c, c))
            cur.enter_partial = cur.enter_partial or partial
            continue

        xm = EXIT_RE.search(msg)
        if xm:
            codes, partial = _parse_exit_tokens(xm.group(1))
            cur.exit_codes.extend(codes)
            cur.exit_partial = cur.exit_partial or partial

    flush()
    return events


def load_db_snapshot_index(
    db,
    *,
    strategy_id: str,
    start: str,
    end: str,
) -> Dict[str, List[Tuple[str, str]]]:
    """event_time → [(code, name), ...] insert(id) 순."""
    start_t = f"{start} 00:00:00"
    end_t = f"{end} 23:59:59"
    rows = db.conn.execute(
        """
        SELECT event_time, code, name, id
        FROM target_candidates_history
        WHERE strategy_id=%s
          AND event_time BETWEEN %s AND %s
        ORDER BY event_time, id
        """,
        (strategy_id, start_t, end_t),
    ).fetchall()
    out: Dict[str, List[Tuple[str, str]]] = {}
    for r in rows:
        et = str(r["event_time"])[:19]
        out.setdefault(et, []).append((str(r["code"]), str(r.get("name") or r["code"])))
    return out


def rebuild_ordered_universe(
    events: Sequence[UniverseEvent],
    db_index: Dict[str, List[Tuple[str, str]]],
) -> List[Tuple[str, List[Tuple[str, str]]]]:
    """
    로그 ENTER/EXIT 로 순서 복원. 멤버십 불일치 시 기존 DB 스냅샷으로 보정.
  """
    state: List[str] = []
    names: Dict[str, str] = {}
    out: List[Tuple[str, List[Tuple[str, str]]]] = []

    for ev in events:
        for c in ev.exit_codes:
            if c in state:
                state.remove(c)
        for c, n in ev.enter_codes:
            if c not in state:
                state.append(c)
            names[c] = n or names.get(c, c)

        db_rows = db_index.get(ev.event_time) or []
        db_codes = [c for c, _ in db_rows]
        db_set = set(db_codes)

        if len(state) != ev.count or set(state) != db_set:
            # 멤버십: DB(당시 저장본) 우선, 순서: 로그 state 우선
            merged: List[str] = []
            seen: set = set()
            for c in state:
                if c in db_set and c not in seen:
                    merged.append(c)
                    seen.add(c)
            for c in db_codes:
                if c not in seen:
                    merged.append(c)
                    seen.add(c)
            if db_codes and set(merged) != db_set:
                merged = list(db_codes)
            state = merged
            for c, n in db_rows:
                if n:
                    names[c] = n

        items = [(c, names.get(c, c)) for c in state]
        out.append((ev.event_time, items))

    return out


def apply_snapshots(
    db,
    *,
    strategy_id: str,
    start: str,
    end: str,
    snapshots: Sequence[Tuple[str, List[Tuple[str, str]]]],
    dry_run: bool,
) -> Dict[str, int]:
    start_t = f"{start} 00:00:00"
    end_t = f"{end} 23:59:59"
    stats = {"deleted_events": 0, "inserted_rows": 0, "snapshots": len(snapshots)}

    if dry_run:
        return stats

    from kis_trader.database.db_manager import get_db
    ext = get_db()

    with ext.conn:
        del_row = ext.conn.execute(
            """
            SELECT COUNT(DISTINCT event_time) AS c FROM target_candidates_history
            WHERE strategy_id=%s AND event_time BETWEEN %s AND %s
            """,
            (strategy_id, start_t, end_t),
        ).fetchone()
        stats["deleted_events"] = int((del_row or {}).get("c") or 0)

        ext.conn.execute(
            """
            DELETE FROM target_candidates_history
            WHERE strategy_id=%s AND event_time BETWEEN %s AND %s
            """,
            (strategy_id, start_t, end_t),
        )

        for event_time, items in snapshots:
            payload = [{"code": c, "name": n} for c, n in items if c]
            stats["inserted_rows"] += ext.insert_condition_universe_snapshot(
                strategy_id=strategy_id,
                event_time=event_time,
                items=payload,
            )
    return stats


def main(argv: Optional[Sequence[str]] = None) -> int:
    ap = argparse.ArgumentParser(description="로그 → target_candidates_history 재구축")
    ap.add_argument("--start", required=True, help="YYYY-MM-DD")
    ap.add_argument("--end", default=None, help="YYYY-MM-DD (기본=start)")
    ap.add_argument("--strategy", default="MOMENTUM")
    ap.add_argument("--logger", default="kis_trader.cond",
                    help="유니버스 소스 로거 (기본 cond=조건검색)")
    ap.add_argument("--log-file", default=None, help="journal 대신 파일")
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--apply", action="store_true")
    args = ap.parse_args(argv)

    if not args.dry_run and not args.apply:
        print("⚠️  --dry-run 또는 --apply 중 하나를 지정하세요.")
        return 2

    end = args.end or args.start
    print(f"📖 로그 파싱 {args.start} ~ {end} | strategy={args.strategy} | logger={args.logger}")

    lines = list(iter_log_lines(start=args.start, end=end, log_file=args.log_file))
    print(f"   총 로그 줄: {len(lines):,}")

    events = parse_cond_events(lines, strategy_id=args.strategy, logger_filter=args.logger)
    print(f"   유니버스 변동 이벤트: {len(events):,}")

    if not events:
        print("❌ 이벤트 없음 — journal 권한/기간/전략을 확인하세요.")
        return 1

    from database import TradeDB
    db = TradeDB()
    try:
        db_index = load_db_snapshot_index(
            db, strategy_id=args.strategy.upper(), start=args.start, end=end,
        )
        print(f"   기존 DB 스냅샷(보정용): {len(db_index):,}")

        snapshots = rebuild_ordered_universe(events, db_index)

        partial_n = sum(1 for e in events if e.enter_partial or e.exit_partial)
        mismatches = 0
        for ev, (_, items) in zip(events, snapshots):
            if len(items) != ev.count:
                mismatches += 1

        print(f"   ENTER/EXIT 잘림(…): {partial_n}건 | count 불일치(보정 후): {mismatches}건")
        if snapshots[:3]:
            et0, it0 = snapshots[0]
            print(f"   첫 스냅샷 {et0} → {len(it0)}종목 | 앞 5: {[c for c,_ in it0[:5]]}")
        if len(snapshots) > 3:
            et1, it1 = snapshots[-1]
            print(f"   마지막 {et1} → {len(it1)}종목")

        if args.dry_run:
            print("✅ dry-run 완료 (--apply 로 DB 반영)")
            return 0

        stats = apply_snapshots(
            db,
            strategy_id=args.strategy.upper(),
            start=args.start,
            end=end,
            snapshots=snapshots,
            dry_run=False,
        )
        print(
            f"✅ 적용 완료: 삭제 {stats['deleted_events']} 스냅샷 | "
            f"신규 {stats['snapshots']} 스냅샷 / {stats['inserted_rows']}행"
        )
        return 0
    finally:
        db.close()


if __name__ == "__main__":
    raise SystemExit(main())
