#!/usr/bin/env python3
"""한국 주식시장(KRX/KOSDAQ) 휴장일을 investing.com에서 받아 state/market_holidays.json에 저장.

- 올해 + 내년 데이터를 함께 수집해 12월 → 1월 갭 방지.
- launchd `ai.openclaw.stock.holiday-sync`가 주 1회 호출 (LLM 미경유).
- 실패 시 stderr만 남기고 기존 state 파일은 보존 (web view는 옛 데이터로 계속 동작).

CLI:
  python3 holiday_sync.py            # 올해+내년 fetch 후 저장
  python3 holiday_sync.py --years 1  # 올해만
  python3 holiday_sync.py --show     # 저장된 휴장일 출력 (network 호출 없음)

Source: https://kr.investing.com/holiday-calendar/service/getCalendarFilteredData
필터: country[]=11(한국), 거래소 = "서울 증권 거래소" (KOSDAQ는 같은 날 동시 휴장이라 첫 매칭만)
"""
from __future__ import annotations

import argparse
import json
import re
import sys
import urllib.parse
import urllib.request
from datetime import date, datetime
from pathlib import Path
from zoneinfo import ZoneInfo

WORKSPACE = Path(__file__).resolve().parent.parent
STATE_FILE = WORKSPACE / 'state' / 'market_holidays.json'
HEALTH_FILE = WORKSPACE / 'state' / 'holiday_sync_health.json'
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
KST = ZoneInfo('Asia/Seoul')

URL = 'https://kr.investing.com/holiday-calendar/service/getCalendarFilteredData'
NAGER_URL = 'https://date.nager.at/api/v3/PublicHolidays/{year}/KR'
KOREA_COUNTRY_ID = 11
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'

ROW_RE = re.compile(
    r'<tr>\s*<td class="date[^"]*">([^<]*)</td>.*?<td>([^<]+)</td>\s*<td class="last">([^<]+)</td>',
    re.DOTALL,
)
DATE_RE = re.compile(r'(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일')


def fetch_year(year: int) -> dict[str, str]:
    """{date(YYYY-MM-DD) → 휴장명} for the given year. 빈 dict 반환 가능."""
    body = urllib.parse.urlencode([
        ('country[]', str(KOREA_COUNTRY_ID)),
        ('dateFrom', f'{year}-01-01'),
        ('dateTo', f'{year}-12-31'),
    ]).encode()
    req = urllib.request.Request(URL, data=body, method='POST', headers={
        'User-Agent': USER_AGENT,
        'X-Requested-With': 'XMLHttpRequest',
        'Referer': 'https://kr.investing.com/holiday-calendar/',
        'Origin': 'https://kr.investing.com',
        'Accept': 'application/json, text/javascript, */*; q=0.01',
        'Accept-Language': 'ko-KR,ko;q=0.9',
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
    })
    with urllib.request.urlopen(req, timeout=20) as r:
        payload = json.loads(r.read().decode('utf-8'))
    html_str = payload.get('data', '')
    last_date: str | None = None
    out: dict[str, str] = {}
    for date_raw, exch, name in ROW_RE.findall(html_str):
        date_raw = date_raw.strip()
        exch = exch.strip()
        name = name.strip()
        if date_raw:
            m = DATE_RE.match(date_raw)
            if m:
                last_date = f'{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}'
        # 한국 KRX 본장 기준 — 서울/코스닥 공동 휴장이라 서울만 보면 충분.
        if last_date and last_date.startswith(str(year)) and '서울' in exch and last_date not in out:
            out[last_date] = name
    return out


