#!/usr/bin/env python3
"""워치리스트(behive_watchlist.json) 시세 감시 → buy/target/stop 조건 트리거 → 레이 텔레그램 알림.

LLM을 깨우지 않음. 미보유 종목 시세는 ka10095 batch 1콜(단건 ka10001 fallback), 보유 종목은 kt00018 결과 재활용.
한번 알린 조건은 수동 리셋(웹뷰 '알림 리셋' 버튼) 전까지 재알림 X. 장외 시간에는 early exit.

필터링:
  - 미보유 종목: buy 레벨만 알림. 최초 매수구간 진입 1회만 (_unheld_done 마커),
                 이후 다른 buy level 진입은 무시. target/stop은 매도 신호라 무의미.
  - 보유 종목:   buy는 평단 미만 levels(추가매수)만, 각 level 별도 dedup.
                 target/stop 도달 시 각각 1회씩.

state 포맷 (state/watchlist_alerts.json):
  {"alerts": {"서남": ["buy@4300", "_unheld_done", "target", "stop"], ...}}

Usage:
  python3 watchlist_monitor.py check        # 1회 감시 (cron 용)
  python3 watchlist_monitor.py check --force # 장외 시간에도 실행 (테스트용)
  python3 watchlist_monitor.py dry-run      # 조건 매칭만 출력, 텔레그램 발송 없음
"""
from __future__ import annotations

import fcntl
import html
import json
import subprocess
import sys
import urllib.parse
import urllib.request
from contextlib import contextmanager
from datetime import datetime, timezone, timedelta
from pathlib import Path

KST = timezone(timedelta(hours=9))
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
sys.path.insert(0, str(WORKSPACE / 'scripts'))
import kiwoom_client as kc  # noqa: E402

STATE_DIR = WORKSPACE / 'state'
WATCHLIST = STATE_DIR / 'behive_watchlist.json'
ALERTS_STATE = STATE_DIR / 'watchlist_alerts.json'
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')

TELEGRAM_ACCOUNT = 'stock'  # 레이 봇
EMAIL_RECIPIENT = 'mini.snowoyh@gmail.com'
BUY_PROXIMITY = 1.05        # 매입가 5% 이내 접근 시 'buy' 트리거
MARKET_OPEN = (9, 0)
MARKET_CLOSE = (15, 45)     # 15:30 장 마감 후 최종 체결 포함 버퍼


def load_json(path: Path, default):
    if path.exists():
        try:
            return json.loads(path.read_text())
        except Exception:
            return default
    return default


def save_json(path: Path, data):
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2))


@contextmanager
def alerts_lock():
    """state/watchlist_alerts.json 동시 쓰기 직렬화. monitor cron과 web 리셋 버튼 race 방지."""
    lock_path = ALERTS_STATE.with_suffix(ALERTS_STATE.suffix + '.lock')
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    f = open(lock_path, 'a')
    try:
        fcntl.flock(f.fileno(), fcntl.LOCK_EX)
        yield
    finally:
        try:
            fcntl.flock(f.fileno(), fcntl.LOCK_UN)
        finally:
            f.close()


def load_alerts_state() -> dict:
    """alerts state 로드 + 레거시(date-keyed) 포맷 마이그레이션.

    레거시: {"YYYY-MM-DD": {stock: [conds]}}  → 모든 날짜의 stock entries 머지
    신규:   {"alerts": {stock: [conds]}}
    """
    raw = load_json(ALERTS_STATE, {})
    if not isinstance(raw, dict):
        return {'alerts': {}}
    if isinstance(raw.get('alerts'), dict):
        return {'alerts': dict(raw['alerts'])}
    merged: dict[str, list[str]] = {}
    for v in raw.values():
        if not isinstance(v, dict):
            continue
        for stock, conds in v.items():
            if not isinstance(conds, list):
                continue
            bucket = merged.setdefault(stock, [])
            for c in conds:
                if isinstance(c, str) and c not in bucket:
                    bucket.append(c)
    return {'alerts': merged}


def is_market_hours() -> bool:
    now = datetime.now(KST)
    if now.weekday() >= 5:
        return False
    # KRX 휴장일도 거래 없음 — state/market_holidays.json (holiday_sync 갱신) 참조.
    # 데이터 파일 누락이나 import 실패 시엔 평소대로 진행 (false-positive 회피).
    try:
        from holiday_sync import is_holiday_today
        if is_holiday_today():
            return False
    except Exception:
        pass
    mins = now.hour * 60 + now.minute
    open_m = MARKET_OPEN[0] * 60 + MARKET_OPEN[1]
    close_m = MARKET_CLOSE[0] * 60 + MARKET_CLOSE[1]
    return open_m <= mins <= close_m


