#!/usr/bin/env python3
"""
telegram-live 메시지에서 '월별 주요 이벤트'를 자동 추출 → events.json(KST) 병합.

소스 : ~/sector-agents/agents/*/raw/telegram-live/{YYYY-MM-DD}-{channel}.md  (모든 섹터 채널)
출력 : ~/룬샤시_공시정리/태버지/events.json  ({"events":[{id,date,title,category,note}]})
LLM  : Codex OAuth gpt-5.6-terra (구독, EVENTS_LLM_MODEL로 오버라이드) — 이벤트 추출 + KST 변환
TZ   : 미국 ET(서머타임 자동) → KST. 코드에서 zoneinfo로 재검증.

usage:
  python extract_telegram_events.py                 # 최근 3일 .md → 병합
  python extract_telegram_events.py --days-back 7    # 최근 7일
  python extract_telegram_events.py --since 2026-05-14 --until 2026-05-20
  python extract_telegram_events.py --dry-run        # 저장 안 하고 추출만 출력
"""
from __future__ import annotations
import argparse, glob, hashlib, json, os, re, sys
from datetime import datetime, timedelta, date
from pathlib import Path
from urllib.parse import urlencode
from urllib.request import urlopen
from zoneinfo import ZoneInfo

ET = ZoneInfo("America/New_York")
KST = ZoneInfo("Asia/Seoul")
HOME = Path(os.path.expanduser("~"))
EVENTS_FILE = HOME / "룬샤시_공시정리" / "태버지" / "events.json"
TG_GLOB = str(HOME / "sector-agents" / "agents" / "*" / "raw" / "telegram-live" / "*.md")
MODEL = os.environ.get("EVENTS_LLM_MODEL", "gpt-5.6-terra")
DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})")
SHARED_API = os.environ.get(
    "BLUEHOLE_TELEGRAM_API",
    "http://127.0.0.1:5077/api/telegram/search",
)

CATEGORIES = ["earnings", "macro", "conference", "product", "index", "policy", "etc"]

SYSTEM_PROMPT = """\
너는 한국 투자자용 '주요 이벤트 캘린더'를 만드는 추출기다.
입력은 여러 텔레그램 채널의 그날 메시지 모음(한국어, 해외/국내 주식 일정 포함)이다.
여기서 '미래에 일어날 구체적 일정성 이벤트'만 추출한다.

추출 대상(category):
- earnings : 기업 실적발표/컨콜
- macro    : 거시지표 발표 (FOMC, 고용보고서, CPI, GDP, 금리결정 등)
- conference: 투자자/산업 컨퍼런스, IR, 기술 발표회 (예: 구글 I/O, GTC)
- product  : 신제품 출시/공개
- index    : 지수 편입/편출, 리밸런싱
- policy   : 정책/규제/입법 일정
- etc      : 그 외 명확한 일정

제외: 단순 시황코멘트, 의견, 과거에 이미 일어난 일, 날짜 없는 막연한 언급.

⏰ 시간대 규칙 — 모든 date/시각은 반드시 한국시간(KST, UTC+9) 기준으로 변환한다:
- 미국 증시(NYSE/NASDAQ) 기준 ET는 서머타임에 따라 KST와 +13h(EDT, 3~11월) 또는 +14h(EST, 11~3월) 차이.
- 미국 실적 '장 마감 후(AMC, ~16:00 ET)' → KST 다음날 새벽(EDT 05:00 / EST 06:00). date는 KST 날짜(보통 +1일).
- 미국 실적 '장 시작 전(BMO, ~07:00 ET)' → KST 당일 저녁(EDT 20:00 / EST 21:00).
- 미국 매크로: FOMC 결정 14:00 ET→KST 익일 03:00(EDT)/04:00(EST); 고용 08:30 ET→KST 21:30/22:30; CPI 08:30 ET 동일.
- 한국 일정은 그대로 KST.
- 컨퍼런스/올데이(시각 불명) 이벤트는 현지 날짜를 KST 날짜로 쓰되, 미국 현지면 note에 '(미 현지 M/D)' 병기.
- 채널이 이미 한국시간 주간일정으로 정리한 경우, 그 날짜가 'KST 날짜'인지 'US 현지 날짜'인지 맥락(요일 등)으로 판단해 KST로 통일.

출력은 JSON: {"events":[{
  "date": "YYYY-MM-DD",        # KST 기준 날짜
  "title": "엔비디아 실적",      # 간결한 한국어 제목 (회사/지표명 + 유형)
  "category": "earnings",
  "ticker": "NVDA",            # 있으면, 없으면 ""
  "kst_time": "05/21 06:00",   # KST 시각 (알면), 모르면 ""
  "source_market": "US",       # US | KR | 기타
  "note": "엔비디아(NVDA) FY 실적, 장마감후 발표 | KST 05/21 06:00",
  "confidence": 0.0~1.0
}]}
확실한 것만(confidence>=0.6) 넣어라. 같은 이벤트 중복 금지. 설명/주석 없이 JSON만 출력."""