def fetch_nager(year: int) -> dict[str, str]:
    """date.nager.at에서 한국 공휴일 fetch (평일만).
    investing.com이 임시공휴일(선거일·정부 지정 대체공휴일)을 누락하는 패턴 보강용.
    토·일은 KRX 자체 휴장이라 제외 — KRX 휴장 의미가 없음."""
    req = urllib.request.Request(NAGER_URL.format(year=year), headers={
        'User-Agent': USER_AGENT,
        'Accept': 'application/json',
    })
    with urllib.request.urlopen(req, timeout=20) as r:
        data = json.loads(r.read().decode('utf-8'))
    out: dict[str, str] = {}
    for item in data:
        d = (item.get('date') or '').strip()
        name = (item.get('localName') or item.get('name') or '').strip()
        if not d or not name:
            continue
        try:
            y, m, dd = map(int, d.split('-'))
            if date(y, m, dd).weekday() >= 5:
                continue
        except Exception:
            continue
        out[d] = name
    return out


def is_holiday(date_str: str) -> bool:
    """date_str(YYYY-MM-DD)이 KRX 휴장일이면 True. 데이터 파일 없거나 깨지면 False(안전 fallback).
    다른 스크립트가 self-skip 게이트로 import해서 사용한다."""
    if not STATE_FILE.exists():
        return False
    try:
        data = json.loads(STATE_FILE.read_text())
        return date_str in (data.get('holidays') or {})
    except Exception:
        return False


def is_holiday_today() -> bool:
    return is_holiday(datetime.now(KST).strftime('%Y-%m-%d'))


def is_market_day_today() -> bool:
    """오늘이 KRX 정규장 영업일이면 True (평일 + 휴장일 아님).
    데이터 파일 누락 시 평일이면 True로 fall through — 검증 신호 가리지 않음."""
    now = datetime.now(KST)
    if now.weekday() >= 5:  # 토(5)·일(6)
        return False
    return not is_holiday_today()


def _send_telegram(text: str) -> bool:
    """레이 텔레그램으로 자가 알림. 자격증명·발송 실패 시 stderr만 남기고 False."""
    try:
        cfg = json.loads(CONFIG_PATH.read_text())
        acct = cfg['channels']['telegram']['accounts']['stock']
        token = acct['botToken']
        chat_ids = acct.get('allowFrom') or []
    except Exception as e:
        sys.stderr.write(f'telegram cfg load failed: {e}\n')
        return False
    if not chat_ids:
        return False
    url = f'https://api.telegram.org/bot{token}/sendMessage'
    ok = True
    for chat_id in chat_ids:
        body = urllib.parse.urlencode({
            'chat_id': chat_id, 'text': text[:4000], 'disable_web_page_preview': 'true',
        }).encode()
        try:
            req = urllib.request.Request(url, data=body, method='POST')
            with urllib.request.urlopen(req, timeout=15) as r:
                if r.status != 200:
                    ok = False
        except Exception as e:
            sys.stderr.write(f'telegram send failed: {e}\n')
            ok = False
    return ok


def _load_health() -> dict:
    if not HEALTH_FILE.exists():
        return {'consecutive_both_failures': 0}
    try:
        return json.loads(HEALTH_FILE.read_text())
    except Exception:
        return {'consecutive_both_failures': 0}


def _save_health(h: dict) -> None:
    HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
    HEALTH_FILE.write_text(json.dumps(h, ensure_ascii=False, indent=2))


def _update_health(both_failed: bool, error_lines: list[str]) -> None:
    """sync 시도 결과로 health 상태 갱신 + 2회 연속 진입 시점에만 알림 1회."""
    now_iso = datetime.now(KST).isoformat()
    h = _load_health()
    prev = int(h.get('consecutive_both_failures') or 0)
    h['last_attempt_at'] = now_iso
    if both_failed:
        h['consecutive_both_failures'] = prev + 1
        h['last_failure_reason'] = '\n'.join(error_lines)[:500]
        if h['consecutive_both_failures'] == 2 and prev < 2:
            last_ok = h.get('last_success_at') or '기록 없음'
            text = (
                '🚨 holiday-sync 2회 연속 실패\n'
                'investing.com · nager.at 양쪽 모두 데이터 0건\n'
                f'마지막 성공: {last_ok}\n'
                f'오류:\n{h["last_failure_reason"]}\n'
                'state file은 옛 데이터 유지 — 휴장 가드는 정상 동작'
            )
            sent = _send_telegram(text)
            h['alert_sent_at'] = now_iso if sent else None
    else:
        h['consecutive_both_failures'] = 0
        h['last_success_at'] = now_iso
        h.pop('last_failure_reason', None)
        h.pop('alert_sent_at', None)
    _save_health(h)