def send_telegram(text: str) -> bool:
    cfg = json.loads(CONFIG_PATH.read_text())
    acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
    token = acct['botToken']
    chat_ids = acct.get('allowFrom') or []
    if not chat_ids:
        print('no telegram chat_ids', file=sys.stderr)
        return False
    url = f'https://api.telegram.org/bot{token}/sendMessage'
    ok = True
    for chat_id in chat_ids:
        data = urllib.parse.urlencode({
            'chat_id': chat_id,
            'text': text[:4000],
            'disable_web_page_preview': 'true',
        }).encode()
        try:
            req = urllib.request.Request(url, data=data, method='POST')
            with urllib.request.urlopen(req, timeout=15) as r:
                if r.status != 200:
                    ok = False
                    print(f'telegram HTTP {r.status}', file=sys.stderr)
        except Exception as e:
            print(f'telegram error: {e}', file=sys.stderr)
            ok = False
    return ok


def send_mail(subject: str, html_body: str) -> bool:
    """워치리스트 상세 알림을 이메일 한 통으로 발송."""
    cmd = ['gog', 'gmail', 'send', '--to', EMAIL_RECIPIENT, '--subject', subject, '--body-html', html_body]
    p = subprocess.run(cmd, text=True, capture_output=True)
    if p.returncode != 0:
        print(p.stderr.strip() or p.stdout.strip() or 'gog gmail send failed', file=sys.stderr)
        return False
    return True


def get_held_info() -> dict[str, dict]:
    """보유 종목 {code: {cur_price, avg_price}} — kt00018 재활용."""
    info: dict[str, dict] = {}
    try:
        for positions in kc.get_positions_all().values():
            for p in positions:
                code = (p.get('code') or '').strip()
                if not code:
                    continue
                info[code] = {
                    'cur_price': int(p.get('cur_price') or 0),
                    'avg_price': int(p.get('avg_price') or 0),
                }
    except Exception as e:
        print(f'[warn] positions 조회 실패 — ka10001 단독 사용: {e}', file=sys.stderr)
    return info


def _buy_levels(entry: dict) -> list[int]:
    """buy.levels 우선, 없으면 buy.primary를 단일 레벨로 사용."""
    buy = entry.get('buy')
    if not isinstance(buy, dict):
        return []
    levels = buy.get('levels') or []
    out = [int(lv) for lv in levels if isinstance(lv, (int, float)) and lv > 0]
    if out:
        return out
    primary = buy.get('primary')
    if isinstance(primary, (int, float)) and primary > 0:
        return [int(primary)]
    return []


def evaluate(entry: dict, price: int, is_held: bool, avg_price: int | None) -> list[str]:
    """충족된 조건 반환.

    - 미보유: buy 레벨만 평가 (target/stop 무시) → 'buy@<price>' 키
    - 보유: target/stop 평가, buy는 평단 미만 레벨만 (추가매수 타이밍)
    """
    triggered: list[str] = []
    for lv in _buy_levels(entry):
        if is_held and avg_price and lv >= avg_price:
            continue  # 이미 매수한 가격대 — 알림 불필요
        if price <= lv * BUY_PROXIMITY:
            triggered.append(f'buy@{lv}')
    if is_held:
        target = entry.get('target')
        stop = entry.get('stop')
        if isinstance(target, dict):
            tl = target.get('low')
            if isinstance(tl, (int, float)) and tl > 0 and price >= tl:
                triggered.append('target')
        if isinstance(stop, dict):
            sv = stop.get('value')
            if isinstance(sv, (int, float)) and sv > 0 and price <= sv:
                triggered.append('stop')
    return triggered


def fmt_price(v) -> str:
    try:
        return f'{int(v):,}원'
    except Exception:
        return str(v)


RANK_LABELS = ['1차', '2차', '3차', '4차', '5차']