def gather_md(since: date, until: date) -> list[tuple[str, str]]:
    """기간 내 telegram-live .md 파일 (파일명 날짜 기준)."""
    out = []
    for p in sorted(glob.glob(TG_GLOB)):
        m = DATE_RE.search(os.path.basename(p))
        if not m:
            continue
        try:
            d = datetime.strptime(m.group(1), "%Y-%m-%d").date()
        except ValueError:
            continue
        if since <= d <= until:
            try:
                txt = Path(p).read_text(encoding="utf-8", errors="replace")
            except Exception:
                continue
            if txt.strip():
                out.append((os.path.basename(p), txt))
    return out


def gather_shared(since: date, until: date) -> list[tuple[str, str]]:
    """Read all live account dialogs, grouped by channel for the LLM."""
    since_dt = datetime.combine(since, datetime.min.time(), tzinfo=KST)
    until_dt = datetime.combine(until + timedelta(days=1), datetime.min.time(), tzinfo=KST)
    hours = max(24, int((datetime.now(KST) - since_dt).total_seconds() // 3600) + 24)
    params = urlencode([
        ("q", ""), ("hours", str(hours)), ("limit", "10000"),
        ("source", "telegram_live"),
    ])
    with urlopen(f"{SHARED_API}?{params}", timeout=20) as response:
        payload = json.loads(response.read().decode("utf-8"))
    if not payload.get("success"):
        raise RuntimeError(payload.get("error") or "shared Telegram API failed")
    grouped: dict[str, list[str]] = {}
    for row in reversed(payload.get("messages") or []):
        if row.get("source") != "telegram_live":
            continue
        try:
            timestamp = datetime.fromisoformat(
                str(row.get("message_date") or "").replace("Z", "+00:00")
            ).astimezone(KST)
        except ValueError:
            continue
        if not since_dt <= timestamp < until_dt:
            continue
        text = str(row.get("text") or "").strip()
        if not text:
            continue
        channel = str(row.get("chat_title") or row.get("chat_username") or row.get("chat_key") or "Telegram")
        grouped.setdefault(channel, []).append(
            f"### {timestamp.strftime('%Y-%m-%d %H:%M')}\n\n{text}\n\n---"
        )
    return [(f"shared:{channel}", "\n\n".join(rows)) for channel, rows in sorted(grouped.items())]


EVENTS_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "events": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "date": {"type": "string", "description": "KST 기준 YYYY-MM-DD"},
                    "title": {"type": "string"},
                    "category": {"type": "string"},
                    "ticker": {"type": "string"},
                    "kst_time": {"type": "string"},
                    "source_market": {"type": "string"},
                    "note": {"type": "string"},
                    "confidence": {"type": "number"},
                },
                "required": ["date", "title", "category", "ticker", "kst_time",
                             "source_market", "note", "confidence"],
            },
        }
    },
    "required": ["events"],
}


def _codex_client():
    sa = str(HOME / "sector-agents")
    if sa not in sys.path:
        sys.path.insert(0, sa)
    from pipeline.codex_oauth_responses import CodexOAuthResponsesClient
    helper = os.environ.get("CODEX_OAUTH_HELPER_PATH") or str(
        HOME / "openai-codex-oauth-helper" / "codex_oauth.py")
    client = CodexOAuthResponsesClient(helper)
    if not client.available():
        raise RuntimeError("codex OAuth 자격증명 없음 — codex_oauth.py login 필요")
    return client


def call_llm(blocks: list[tuple[str, str]]) -> list[dict]:
    client = _codex_client()
    # 채널 메시지를 청크로 (너무 크면 분할)
    chunks, cur, size = [], [], 0
    for name, txt in blocks:
        piece = f"\n\n===== {name} =====\n{txt[:8000]}"
        if size + len(piece) > 60000 and cur:
            chunks.append("".join(cur)); cur, size = [], 0
        cur.append(piece); size += len(piece)
    if cur:
        chunks.append("".join(cur))

    today_kst = datetime.now(KST).strftime("%Y-%m-%d")
    events: list[dict] = []
    for i, chunk in enumerate(chunks):
        user = (f"오늘(KST): {today_kst}\n아래는 텔레그램 채널들의 메시지다. "
                f"미래 일정성 이벤트만 KST 기준으로 추출하라:\n{chunk}")
        try:
            payload = client.create_structured_response(
                model=MODEL,
                system_prompt=SYSTEM_PROMPT,
                user_prompt=user,
                schema_name="telegram_events",
                schema=EVENTS_SCHEMA,
            )
            evs = _extract_events(payload)
            print(f"  LLM chunk {i+1}/{len(chunks)}: {len(evs)}건", flush=True)
            events.extend(evs)
        except Exception as e:
            print(f"  LLM chunk {i+1} 실패: {type(e).__name__}: {e}", flush=True)
    return events


def _extract_events(payload) -> list[dict]:
    """codex 응답 payload에서 events 리스트 추출 (output_text JSON 또는 dict)."""
    if isinstance(payload, dict) and isinstance(payload.get("events"), list):
        return payload["events"]
    # output_text 형태
    text = ""
    if isinstance(payload, dict):
        text = payload.get("output_text") or ""
        if not text:
            for item in payload.get("output", []) or []:
                for c in (item.get("content") or []):
                    if isinstance(c, dict) and c.get("text"):
                        text += c["text"]
    elif isinstance(payload, str):
        text = payload
    text = text.strip()
    if not text:
        return []
    try:
        d = json.loads(text)
        return d.get("events", []) if isinstance(d, dict) else (d if isinstance(d, list) else [])
    except Exception:
        return []