def cmd_sync(years_forward: int) -> int:
    this_year = datetime.now(KST).year
    years = list(range(this_year, this_year + max(1, years_forward)))
    investing_data: dict[str, str] = {}
    errors: list[str] = []
    for y in years:
        try:
            investing_data.update(fetch_year(y))
        except Exception as e:
            errors.append(f'investing fetch_year({y}): {e}')
            sys.stderr.write(errors[-1] + '\n')
    # nager는 healthcheck용으로 항상 시도 — 휴리스틱(investing 0건 연도 skip)은 사용 시점에만 적용.
    nager_data_raw: dict[str, str] = {}
    for y in years:
        try:
            nager_data_raw.update(fetch_nager(y))
        except Exception as e:
            errors.append(f'nager fetch_nager({y}): {e}')
            sys.stderr.write(errors[-1] + '\n')
    # 양쪽 다 0건이면 영구 변경/네트워크 차단 의심 — health 카운트 + 2회 연속 시 알림.
    both_failed = (not investing_data) and (not nager_data_raw)
    _update_health(both_failed, errors)
    if not investing_data:
        sys.stderr.write('no holidays parsed — investing.com 미반영. state file은 옛 데이터 유지.\n')
        return 3
    # KRX는 다음 연도 휴장 캘린더를 보통 11~12월에 공식 발표 → 그 전엔 investing.com도 비어 있음.
    # investing이 한 건이라도 잡은 연도에만 nager 보강 — 미발표 연도의 일반 공휴일이 KRX 휴장으로 잘못 등록되는 것 차단.
    investing_years = {int(d[:4]) for d in investing_data}
    nager_data = {d: n for d, n in nager_data_raw.items() if int(d[:4]) in investing_years}
    # investing.com(거래소 캘린더) 우선 — KRX 휴장 권위. nager는 누락분만 채움.
    holidays = {**nager_data, **investing_data}
    nager_extra = sorted(d for d in nager_data if d not in investing_data)
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        'fetched_at': datetime.now(KST).isoformat(),
        'sources': {'primary': URL, 'fallback': NAGER_URL.format(year='YYYY')},
        'years': years,
        'holidays': dict(sorted(holidays.items())),
    }
    tmp = STATE_FILE.with_suffix('.json.tmp')
    tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
    tmp.replace(STATE_FILE)
    extra_msg = f' (nager 보강 {len(nager_extra)}일: {nager_extra})' if nager_extra else ''
    print(f'wrote {STATE_FILE} — {len(holidays)} holidays for {years}{extra_msg}', flush=True)
    return 0


def cmd_show() -> int:
    if not STATE_FILE.exists():
        sys.stderr.write(f'no state file: {STATE_FILE}\n')
        return 1
    data = json.loads(STATE_FILE.read_text())
    print(f'fetched_at: {data.get("fetched_at")}')
    print(f'years: {data.get("years")}')
    for d, n in (data.get('holidays') or {}).items():
        print(f'  {d}  {n}')
    return 0


def main(argv: list[str]) -> int:
    p = argparse.ArgumentParser(prog='holiday_sync')
    p.add_argument('--years', type=int, default=2, help='올해 + N년 (default 2: 올해와 내년)')
    p.add_argument('--show', action='store_true', help='저장된 휴장일 출력 후 종료 (네트워크 호출 없음)')
    args = p.parse_args(argv)
    if args.show:
        return cmd_show()
    return cmd_sync(args.years)


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