def build_alert(entry: dict, condition: str, price: int, change: int = 0, pct: float = 0.0, buy_level: int | None = None) -> str:
    stock = entry.get('stock', '?')
    buy_raw = ((entry.get('buy') or {}).get('raw')) if entry.get('buy') else None
    target_raw = ((entry.get('target') or {}).get('raw')) if entry.get('target') else None
    stop_raw = ((entry.get('stop') or {}).get('raw')) if entry.get('stop') else None
    upside = entry.get('upside_pct')
    if condition == 'buy' and buy_level is not None:
        levels = _buy_levels(entry)
        rank_prefix = ''
        if len(levels) > 1 and buy_level in levels:
            idx = levels.index(buy_level)
            if 0 <= idx < len(RANK_LABELS):
                rank_prefix = f'{RANK_LABELS[idx]} '
        header = f'#{stock} {rank_prefix}매수 구간 진입 ({buy_level:,}원)'
    else:
        header_map = {
            'target': f'#{stock} 목표가 도달',
            'stop': f'⚠️ #{stock} 손절가 이탈',
        }
        header = header_map.get(condition, f'#{stock}')
    cur_line = f'{price:,}원'
    if change or pct:
        cur_line += f' ({change:+,}원, {pct:+.2f}%)'
    lines = [
        f'[워치리스트] {datetime.now(KST).strftime("%m/%d %H:%M")}',
        header,
        f'• 현재가: {cur_line}',
    ]
    if buy_raw:
        lines.append(f'• 매입가: {buy_raw}')
    if target_raw:
        suffix = ''
        if upside is not None:
            sign = '+' if upside >= 0 else ''
            suffix = f' ({sign}{upside}%)'
        lines.append(f'• 목표가: {target_raw}{suffix}')
    if stop_raw:
        lines.append(f'• 손절가: {stop_raw}')
    return '\n'.join(lines)


def _condition_label(condition_key: str) -> str:
    if condition_key.startswith('buy@'):
        try:
            return f'매수구간 진입 {int(condition_key.split("@", 1)[1]):,}원'
        except Exception:
            return '매수구간 진입'
    if condition_key == 'target':
        return '목표가 도달'
    if condition_key == 'stop':
        return '손절가 이탈'
    return condition_key


def build_summary(records: list[dict], mail_ok: bool | None = None) -> str:
    """텔레그램용: 조건/종목/현재가만 짧게 묶어 발송."""
    ts = datetime.now(KST).strftime('%m/%d %H:%M')
    groups = [
        ('buy', '매수구간 진입'),
        ('target', '목표가 도달'),
        ('stop', '손절가 이탈'),
    ]
    lines = [f'[워치리스트 요약] {ts}', f'총 {len(records)}건 감지']
    for prefix, label in groups:
        items = [r for r in records if r['condition_key'].startswith('buy@') if prefix == 'buy'] if prefix == 'buy' else [r for r in records if r['condition_key'] == prefix]
        if not items:
            continue
        lines.append(f'\n[{label}]')
        for r in items:
            cur = f"{r['price']:,}원"
            pct = r.get('pct')
            if isinstance(pct, (int, float)) and pct:
                cur += f' ({pct:+.2f}%)'
            extra = ''
            if r['condition_key'].startswith('buy@'):
                extra = f" / 기준 {r.get('buy_level', 0):,}원"
            lines.append(f"• {r['stock']}: {cur}{extra}")
    if mail_ok is True:
        lines.append('\n상세 내용은 메일로 묶어서 보냈습니다.')
    elif mail_ok is False:
        lines.append('\n⚠️ 상세 메일 발송 실패 — 로그 확인 필요')
    else:
        lines.append('\n상세 내용은 메일로 발송 예정입니다.')
    return '\n'.join(lines)