def normalize(ev: dict) -> dict | None:
    date_str = str(ev.get("date", "")).strip()
    try:
        datetime.strptime(date_str, "%Y-%m-%d")
    except ValueError:
        return None
    title = str(ev.get("title", "")).strip()
    if not title:
        return None
    cat = str(ev.get("category", "etc")).strip().lower()
    if cat not in CATEGORIES:
        cat = "etc"
    note = str(ev.get("note", "")).strip()
    kst_time = str(ev.get("kst_time", "")).strip()
    if kst_time and "KST" not in note:
        note = (note + f" | KST {kst_time}").strip(" |")
    if float(ev.get("confidence", 1) or 0) < 0.6:
        return None
    return {"date": date_str, "title": title, "category": cat, "note": note,
            "_ticker": str(ev.get("ticker", "")).strip()}


def make_id(date_str: str, title: str) -> str:
    return hashlib.sha1(f"{date_str}|{title}".encode()).hexdigest()[:16]


def norm_key(date_str: str, title: str) -> str:
    t = re.sub(r"[\s\(\)\[\]·,.\-_/]", "", title).lower()
    t = re.sub(r"(실적|발표|컨콜|개최|공개|예정)", "", t)
    return f"{date_str}|{t}"


_GENERIC_EVENT_TOKENS = {
    "실적", "발표", "예정", "관련", "승인", "개최", "공개", "시행", "발효",
    "컨퍼런스콜", "컨콜", "제품", "상품", "분기", "1분기", "2분기", "3분기", "4분기",
}


def _title_tokens(title: str) -> set[str]:
    return {
        token.casefold() for token in re.findall(r"[0-9A-Za-z가-힣-]+", title)
        if token.casefold() not in _GENERIC_EVENT_TOKENS and len(token) >= 2
    }


def same_event(left: dict, right: dict) -> bool:
    """Catch cross-channel paraphrases that survive exact normalized-key dedupe."""
    if left.get("date") != right.get("date") or left.get("category") != right.get("category"):
        return False
    a, b = _title_tokens(str(left.get("title") or "")), _title_tokens(str(right.get("title") or ""))
    shared = len(a & b)
    return shared >= 3 and (2 * shared / max(1, len(a) + len(b))) >= 0.65


def load_events() -> list[dict]:
    if not EVENTS_FILE.exists():
        return []
    try:
        d = json.loads(EVENTS_FILE.read_text(encoding="utf-8"))
        return d.get("events", []) if isinstance(d, dict) else (d if isinstance(d, list) else [])
    except Exception:
        return []


def save_events(events: list[dict]) -> None:
    tmp = EVENTS_FILE.with_suffix(".json.tmp")
    tmp.write_text(json.dumps({"events": events}, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp.replace(EVENTS_FILE)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--days-back", type=int, default=3)
    ap.add_argument("--since")
    ap.add_argument("--until")
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    until = datetime.strptime(args.until, "%Y-%m-%d").date() if args.until else datetime.now(KST).date()
    since = datetime.strptime(args.since, "%Y-%m-%d").date() if args.since else until - timedelta(days=args.days_back)

    try:
        blocks = gather_shared(since, until)
        scope = "all_account_dialogs"
    except (OSError, TimeoutError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
        print(f"[gather] 공용 인덱스 실패, 레거시 파일 폴백: {exc}", flush=True)
        blocks = gather_md(since, until)
        scope = "legacy_sector_files"
    print(f"[gather] {since}~{until}: {scope} 소스 {len(blocks)}개", flush=True)
    if not blocks:
        print("처리할 메시지 없음."); return

    raw = call_llm(blocks)
    new = [n for n in (normalize(e) for e in raw) if n]
    print(f"[extract] 정규화된 이벤트 {len(new)}건", flush=True)

    existing = load_events()
    seen = {norm_key(e.get("date", ""), e.get("title", "")) for e in existing}
    added = []
    for n in new:
        k = norm_key(n["date"], n["title"])
        if k in seen or any(same_event(n, row) for row in [*existing, *added]):
            continue
        seen.add(k)
        src = n.pop("_ticker", "")
        added.append({"id": make_id(n["date"], n["title"]), **n})

    print(f"[merge] 기존 {len(existing)}건 + 신규 {len(added)}건", flush=True)
    for a in sorted(added, key=lambda x: x["date"])[:40]:
        print(f"   + {a['date']} [{a['category']}] {a['title']} — {a['note'][:70]}", flush=True)

    if args.dry_run:
        print("[dry-run] 저장 안 함."); return
    if added:
        save_events(existing + added)
        print(f"[saved] events.json → 총 {len(existing)+len(added)}건", flush=True)
    else:
        print("[saved] 신규 없음 — 변경 없음")


if __name__ == "__main__":
    main()