def build_detail_email(records: list[dict]) -> str:
    """이메일용: 기존 개별 알림 상세를 카드 형태로 묶음."""
    esc = html.escape
    ts = datetime.now(KST).strftime('%Y-%m-%d %H:%M')
    parts = [
        '<div style="font-family:-apple-system,BlinkMacSystemFont,&quot;Apple SD Gothic Neo&quot;,sans-serif;font-size:14px;color:#222;max-width:720px;line-height:1.55;">',
        f'<div style="margin-bottom:16px;color:#444;">관리자님, 워치리스트 조건 감지 {len(records)}건 상세입니다. <span style="color:#888;">{esc(ts)}</span></div>',
    ]
    for r in records:
        e = r['entry']
        buy_raw = ((e.get('buy') or {}).get('raw')) if e.get('buy') else None
        target_raw = ((e.get('target') or {}).get('raw')) if e.get('target') else None
        stop_raw = ((e.get('stop') or {}).get('raw')) if e.get('stop') else None
        summary = e.get('summary') or []
        notes = e.get('notes') or []
        video = e.get('video') or {}
        cond = _condition_label(r['condition_key'])
        color = '#d24f4f' if r['condition_key'] != 'stop' else '#1565c0'
        cur_line = f"{r['price']:,}원"
        if r.get('change') or r.get('pct'):
            cur_line += f" ({r.get('change', 0):+,}원, {r.get('pct', 0.0):+.2f}%)"
        parts.append('<div style="border:1px solid #e5e5e5;border-radius:8px;padding:16px 18px;margin-bottom:16px;background:#fafafa;">')
        parts.append(f'<div style="font-size:16px;font-weight:700;margin-bottom:4px;">{esc(r["stock"])} <span style="font-size:12px;color:#666;font-weight:400;">{esc(e.get("code") or "")}</span></div>')
        parts.append(f'<div style="font-weight:700;color:{color};margin-bottom:10px;">{esc(cond)}</div>')
        parts.append('<table style="width:100%;border-collapse:collapse;background:#fff;border:1px solid #eee;margin:10px 0 14px;">')
        rows = [('현재가', cur_line)]
        if buy_raw:
            rows.append(('매입가', buy_raw))
        if target_raw:
            rows.append(('목표가', target_raw))
        if stop_raw:
            rows.append(('손절가', stop_raw))
        for label, value in rows:
            parts.append(f'<tr><td style="color:#666;padding:6px 10px;width:90px;border-bottom:1px solid #f0f0f0;">{esc(label)}</td><td style="padding:6px 10px;border-bottom:1px solid #f0f0f0;font-weight:500;">{esc(value)}</td></tr>')
        parts.append('</table>')
        if summary:
            parts.append('<div style="font-weight:600;margin:10px 0 4px;">요약</div><ul style="margin:4px 0 8px 0;padding-left:20px;">')
            for item in summary[:6]:
                parts.append(f'<li>{esc(item)}</li>')
            parts.append('</ul>')
        if notes:
            parts.append('<div style="font-weight:600;margin:10px 0 4px;">메모</div><ul style="margin:4px 0 8px 0;padding-left:20px;">')
            for item in notes[:6]:
                parts.append(f'<li>{esc(item)}</li>')
            parts.append('</ul>')
        if video.get('url'):
            parts.append(f'<div style="color:#888;font-size:12px;margin-top:10px;border-top:1px dashed #ddd;padding-top:8px;">출처: <a href="{esc(video.get("url", ""))}" style="color:#06c;">{esc(video.get("title") or video.get("url"))}</a></div>')
        parts.append('</div>')
    parts.append('</div>')
    return '\n'.join(parts)


def run(dry: bool = False, force: bool = False) -> int:
    if not force and not is_market_hours():
        print(f'장외 시간 — skip ({datetime.now(KST).strftime("%Y-%m-%d %H:%M")})')
        return 0
    watchlist = load_json(WATCHLIST, {})
    if not watchlist:
        print('watchlist 비어있음')
        return 0
    alerts_state = load_alerts_state()
    alerts_by_stock: dict[str, list[str]] = alerts_state.setdefault('alerts', {})

    held_info = get_held_info()
    triggered_total = 0
    pending_records: list[dict] = []
    watchlist_dirty = False

    # 미보유 종목은 ka10095 batch 1콜로 일괄 시세 조회 (단건 ka10001 × N 대비 압도적으로 빠르고 rate limit 부담 없음).
    non_held_codes = [
        (e.get('code') or '').strip()
        for s, e in watchlist.items()
        if isinstance(e, dict) and e.get('status') != 'pending_delete' and (e.get('code') or '').strip()
        and (e.get('code') or '').strip() not in held_info
    ]
    batch_quotes: dict[str, dict] = {}
    if non_held_codes:
        try:
            batch_quotes = kc.get_watchlist_quotes(non_held_codes)
        except Exception as e:
            print(f'[warn] ka10095 batch 실패, 단건 fallback: {e}', file=sys.stderr)

    for stock, entry in list(watchlist.items()):
        if not isinstance(entry, dict):
            continue
        if entry.get('status') == 'pending_delete':
            continue
        code = (entry.get('code') or '').strip()
        if not code:
            try:
                info = kc.resolve_stock_code(stock)
                code = info.get('code', '')
                if code:
                    entry['code'] = code
                    watchlist_dirty = True
            except Exception as e:
                print(f'[{stock}] 종목코드 매핑 실패: {e} — 스킵', file=sys.stderr)
                continue
        held = held_info.get(code)
        is_held = held is not None
        avg_price = held.get('avg_price') if held else None
        price = held.get('cur_price') if held else None
        change = 0
        pct = 0.0
        if not price:
            bq = batch_quotes.get(code)
            if bq and bq.get('price'):
                price = bq['price']
                change = bq.get('change', 0)
                pct = bq.get('change_pct', 0.0)
            else:
                try:
                    q = kc.get_stock_quote(code)
                    price = q.get('price', 0)
                    change = q.get('change', 0)
                    pct = q.get('change_pct', 0.0)
                except Exception as e:
                    print(f'[{stock}/{code}] quote 실패: {e}', file=sys.stderr)
                    continue
        if not isinstance(price, (int, float)) or price <= 0:
            continue
        conds = evaluate(entry, int(price), is_held, avg_price)
        already = set(alerts_by_stock.get(stock, []))
        new_conds = [c for c in conds if c not in already]
        # 미보유 상태에서 한 번이라도 buy 알림 발사된 적 있으면(_unheld_done) 이후 buy 알림 전부 차단.
        if not is_held and '_unheld_done' in already:
            new_conds = [c for c in new_conds if not c.startswith('buy@')]
        buy_conds = [c for c in new_conds if c.startswith('buy@')]
        other_conds = [c for c in new_conds if not c.startswith('buy@')]
        if len(buy_conds) > 1:
            # 여러 buy 레벨이 한 번에 걸리면 가장 높은(먼저 대응할) 레벨 1개만 알림.
            buy_conds = [max(buy_conds, key=lambda c: int(c.split('@', 1)[1]))]
        for c in [*buy_conds, *other_conds]:
            if c.startswith('buy@'):
                lv = int(c.split('@', 1)[1])
                text = build_alert(entry, 'buy', int(price), int(change), float(pct), buy_level=lv)
            else:
                lv = None
                text = build_alert(entry, c, int(price), int(change), float(pct))
            if dry:
                print('--- DRY ---')
                print(text)
                triggered_total += 1
            else:
                pending_records.append({
                    'stock': stock,
                    'entry': entry,
                    'condition_key': c,
                    'buy_level': lv,
                    'price': int(price),
                    'change': int(change),
                    'pct': float(pct),
                    'is_held': is_held,
                })

    if dry:
        if triggered_total:
            print('--- SUMMARY ---')
            # dry-run에서는 위에서 개별 상세를 이미 출력하므로 요약 포맷만 추가 점검.
            # pending_records는 실제 발송 모드에서만 채운다.
        print(f'done. watched={len(watchlist)} triggered={triggered_total}')
        return 0

    if pending_records:
        subject = f'[워치리스트 조건 감지] {len(pending_records)}건 — {datetime.now(KST).strftime("%Y-%m-%d %H:%M")}'
        mail_ok = send_mail(subject, build_detail_email(pending_records))
        summary = build_summary(pending_records, mail_ok=mail_ok)
        if send_telegram(summary):
            # 발송 직후 락 잡고 read-modify-write — web 리셋 버튼과의 race 차단.
            with alerts_lock():
                latest = load_alerts_state()
                latest_alerts = latest.setdefault('alerts', {})
                for r in pending_records:
                    bucket = latest_alerts.setdefault(r['stock'], [])
                    cond = r['condition_key']
                    if cond not in bucket:
                        bucket.append(cond)
                    if not r['is_held'] and cond.startswith('buy@') and '_unheld_done' not in bucket:
                        bucket.append('_unheld_done')
                    triggered_total += 1
                    print(f"alerted {r['stock']}:{cond} @ {r['price']:,}원")
                save_json(ALERTS_STATE, latest)
    else:
        # 알림 없을 때도 마이그레이션이 일어났을 수 있으니 한 번 저장(레거시 → 신규).
        with alerts_lock():
            save_json(ALERTS_STATE, alerts_state)

    if watchlist_dirty:
        save_json(WATCHLIST, watchlist)
    print(f'done. watched={len(watchlist)} triggered={triggered_total}')
    return 0


def main():
    cmd = sys.argv[1] if len(sys.argv) > 1 else 'help'
    force = '--force' in sys.argv[2:]
    try:
        if cmd == 'check':
            return run(dry=False, force=force)
        if cmd == 'dry-run':
            return run(dry=True, force=True)
        print(__doc__, file=sys.stderr)
        return 2
    except Exception as e:
        import traceback
        tb = traceback.format_exc()
        print(f'[fatal] {e}\n{tb}', file=sys.stderr)
        try:
            send_telegram(f'⚠️ [watchlist_monitor] 실행 실패\n{type(e).__name__}: {e}')
        except Exception:
            pass
        return 1


if __name__ == '__main__':
    sys.exit(main())
