#!/usr/bin/env python3
import json
import os
import re
import subprocess
import sys
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from html import unescape
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from zoneinfo import ZoneInfo

KST = ZoneInfo('Asia/Seoul')
WORKSPACE = Path('/Users/snowoyh/.openclaw/workspace')
STATE_DIR = WORKSPACE / 'state'
STATE_DIR.mkdir(parents=True, exist_ok=True)
SEND_STATE = STATE_DIR / 'briefing_send_state.json'
IPO_SYNC_STATE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace/state/ipo_calendar_sync.json')
IPO_SEEN_STATE = STATE_DIR / 'briefing_ipo_seen.json'
NEWS_HISTORY_FILE = STATE_DIR / 'news_sent_history.json'
PENDING_SELECTION_FILE = STATE_DIR / 'briefing_pending_selection.json'
NEWS_HISTORY_LOOKBACK_HOURS = 48
NEWS_HISTORY_RETENTION_DAYS = 7
RECIPIENT = 'mini.snowoyh@gmail.com'
NEWS_FEEDS = [
    ('국내 경제', 'https://www.yna.co.kr/rss/economy.xml'),
    ('국제', 'https://www.yna.co.kr/rss/international.xml'),
    ('국제', 'https://rss.edaily.co.kr/world_news.xml'),
    ('증시', 'https://rss.edaily.co.kr/stock_news.xml'),
]
# 토요일 오전(해외 전용 브리핑)에서 쓰는 축소 피드 — 국내 경제·증시 제외
OVERSEAS_NEWS_FEEDS = [f for f in NEWS_FEEDS if f[0] == '국제']
MARKET_FEEDS = [
    ('USD/KRW', 'https://query1.finance.yahoo.com/v8/finance/chart/KRW%3DX?range=2d&interval=1d'),
]
NAVER_INDEX_FEEDS = [
    ('.INX', 'https://m.stock.naver.com/worldstock/index/.INX/total'),
    ('.IXIC', 'https://m.stock.naver.com/worldstock/index/.IXIC/total'),
]
NAVER_DOMESTIC_INDEX_FEEDS = [
    ('KOSPI', 'https://m.stock.naver.com/domestic/index/KOSPI/total'),
    ('KOSDAQ', 'https://m.stock.naver.com/domestic/index/KOSDAQ/total'),
]
KOSPI_FUTURES_INVESTING_URL = 'https://kr.investing.com/indices/korea-200-futures'
MSCI_KOREA_INVESTING_URL = 'https://kr.investing.com/etfs/ishares-south-korea-index'


def run(cmd: list[str]) -> str:
    p = subprocess.run(cmd, capture_output=True, text=True)
    if p.returncode != 0:
        raise RuntimeError(f"command failed: {' '.join(cmd)}\n{p.stderr.strip()}")
    return p.stdout


def load_json(path: Path, default):
    if path.exists():
        try:
            return json.loads(path.read_text())
        except Exception as e:
            print(f"[briefing_mail:load_json:{path}] {type(e).__name__}: {e}", file=sys.stderr)
            return default
    return default


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


def fetch(url: str) -> str:
    req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
    with urllib.request.urlopen(req, timeout=30) as r:
        return r.read().decode('utf-8', 'ignore')


def fetch_bytes(url: str) -> bytes:
    req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
    with urllib.request.urlopen(req, timeout=30) as r:
        return r.read()


def get_today_events(target_date: datetime) -> list[str]:
    start = target_date.astimezone(KST).replace(hour=0, minute=0, second=0, microsecond=0)
    end = start + timedelta(days=1)
    out = run([
        'gog', 'calendar', 'events', 'mini.snowoyh@gmail.com',
        '--from', start.isoformat(), '--to', end.isoformat(), '--json'
    ])
    data = json.loads(out)
    items = []
    for ev in data.get('events', []):
        summary = ev.get('summary', '(제목 없음)')
        s = ev.get('start', {})
        e = ev.get('end', {})
        if 'dateTime' in s:
            st = datetime.fromisoformat(s['dateTime']).astimezone(KST)
            en = datetime.fromisoformat(e['dateTime']).astimezone(KST) if 'dateTime' in e else None
            times = f"{st:%H:%M}"
            if en:
                times += f"-{en:%H:%M}"
        else:
            times = '종일'
        items.append(f"- {summary} ({times})")
    return items


def section(title: str, lines: list[str]) -> list[str]:
    if not lines:
        return []
    return [f'■ {title}'] + lines + ['']


def clean_title(title: str) -> str:
    title = re.sub(r'\s+', ' ', title).strip()
    title = re.sub(r'\s*-\s*[^-]+$', '', title)
    return title


DESCRIPTION_MAX_LEN = 500


def clean_description(desc: str) -> str:
    if not desc:
        return ''
    text = re.sub(r'<[^>]+>', ' ', desc)
    text = unescape(text)
    text = re.sub(r'\(서울=연합뉴스\)\s*[^=]{0,40}기자\s*=\s*', '', text)
    text = re.sub(r'\s+', ' ', text).strip()
    if len(text) > DESCRIPTION_MAX_LEN:
        text = text[:DESCRIPTION_MAX_LEN].rstrip() + '…'
    return text


def source_from_link(link: str) -> str:
    m = re.search(r'https?://(?:www\.|rss\.)?([^/]+)', link or '')
    if not m:
        return ''
    host = m.group(1).lower()
    mapping = {
        'yna.co.kr': '연합뉴스',
        'edaily.co.kr': '이데일리',
        'mk.co.kr': '매일경제',
        'hankyung.com': '한국경제',
    }
    for key, name in mapping.items():
        if host.endswith(key):
            return name
    return host


def parse_google_news_feed(url: str) -> list[dict]:
    xml_text = fetch(url)
    try:
        root = ET.fromstring(xml_text)
    except ET.ParseError:
        # Recover from bare-& in attribute values (common RSS breakage, e.g. youtube embed URLs).
        fixed = re.sub(r'&(?!#?\w+;)', '&amp;', xml_text)
        root = ET.fromstring(fixed)
    items = []
    for item in root.findall('./channel/item'):
        title = clean_title(item.findtext('title', default=''))
        link = item.findtext('link', default='').strip()
        pub_date = item.findtext('pubDate', default='').strip()
        description = clean_description(item.findtext('description', default=''))
        source_el = item.find('source')
        source = source_el.text.strip() if source_el is not None and source_el.text else ''
        if not source:
            source = source_from_link(link)
        pub_dt = None
        if pub_date:
            try:
                pub_dt = parsedate_to_datetime(pub_date)
                if pub_dt.tzinfo is None:
                    pub_dt = pub_dt.replace(tzinfo=KST)
            except (TypeError, ValueError):
                pub_dt = None
        if title and link:
            items.append({
                'title': title,
                'link': link,
                'pubDate': pub_date,
                'pub_dt': pub_dt,
                'source': source,
                'description': description,
            })
    return items


def news_priority(title: str) -> int:
    t = title.lower()
    score = 0
    keywords = {
        '전쟁': 7,
        '침공': 6, '공습': 6, '폭격': 6, '포격': 6, '미사일': 6, '휴전': 6, '정전': 6,
        '총격': 6, '총기난사': 6, '저격': 6, '암살': 6, '피습': 6, '테러': 6, '핵실험': 6,
        '관세': 5, '금리': 5, '환율': 5, '인플레이션': 5, 'cpi': 5, 'fomc': 5,
        '지정학': 5, '제재': 5,
        '반도체': 4, 'ai': 4, '실적': 4, '수출': 4, '경기': 4, '유가': 4,
        '코스피': 3, '코스닥': 3, '나스닥': 3, 's&p': 3, '증시': 3,
        '달러': 3, '국채': 3,
        '트럼프': 3, '푸틴': 3, '시진핑': 3, '김정은': 3,
    }
    for k, w in keywords.items():
        if k in t:
            score += w
    return score


def summarize_news_title(title: str) -> tuple[str, str, str, str]:
    t = title.strip()
    rules = [
        (
            '관세',
            '핵심: 관세가 올라간다는 뜻입니다. 물건을 수출할 때 더 많은 돈을 내야 한다는 얘기입니다.',
            '의미: 삼성이나 LG 같은 큰 회사들이 장난감을 팔 때 비용이 더 커집니다. 그럼 이익이 줄어들 수 있어요.',
            '체크: 한국 회사들이 미국이나 다른 나라에 물건을 많이 팔기 때문에 관세 소식을 정말 자세히 봐야 합니다.',
            '제 의견: 관세는 뉴스가 나온 후 2~3일 더 지켜봐야 돼요. 한 번 뉴스 떴다고 끝이 아니니까요.'
        ),
        (
            '금리',
            '핵심: 금리가 올라가거나 내려간다는 뜻입니다. 은행에 돈을 맡길 때 받는 이자 같은 거요.',
            '의미: 금리가 올라가면 돈을 빌려 쓰는 회사들이 더 많이 내야 해요. 그럼 회사 이익이 줄어들 수 있습니다.',
            '체크: 달러 환율과 채권 금리가 함께 움직이는지 봐야 해요. 그럼 시장이 어떻게 될지 더 쉽게 알 수 있어요.',
            '제 의견: 금리 뉴스는 한 번으로 끝나지 않아요. 2~3일 동안 계속 주의 깊게 봐야 합니다.'
        ),
        (
            '환율',
            '핵심: 원화와 달러의 교환 비율이 바뀐다는 뜻입니다. 달러가 올라가면 한국 돈이 약해진다는 거예요.',
            '의미: 한국 회사가 미국에 물건을 팔면 원화로 받는 돈이 줄어들어요. 반대로 물건을 싸게 만들 수 있어서 도움도 되고요.',
            '체크: 외국 사람들이 한국 주식을 사거나 팔 때 환율이 얼마나 영향을 주는지 봐야 해요.',
            '제 의견: 환율은 숫자가 얼마 올랐냐보다 빠르게 변하는 게 더 중요해요. 빠르게 변하면 위험신호입니다.'
        ),
        (
            '반도체',
            '핵심: 반도체(스마트폰이나 컴퓨터에 들어가는 아주 작은 부품) 소식이 나왔다는 뜻입니다.',
            '의미: 반도체 소식은 삼성, SK하이닉스 같은 큰 회사에 직접 영향을 줘요. 그럼 한국 전체 주가에도 미쳐요.',
            '체크: 반도체 수요가 정말 늘어나는 건지, 아니면 그냥 하루 뉴스인지 구분해서 봐야 합니다.',
            '제 의견: 반도체는 장기로 수요가 늘어날지를 보는 게 가장 중요해요. 한 번 소식보다요.'
        ),
        (
            '실적',
            '핵심: 회사가 얼마나 돈을 벌었는지 발표한 것입니다. 좋은 결과인지 나쁜 결과인지를 뜻해요.',
            '의미: 실적이 좋으면 주가가 올라가고, 나쁘면 내려가요. 아주 중요한 신호입니다.',
            '체크: 실적이 시장이 기대했던 것보다 좋았는지 나빴는지를 봐야 해요. 그게 더 중요합니다.',
            '제 의견: 실적 뉴스가 나왔을 때는 하루 반응보다 그 다음날 움직임을 봐야 돼요. 정확해집니다.'
        ),
        (
            '유가',
            '핵심: 석유 값이 올라가거나 내려간다는 뜻입니다. 세계 모든 나라의 물가에 영향을 줘요.',
            '의미: 유가가 올라가면 휘발유, 택시, 배송비가 모두 비싸져요. 그럼 회사 비용이 늘어납니다.',
            '체크: 환율과 유가가 같이 올라가는지 봐야 해요. 같이 올라가면 한국 회사들 부담이 훨씬 커집니다.',
            '제 의견: 유가는 한두 번 변해도 괜찮아요. 추세가 바뀌는 게 더 중요합니다. 계속 올라갈 건지 내려갈 건지요.'
        ),
        (
            '나스닥',
            '핵심: 미국의 기술주 지수가 올라가거나 내려갔다는 뜻입니다. 애플, 구글, 마이크로소프트 같은 회사들이요.',
            '의미: 미국 기술주가 올라가면 한국의 AI, 반도체 회사들도 주가가 올라갈 가능성이 커집니다.',
            '체크: 정말 미국 기술주 전체가 올라갔는지, 아니면 몇 개 회사만 올라갔는지 구분해서 봐야 합니다.',
            '제 의견: 미국에서 좋은 소식이 나도 한국에선 반대가 될 수 있어요. 항상 신중하게 봐야 합니다.'
        ),
        (
            '코스피',
            '핵심: 한국 큰 회사들(삼성, SK, 현대 같은)의 주가 지수가 변했다는 뜻입니다.',
            '의미: 코스피가 올라가면 한국 경제가 잘 나간다는 신호예요. 외국인도 한국 주식을 사고 싶다는 뜻이기도 해요.',
            '체크: 원화 환율과 외국인이 얼마나 사고 파는지를 함께 봐야 해요. 그럼 진짜 강한 건지 약한 건지 알 수 있습니다.',
            '제 의견: 코스피 뉴스 하나보다 계속 올라가는 흐름이 중요해요. 한 번 올랐다고 끝이 아니니까요.'
        ),
        (
            '코스닥',
            '핵심: 한국의 작은 회사, 새로운 회사들(스타트업 같은)의 주가 지수가 변했다는 뜻입니다.',
            '의미: 코스닥이 올라가면 젊은 세대, 벤처 회사들이 잘 나간다는 신호예요. 위험도는 높지만요.',
            '체크: 코스닥이 올라갈 때 거래량도 함께 늘어나는지 봐야 해요. 거래량이 없으면 진짜가 아니거든요.',
            '제 의견: 코스닥은 재미있는 테마가 많지만 아주 빠르게 변해요. 계속 지켜봐야 합니다.'
        ),
    ]
    for keyword, line1, line2, line3, opinion in rules:
        if keyword.lower() in t.lower():
            return line1, line2, line3, opinion
    return (
        '핵심: 시장에 영향을 줄 수 있는 소식이 나왔습니다.',
        '의미: 이 소식이 어떤 회사들한테 좋은지 나쁜지 생각해봐야 합니다.',
        '체크: 이 뉴스가 그 다음날, 그 다음주에도 계속 영향을 주는지 지켜봐야 합니다.',
        '제 의견: 뉴스 제목만 보지 말고 실제로 주가가 어떻게 움직이는지 봐야 가장 정확해요.'
    )


NEWS_CUTOFF_SCORE = 5
NEWS_PER_CATEGORY_CAP = 2
NEWS_TOTAL_CAP = 12  # 전체 상한: 카테고리별 cap 통과 후 점수순 상위 N건만
NEWS_DUP_THRESHOLD = 0.40  # min-overlap ratio: overlap / min(|A|,|B|) >= threshold → 같은 사안으로 간주


def _title_tokens(title: str) -> set:
    return set(re.findall(r'[가-힣0-9A-Za-z]{2,}', title.lower()))


def _is_similar_to_seen(tokens: set, seen_token_sets: list, threshold: float = NEWS_DUP_THRESHOLD) -> bool:
    if not tokens:
        return False
    for t in seen_token_sets:
        if not t:
            continue
        denom = min(len(tokens), len(t))
        if not denom:
            continue
        if len(tokens & t) / denom >= threshold:
            return True
    return False


def briefing_cutoff(mode: str, now: datetime) -> datetime:
    """뉴스 발행시각 하한선: 이 시각 이후 발행분만 포함."""
    if mode == 'morning':
        return (now - timedelta(days=1)).replace(hour=19, minute=0, second=0, microsecond=0)
    return now.replace(hour=7, minute=0, second=0, microsecond=0)


def _normalize_news_key(title: str) -> str:
    return re.sub(r'[^0-9A-Za-z가-힣]+', '', title).lower()


def _parse_iso(s: Optional[str]) -> datetime:
    if not s:
        return datetime.min.replace(tzinfo=KST)
    try:
        dt = datetime.fromisoformat(s)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=KST)
        return dt
    except Exception:
        return datetime.min.replace(tzinfo=KST)


def load_news_history() -> dict:
    return load_json(NEWS_HISTORY_FILE, {'items': []})


def gc_news_history(history: dict, now: datetime) -> dict:
    cutoff = now - timedelta(days=NEWS_HISTORY_RETENTION_DAYS)
    history['items'] = [
        it for it in history.get('items', [])
        if _parse_iso(it.get('sent_at')) >= cutoff
    ]
    return history


def recent_history_keys(history: dict, now: datetime, lookback_hours: int = NEWS_HISTORY_LOOKBACK_HOURS) -> set:
    cutoff = now - timedelta(hours=lookback_hours)
    return {
        it['key'] for it in history.get('items', [])
        if it.get('key') and _parse_iso(it.get('sent_at')) >= cutoff
    }


def recent_history_token_sets(history: dict, now: datetime,
                              lookback_hours: int = NEWS_HISTORY_LOOKBACK_HOURS) -> list:
    """최근 발송 제목의 토큰 집합 — 완전일치(key)로는 못 막는 '같은 사안 다른 제목'용."""
    cutoff = now - timedelta(hours=lookback_hours)
    out = []
    for it in history.get('items', []):
        title = (it.get('title') or '').strip()
        if not title or _parse_iso(it.get('sent_at')) < cutoff:
            continue
        tokens = _title_tokens(title)
        if tokens:
            out.append(tokens)
    return out


def mark_news_sent_from_pending(mode: str, today_key: str, now: datetime) -> int:
    """발송 성공 후 pending selection을 history로 옮기고 pending에서 제거. 반환: 추가된 항목 수."""
    pending = load_json(PENDING_SELECTION_FILE, {})
    selection = pending.get(mode) or {}
    if selection.get('date') != today_key:
        return 0
    articles = selection.get('articles', []) or []
    history = load_news_history()
    history = gc_news_history(history, now)
    sent_at = now.isoformat()
    added = 0
    for a in articles:
        key = a.get('key')
        if not key:
            continue
        history['items'].append({
            'key': key,
            'title': a.get('title'),
            'url': a.get('url'),
            'mode': mode,
            'sent_at': sent_at,
        })
        added += 1
    save_json(NEWS_HISTORY_FILE, history)
    pending.pop(mode, None)
    save_json(PENDING_SELECTION_FILE, pending)
    return added


def collect_news_articles(
    cutoff_score: int = NEWS_CUTOFF_SCORE,
    per_category_cap: int = NEWS_PER_CATEGORY_CAP,
    published_after: Optional[datetime] = None,
    exclude_keys: Optional[set] = None,
    exclude_token_sets: Optional[list] = None,
    feeds: Optional[list] = None,
) -> list[dict]:
    """Return structured news articles passing cutoff + per-category cap + fuzzy title dedup.

    published_after: 발행일이 이보다 이전인 항목 제외 (pub_dt 없으면 통과 — history에서 막힘)
    exclude_keys: normalized title이 이 set에 있으면 제외 (최근 발송 이력, 완전일치)
    exclude_token_sets: 최근 발송 이력 제목의 토큰 집합 — fuzzy 유사도로도 제외
    feeds: 사용할 (category, url) 목록. 기본 NEWS_FEEDS, 토요일은 OVERSEAS_NEWS_FEEDS
    """
    exclude_keys = exclude_keys or set()
    seen_norm = set()
    seen_token_sets = list(exclude_token_sets or [])
    result = []
    for category, url in (feeds or NEWS_FEEDS):
        try:
            items = parse_google_news_feed(url)
        except Exception as e:
            print(f"[briefing_mail:news_feed:{category}] {type(e).__name__}: {e}", file=sys.stderr)
            continue
        candidates = []
        for item in items:
            title = item['title']
            normalized = re.sub(r'[^0-9A-Za-z가-힣]+', '', title).lower()
            if not normalized:
                continue
            pub_dt = item.get('pub_dt')
            if published_after and pub_dt and pub_dt < published_after:
                continue
            if normalized in exclude_keys:
                continue
            score = news_priority(title)
            if score < cutoff_score:
                continue
            candidates.append({
                'category': category,
                'title': title,
                'link': item.get('link', ''),
                'source': item.get('source', ''),
                'description': item.get('description', ''),
                'score': score,
                '_norm': normalized,
                '_tokens': _title_tokens(title),
            })
        candidates.sort(key=lambda x: x['score'], reverse=True)
        cat_accepted = 0
        for c in candidates:
            if cat_accepted >= per_category_cap:
                break
            if c['_norm'] in seen_norm:
                continue
            if _is_similar_to_seen(c['_tokens'], seen_token_sets):
                continue
            seen_norm.add(c['_norm'])
            seen_token_sets.append(c['_tokens'])
            result.append({k: v for k, v in c.items() if not k.startswith('_')})
            cat_accepted += 1
    if NEWS_TOTAL_CAP and len(result) > NEWS_TOTAL_CAP:
        result = sorted(result, key=lambda x: x['score'], reverse=True)[:NEWS_TOTAL_CAP]
    return result


def get_simple_news(limit: Optional[int] = None) -> list[str]:
    """Legacy text-line output. Applies new cutoff/cap logic. `limit` caps total items (no forced min)."""
    picked = collect_news_articles()
    if limit is not None:
        picked = sorted(picked, key=lambda x: x['score'], reverse=True)[:limit]
    if not picked:
        return ['- 지금 기준 통과한 핵심 경제 뉴스가 없습니다.']
    lines = []
    for item in picked:
        lines.append(f"- [{item['category']}] {item['title']}")
        if item.get('source'):
            lines.append(f"  출처: {item['source']}")
        if item.get('link'):
            lines.append(f"  원문: {item['link']}")
        lines.append('')
    if lines and lines[-1] == '':
        lines.pop()
    return lines


def format_change(symbol: str, prev_close: float, close: float) -> str:
    diff = close - prev_close
    pct = (diff / prev_close * 100) if prev_close else 0.0
    arrow = '▲' if diff > 0 else '▼' if diff < 0 else '-'
    return f'- {symbol}: {close:,.2f} ({arrow} {abs(diff):,.2f}, {abs(pct):.2f}%)'


def get_naver_index(code: str, page_url: str, domestic: bool = False):
    try:
        base = 'https://m.stock.naver.com/api' if domestic else 'https://api.stock.naver.com'
        raw = fetch_bytes(f'{base}/index/{code}/basic')
        data = json.loads(raw.decode('utf-8', 'ignore'))
        name = data.get('indexName') or data.get('stockName') or code
        price = data.get('closePrice', '')
        change = data.get('compareToPreviousClosePrice', '')
        pct = data.get('fluctuationsRatio', '')
        if not price:
            return None
        try:
            change_f = float(change.replace(',', ''))
            arrow = '▲' if change_f > 0 else '▼' if change_f < 0 else '-'
            suffix = f' ({arrow} {abs(change_f):,.2f}, {abs(float(pct)):.2f}%)'
        except (ValueError, TypeError):
            suffix = ''
        return f'- {name}: {price}{suffix}'
    except Exception as e:
        print(f"[briefing_mail:naver_index:{code}] {type(e).__name__}: {e}", file=sys.stderr)
        return None


def get_market_snapshot(include_domestic: bool = False) -> list[str]:
    lines = []
    fx_lines = []
    if include_domestic:
        for code, page_url in NAVER_DOMESTIC_INDEX_FEEDS:
            line = get_naver_index(code, page_url, domestic=True)
            if line:
                lines.append(line)
    for code, page_url in NAVER_INDEX_FEEDS:
        line = get_naver_index(code, page_url)
        if line:
            lines.append(line)
    for symbol, url in MARKET_FEEDS:
        try:
            raw = fetch_bytes(url)
            data = json.loads(raw.decode('utf-8', 'ignore'))
            result = data['chart']['result'][0]
            closes = [x for x in result['indicators']['quote'][0]['close'] if x is not None]
            if len(closes) >= 2:
                fx_lines.append(format_change(symbol, closes[-2], closes[-1]))
            elif len(closes) == 1:
                fx_lines.append(f'- {symbol}: {closes[-1]:,.2f}')
        except Exception as e:
            print(f"[briefing_mail:market_feed:{symbol}] {type(e).__name__}: {e}", file=sys.stderr)
            continue
    merged = lines + fx_lines
    return merged or ['- 주요 시장 지표를 불러오지 못했습니다.']


def parse_investing_quote(url: str, fallback_title: str) -> Optional[str]:
    try:
        html = fetch(url)
        title_match = re.search(r'<title>\s*([^<]+?)\s*</title>', html, re.IGNORECASE)
        title = unescape(title_match.group(1)).strip() if title_match else fallback_title

        price_match = re.search(r'data-test="instrument-price-last">\s*([^<]+?)\s*<', html)
        change_match = re.search(r'data-test="instrument-price-change">\s*([^<]+?)\s*<', html)
        pct_match = re.search(r'data-test="instrument-price-change-percent">\s*([^<]+?)\s*<', html)

        price = unescape(price_match.group(1)).strip() if price_match else ''
        change = unescape(change_match.group(1)).strip() if change_match else ''
        pct = unescape(pct_match.group(1)).strip() if pct_match else ''

        if price:
            clean_title = re.sub(r'\s*선물 시세\s*$', '', title).strip()
            change_clean = change.replace('+', '').strip()
            pct_clean = pct.replace('(', '').replace(')', '').strip()
            arrow = '-'
            if change.startswith('+'):
                arrow = '▲'
            elif change.startswith('-'):
                arrow = '▼'
            suffix = ''
            if change_clean and pct_clean:
                suffix = f' ({arrow} {change_clean}, {pct_clean})'
            elif change_clean:
                suffix = f' ({arrow} {change_clean})'
            elif pct_clean:
                suffix = f' ({pct_clean})'
            return f'- {clean_title}: {price}{suffix}'
    except Exception as e:
        print(f"[briefing_mail:investing_quote:{fallback_title}] {type(e).__name__}: {e}", file=sys.stderr)
        return None
    return None


def get_kospi_night_futures() -> list[str]:
    line = parse_investing_quote(KOSPI_FUTURES_INVESTING_URL, '코스피200 선물 (F)')
    if line:
        line = re.sub(r'^-\s*[^:]+:', '- 코스피 선물:', line)
        return [line]
    return ['- 코스피 야간선물 데이터를 불러오지 못했습니다.']


def get_msci_korea() -> list[str]:
    line = parse_investing_quote(MSCI_KOREA_INVESTING_URL, 'MSCI')
    if line:
        line = re.sub(r'^-\s*[^:]+:', '- MSCI:', line)
        return [line]
    return ['- MSCI 데이터를 불러오지 못했습니다.']


def was_already_sent(mode: str, today_key: str) -> bool:
    state = load_json(SEND_STATE, {})
    return state.get(mode) == today_key


def mark_sent(mode: str, today_key: str):
    state = load_json(SEND_STATE, {})
    state[mode] = today_key
    save_json(SEND_STATE, state)


IPO_KIND_LABEL = {'subscription': '청약', 'listing': '상장'}
IPO_ACTION_LABEL = {'created': '신규', 'updated': '변경', 'recreated': '변경', 'deleted': '취소'}


def _format_ipo_change(item: dict) -> Optional[str]:
    kind = (item.get('kind') or '').strip()
    action = (item.get('action') or '').strip()
    name = (item.get('name') or '').strip()
    if not name or action not in IPO_ACTION_LABEL:
        return None
    kind_label = IPO_KIND_LABEL.get(kind, kind or '?')
    new_date = (item.get('new_date') or '').strip()
    old_date = (item.get('old_date') or '').strip()
    if action in ('updated', 'recreated') and old_date and new_date and old_date != new_date:
        return f'- [{kind_label}] {name} (변경, {old_date} → {new_date})'
    if action == 'deleted':
        return f'- [{kind_label}] {name} (취소)' + (f' — 기존 {old_date}' if old_date else '')
    if new_date:
        return f'- [{kind_label}] {name} ({IPO_ACTION_LABEL[action]}, {new_date})'
    return f'- [{kind_label}] {name} ({IPO_ACTION_LABEL[action]})'


def read_ipo_changes_for_brief() -> list[str]:
    """IPO sync state(`ipo_calendar_sync.json`)에서 아직 브리핑에 노출되지 않은 changes를 줄 목록으로 반환.

    sync 자체는 launchd(`ai.openclaw.stock.ipo-calendar-sync`, 매주 금 17:00)가 책임짐. 여기서는 읽기만.
    같은 sync 결과(last_run_at 동일)는 한 번만 노출하기 위해 `briefing_ipo_seen.json` dedup 사용.
    """
    state = load_json(IPO_SYNC_STATE, {})
    last_run = (state.get('last_run_at') or '').strip()
    if not last_run:
        return []
    seen = load_json(IPO_SEEN_STATE, {})
    if seen.get('last_seen_run_at') == last_run:
        return []
    lines = []
    for item in state.get('last_changes', []) or []:
        line = _format_ipo_change(item)
        if line:
            lines.append(line)
    return lines


def mark_ipo_changes_seen():
    state = load_json(IPO_SYNC_STATE, {})
    last_run = (state.get('last_run_at') or '').strip()
    if not last_run:
        return
    save_json(IPO_SEEN_STATE, {'last_seen_run_at': last_run})


def get_tomorrow_listings(now: datetime) -> list[str]:
    """내일 [신규상장] 종목을 캘린더에서 추출 (D-1 임박 알림용)."""
    start = now.astimezone(KST).replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
    end = start + timedelta(days=1)
    try:
        out = run([
            'gog', 'calendar', 'events', 'mini.snowoyh@gmail.com',
            '--from', start.isoformat(), '--to', end.isoformat(), '--json'
        ])
        data = json.loads(out)
    except Exception as e:
        print(f"[briefing_mail:tomorrow_listings] {type(e).__name__}: {e}", file=sys.stderr)
        return []
    lines = []
    for ev in data.get('events', []):
        summary = (ev.get('summary') or '').strip()
        if not summary.startswith('[신규상장]'):
            continue
        name = summary.replace('[신규상장]', '', 1).strip()
        date_str = (ev.get('start', {}) or {}).get('date') or ''
        lines.append(f'- {name} ({date_str})' if date_str else f'- {name}')
    return lines


def build_morning() -> tuple[str, str]:
    now = datetime.now(KST)
    events = get_today_events(now)
    ipo_changes = read_ipo_changes_for_brief()
    tomorrow_listings = get_tomorrow_listings(now)
    body = [
        f'관리자님, 좋은 아침입니다.',
        f'{now:%Y-%m-%d} 오전 브리핑만 간단히 정리드리겠습니다.',
        ''
    ]
    body += section('오늘 일정', events or ['- 오늘 등록된 일정이 없습니다.'])
    body += section('내일 상장', tomorrow_listings)
    body += section('공모일정 변경', ipo_changes)
    body += section('시장 체크', get_kospi_night_futures() + get_msci_korea() + get_market_snapshot())
    body += section('중요 뉴스 요약', get_simple_news())
    if body and body[-1] == '':
        body.pop()
    return (f'[오전 브리핑] {now:%Y-%m-%d}', '\n'.join(body))


def build_evening() -> tuple[str, str]:
    now = datetime.now(KST)
    tomorrow = now + timedelta(days=1)
    events = get_today_events(tomorrow)
    ipo_changes = read_ipo_changes_for_brief()
    tomorrow_listings = get_tomorrow_listings(now)
    body = [
        f'관리자님, 오늘 마감 브리핑입니다.',
        f'{now:%Y-%m-%d} 저녁 기준으로 짧게 정리드리겠습니다.',
        ''
    ]
    body += section('내일 일정', events or ['- 내일 등록된 일정이 없습니다.'])
    body += section('내일 상장', tomorrow_listings)
    body += section('공모일정 변경', ipo_changes)
    body += section('오늘 증시 간단 요약', get_market_snapshot(include_domestic=True))
    body += section('핵심 뉴스', get_simple_news(3))
    if body and body[-1] == '':
        body.pop()
    return (f'[오후 브리핑] {now:%Y-%m-%d}', '\n'.join(body))


def send_mail(subject: str, body: str, html: bool = False) -> str:
    base = ['gog', 'gmail', 'send', '--to', RECIPIENT, '--subject', subject]
    if html:
        cmd = base + ['--body-html', body]
        p = subprocess.run(cmd, text=True, capture_output=True)
    else:
        cmd = base + ['--body-file', '-']
        p = subprocess.run(cmd, input=body, text=True, capture_output=True)
    if p.returncode != 0:
        raise RuntimeError(p.stderr.strip() or p.stdout.strip())
    return p.stdout.strip()


WEEKDAY_KR = ['월', '화', '수', '목', '금', '토', '일']


def _empty_market_block(name: str, err: str) -> dict:
    return {
        'channel': name, 'recap_expected_today': False, 'available_today': False,
        'video': None, 'transcript': '', 'transcript_status': 'error', 'error': err,
    }


def get_us_market_block() -> dict:
    """오전 브리핑용 미국증시 요약 블록. 실패해도 브리핑이 죽지 않도록 예외를 삼킨다."""
    try:
        sys.path.insert(0, str(Path(__file__).resolve().parent))
        import youtube_briefing_digest  # type: ignore
        return youtube_briefing_digest.get_us_summary()
    except Exception as e:
        return _empty_market_block('미국증시 요약', f'{type(e).__name__}: {e}')


def get_behive_market_block() -> dict:
    """오전 브리핑용 국내 증시 시황 블록(비하이브 '주식시황'). best-effort — 없어도 브리핑 진행."""
    try:
        sys.path.insert(0, str(Path(__file__).resolve().parent))
        import youtube_briefing_digest  # type: ignore
        return youtube_briefing_digest.get_behive_market_summary()
    except Exception as e:
        return _empty_market_block('국내 증시 시황', f'{type(e).__name__}: {e}')


def get_behive_close_block() -> dict:
    """오후 브리핑용 마감시황 블록(비하이브 '마감시황'). best-effort — 없어도 브리핑 진행."""
    try:
        sys.path.insert(0, str(Path(__file__).resolve().parent))
        import youtube_briefing_digest  # type: ignore
        return youtube_briefing_digest.get_behive_close_summary()
    except Exception as e:
        return _empty_market_block('마감시황', f'{type(e).__name__}: {e}')


def build_prepare_payload(mode: str, final: bool = False) -> dict:
    if mode not in {'morning', 'evening'}:
        raise SystemExit('mode must be morning or evening')
    now = datetime.now(KST)
    target = now if mode == 'morning' else (now + timedelta(days=1))
    # 토요일 오전은 해외 전용 — 일정·IPO는 수집하지 않는다. gog 3콜이 빠지고,
    # IPO seen을 선점하지 않아 금요일 sync 결과가 월요일 아침에 정상 노출된다.
    overseas_only = (mode == 'morning' and now.weekday() == 5)
    if overseas_only:
        events, ipo_changes, tomorrow_listings = [], [], []
    else:
        events = get_today_events(target)
        ipo_changes = read_ipo_changes_for_brief()
        tomorrow_listings = get_tomorrow_listings(now)

    cutoff = briefing_cutoff(mode, now)
    history = load_news_history()
    history = gc_news_history(history, now)
    save_json(NEWS_HISTORY_FILE, history)
    exclude_keys = recent_history_keys(history, now)
    exclude_token_sets = recent_history_token_sets(history, now)
    articles = collect_news_articles(
        published_after=cutoff,
        exclude_keys=exclude_keys,
        exclude_token_sets=exclude_token_sets,
        feeds=OVERSEAS_NEWS_FEEDS if overseas_only else None,
    )

    today_key = now.strftime('%Y-%m-%d')
    already_sent = was_already_sent(mode, today_key)

    # 이미 발송된 뒤(폴백이 스킵할 예정)에 pending을 덮어쓰면 발송본과 무관한 목록이 남아
    # "같은 뉴스가 또 와?" 디버깅 때 잘못된 증거를 보게 된다.
    if not already_sent:
        pending = load_json(PENDING_SELECTION_FILE, {})
        pending[mode] = {
            'date': today_key,
            'articles': [
                {
                    'key': _normalize_news_key(a['title']),
                    'title': a['title'],
                    'url': a.get('link', ''),
                }
                for a in articles
            ],
        }
        save_json(PENDING_SELECTION_FILE, pending)

    # 오전: @futuresnow(상세 미국) + 비하이브 '주식시황'(국내+미국) → LLM이 해외/국내로 재정리.
    # 오후: 비하이브 '마감시황'(국내 장 마감 해설) → best-effort 마감시황 카드.
    us_market = None
    behive_market = None
    behive_close = None
    us_send_directive = 'proceed'
    if mode == 'morning':
        us_market = get_us_market_block()
        behive_market = get_behive_market_block()
        any_available = us_market.get('available_today') or behive_market.get('available_today')
        any_expected = us_market.get('recap_expected_today') or behive_market.get('recap_expected_today')
        if any_available:
            us_send_directive = 'proceed'          # 해외/국내 소스 중 하나라도 게시 → 진행
        elif any_expected and not final:
            us_send_directive = 'wait'             # 평일인데 둘 다 미게시 → 다음 폴백 재시도
        else:
            us_send_directive = 'proceed'          # 주말 등 예정 없음 / 최종 발송
    elif mode == 'evening':
        behive_close = get_behive_close_block()    # best-effort, 발송 게이트 없음

    return {
        'mode': mode,
        'date': today_key,
        'date_target': target.strftime('%Y-%m-%d'),
        'weekday_kr': WEEKDAY_KR[now.weekday()],
        'events': events,
        'ipo_changes': ipo_changes,
        'tomorrow_listings': tomorrow_listings,
        'articles': articles,
        'overseas_only': overseas_only,
        'news_window': {
            'published_after': cutoff.isoformat(),
            'history_lookback_hours': NEWS_HISTORY_LOOKBACK_HOURS,
            'excluded_recent_count': len(exclude_keys),
        },
        'market': {
            'futures': get_kospi_night_futures() if mode == 'morning' else [],
            'msci': get_msci_korea() if mode == 'morning' else [],
            'indices': get_market_snapshot(include_domestic=(mode == 'evening')),
        },
        'already_sent': already_sent,
        'us_market': us_market,
        'behive_market': behive_market,
        'behive_close': behive_close,
        'us_send_directive': us_send_directive,
    }


def cmd_prepare(mode: str, final: bool = False) -> int:
    payload = build_prepare_payload(mode, final=final)
    print(json.dumps(payload, ensure_ascii=False, indent=2))
    return 0


def html_escape(s: str) -> str:
    return (str(s)
            .replace('&', '&amp;')
            .replace('<', '&lt;')
            .replace('>', '&gt;')
            .replace('"', '&quot;')
            .replace("'", '&#39;'))


HTML_STYLES = {
    'wrap': "font-family:-apple-system,BlinkMacSystemFont,'Apple SD Gothic Neo',sans-serif;font-size:14px;color:#222;max-width:680px;line-height:1.55;",
    'greet': 'color:#444;margin-bottom:18px;',
    'card': 'margin-bottom:18px;border:1px solid #e5e5e5;border-radius:8px;padding:14px 16px;background:#fafafa;',
    'card_title': 'font-size:14px;font-weight:700;color:#111;margin-bottom:10px;',
    'line': 'color:#333;font-size:13px;margin:3px 0;',
    'market_line': 'color:#333;font-size:13px;margin:4px 0;',
    'market_label': 'color:#444;',
    'market_value': "font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#111;",
    'pos': 'color:#d24f4f;font-weight:600;',
    'neg': 'color:#1565c0;font-weight:600;',
    'flat': 'color:#666;',
    'news_section': 'font-size:14px;font-weight:700;color:#111;margin:24px 0 10px;border-left:3px solid #333;padding-left:8px;',
    'article': 'margin-bottom:12px;border:1px solid #eee;border-radius:6px;padding:12px 14px;background:#fff;',
    'category': 'display:inline-block;background:#eef3ff;color:#0066cc;font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;margin-right:6px;vertical-align:middle;',
    'title': 'color:#111;font-weight:600;font-size:14px;',
    'source': 'color:#888;font-size:12px;margin:6px 0 8px;',
    'link': 'color:#0066cc;text-decoration:none;',
    'summary': 'color:#333;font-size:13px;line-height:1.6;',
}


def _strip_bullet(text: str) -> str:
    return re.sub(r'^\s*-\s*', '', text).strip()


def _split_market_line(line: str) -> tuple[str, str, str]:
    """`- 코스피: 6,641.02 (▲ 25.99, 0.39%)` → (label, value, change)"""
    text = _strip_bullet(line)
    label, _, rest = text.partition(':')
    rest = rest.strip()
    change = ''
    value = rest
    if '(' in rest and rest.rstrip().endswith(')'):
        head, _, tail = rest.rpartition('(')
        value = head.strip()
        change = tail.rstrip(')').strip()
    return label.strip(), value, change


def _market_change_color(change: str) -> str:
    if '▲' in change:
        return HTML_STYLES['pos']
    if '▼' in change:
        return HTML_STYLES['neg']
    return HTML_STYLES['flat']


def _card(title: str, inner_html: str) -> str:
    S = HTML_STYLES
    return (
        f'<div style="{S["card"]}">'
        f'<div style="{S["card_title"]}">{html_escape(title)}</div>'
        f'{inner_html}'
        f'</div>'
    )


def _list_card(title: str, lines: list, empty: Optional[str] = None) -> str:
    if not lines:
        if empty is None:
            return ''
        lines = [empty]
    S = HTML_STYLES
    body = '\n'.join(
        f'<div style="{S["line"]}">{html_escape(_strip_bullet(l))}</div>'
        for l in lines
    )
    return _card(title, body)


def _market_card(title: str, lines: list) -> str:
    if not lines:
        return ''
    S = HTML_STYLES
    rows = []
    for l in lines:
        label, value, change = _split_market_line(l)
        if not label and not value:
            rows.append(f'<div style="{S["line"]}">{html_escape(_strip_bullet(l))}</div>')
            continue
        right = html_escape(value)
        if change:
            color = _market_change_color(change)
            right = (
                f'{html_escape(value)} '
                f'<span style="{color}">({html_escape(change)})</span>'
            )
        else:
            # 괄호 없이 값 자체에 ▲/▼가 있으면(예: 비하이브 야간선물 "▼ 1.15%") 값에 색 적용
            color = _market_change_color(value)
            if color != HTML_STYLES['flat']:
                right = f'<span style="{color}">{html_escape(value)}</span>'
        rows.append(
            f'<div style="{S["market_line"]}">'
            f'<span style="{S["market_label"]}">{html_escape(label)}</span>'
            f' &nbsp; '
            f'<span style="{S["market_value"]}">{right}</span>'
            f'</div>'
        )
    return _card(title, '\n'.join(rows))


_US_GROUP_ICONS = [
    ('지수', '📊'), ('특징주', '🏢'), ('섹터', '🏢'), ('테마', '🏢'),
    ('연준', '🏦'), ('금리', '🏦'), ('일정', '🗓'), ('지표', '🗓'),
    ('전문가', '💬'), ('시각', '💬'), ('전망', '💬'), ('이슈', '📰'),
    ('기타', '🗒'), ('그 외', '🗒'),
]


def _us_group_icon(label: str) -> str:
    for key, icon in _US_GROUP_ICONS:
        if key in label:
            return icon
    return '📰'


def _emphasize(text: str) -> str:
    """`**강조**` 마크다운을 굵게+형광펜 하이라이트 <span>으로 변환. 나머지는 html_escape."""
    parts = re.split(r'\*\*(.+?)\*\*', str(text))
    out = []
    for i, p in enumerate(parts):
        if i % 2 == 1:  # ** ** 안쪽 — 배경 없이 글자만 굵게 + 진한 강조색
            out.append(
                f'<span style="font-weight:700;color:#c62828;">{html_escape(p)}</span>'
            )
        else:
            out.append(html_escape(p))
    return ''.join(out)


def _emphasize_multiline(text: str) -> str:
    return '<br>'.join(_emphasize(line) for line in str(text).split('\n'))


def _us_items_html(items: list) -> str:
    lis = []
    for it in items:
        s = _strip_bullet(str(it).strip())
        if not s:
            continue
        # 불릿 앞 '🔥' = 국내 증시 영향 큰 항목 → 강조 배경 밴드
        if s.startswith('🔥'):
            s = s[1:].strip()
            lis.append(
                '<li style="margin:6px 0;padding:4px 7px;line-height:1.7;color:#1a1a1a;'
                'font-size:13.5px;background:#fff7f6;border-radius:4px;">'
                f'🔥 {_emphasize(s)}</li>'
            )
        else:
            lis.append(
                '<li style="margin:6px 0;padding-left:3px;line-height:1.7;color:#1a1a1a;'
                f'font-size:13.5px;">{_emphasize(s)}</li>'
            )
    return f'<ul style="margin:2px 0 0;padding-left:19px;">{chr(10).join(lis)}</ul>'


_US_PALETTE = {'header': '#0d47a1', 'group': '#1565c0', 'accent': '#1565c0',
               'bg': '#eef4ff', 'border': '#c5d4f0', 'rule': '#cfd8ea'}
_BEHIVE_PALETTE = {'header': '#1b5e20', 'group': '#2e7d32', 'accent': '#2e7d32',
                   'bg': '#eef7ee', 'border': '#c5e0c5', 'rule': '#cfe3cf'}
_CLOSE_PALETTE = {'header': '#5d4037', 'group': '#8d6e63', 'accent': '#8d6e63',
                  'bg': '#f7f1ec', 'border': '#e0d3c8', 'rule': '#e6dbd1'}


def _video_summary_card(heading: str, summary, market: dict, pal: dict) -> str:
    """시황 영상 요약 카드 (미국증시·국내증시 공용).

    summary: 카테고리 그룹 리스트 [{"label": "지수", "items": [...]}, ...] 또는
             하위호환용 평문 문자열(줄바꿈으로 불릿 구분).
    pal: 색상 팔레트(_US_PALETTE / _BEHIVE_PALETTE).
    """
    video = (market or {}).get('video') or {}
    blocks = []
    if isinstance(summary, list):
        first = True
        for g in summary:
            if not isinstance(g, dict):
                continue
            label = str(g.get('label', '')).strip()
            items = [x for x in (g.get('items') or []) if str(x).strip()]
            if not items:
                continue
            mt = '0' if first else '12px'
            blocks.append(
                f'<div style="margin:{mt} 0 1px;font-size:12.5px;font-weight:700;'
                f'color:{pal["group"]};letter-spacing:-0.2px;">{_us_group_icon(label)} {html_escape(label)}</div>'
            )
            blocks.append(_us_items_html(items))
            first = False
    else:
        lines = [l for l in str(summary).split('\n') if l.strip()]
        blocks.append(_us_items_html(lines))

    src = ''
    if video.get('url'):
        title = html_escape(video.get('title', '영상'))
        src = (
            f'<div style="margin-top:11px;padding-top:9px;border-top:1px dashed {pal["rule"]};'
            'color:#8a93a6;font-size:12px;">출처: '
            f'<a href="{html_escape(video["url"])}" style="color:{pal["accent"]};text-decoration:none;" '
            f'target="_blank">{title} ↗</a></div>'
        )
    return (
        f'<div style="margin-bottom:20px;border:1px solid {pal["border"]};'
        f'border-left:4px solid {pal["accent"]};border-radius:8px;background-color:{pal["bg"]};'
        'padding:14px 16px 13px;">'
        f'<div style="font-size:15px;font-weight:700;color:{pal["header"]};margin-bottom:6px;'
        f'letter-spacing:-0.2px;">{heading}</div>'
        f'{"".join(blocks)}'
        f'{src}</div>'
    )


def _article_card(a: dict) -> str:
    S = HTML_STYLES
    title = html_escape(a.get('title', ''))
    cat = html_escape(a.get('category', ''))
    source = html_escape(a.get('source', ''))
    link = a.get('link', '')
    summary = a.get('summary') or ''
    if isinstance(summary, dict):
        summary = ' '.join(
            str(v) for v in (summary.get(k) for k in ('core', 'meaning', 'check', 'opinion')) if v
        )
    link_html = (
        f' | <a href="{html_escape(link)}" style="{S["link"]}" target="_blank">원문 ↗</a>'
        if link else ''
    )
    # 국내 증시에 큰 영향을 줄 뉴스: 빨강 강조 테두리 + 🔥 배지
    impact = bool(a.get('kr_impact'))
    article_style = S['article']
    badge = ''
    if impact:
        article_style += 'border-left:4px solid #c62828;background:#fff7f6;'
        badge = (
            '<span style="display:inline-block;background:#fdecea;color:#c62828;'
            'font-size:11px;font-weight:700;padding:2px 8px;border-radius:10px;'
            'margin-right:6px;vertical-align:middle;">🔥 국내증시 영향</span>'
        )
    parts = [f'<div style="{article_style}">']
    parts.append(
        f'<div>{badge}<span style="{S["category"]}">{cat}</span>'
        f'<span style="{S["title"]}">{title}</span></div>'
    )
    parts.append(f'<div style="{S["source"]}">출처: {source}{link_html}</div>')
    if summary:
        parts.append(
            f'<div style="{S["summary"]}">{_emphasize_multiline(summary)}</div>'
        )
    parts.append('</div>')
    return '\n'.join(parts)


def _briefing_footer_note(lines: list[str]) -> str:
    if not lines:
        return ''
    S = HTML_STYLES
    body = '<br>'.join(html_escape(line) for line in lines if str(line).strip())
    if not body:
        return ''
    return (
        f'<div style="margin-top:18px;padding-top:11px;border-top:1px solid #eee;'
        f'{S["source"]}">{body}</div>'
    )


def _evening_close_missing_notice(data: dict) -> str:
    """오후 브리핑에서 마감시황 카드가 없을 때 상단에 띄우는 안내 배너."""
    if data.get('close_summary'):
        return ''
    behive_close = data.get('behive_close') or {}
    if behive_close.get('available_today'):
        msg = '마감시황 요약을 생성하지 못했습니다.'
    else:
        msg = '비하이브 마감시황 영상이 아직 게시되지 않아 이번 브리핑에는 포함되지 않았습니다.'
    return (
        '<div style="margin:0 0 14px;padding:10px 13px;border-radius:8px;'
        'background:#f5efe9;border:1px solid #e0d3c8;color:#5d4037;'
        'font-size:13px;line-height:1.5;">'
        f'📕 마감시황 없음 — {html_escape(msg)}</div>'
    )


def _morning_missing_summary_notes(data: dict) -> list[str]:
    notes = []
    us_market = data.get('us_market') or {}
    behive_market = data.get('behive_market') or {}

    if not us_market.get('available_today'):
        notes.append('오선 요약 없음')
    elif not data.get('overseas_summary'):
        notes.append('오선 요약 없음')

    # 토요일 해외 전용 브리핑은 국내 시황을 애초에 넣지 않으므로 "없음" 각주가 노이즈다.
    if not data.get('overseas_only'):
        if not behive_market.get('available_today'):
            notes.append('비하이브 요약 없음')
        elif not data.get('domestic_summary'):
            notes.append('비하이브 요약 없음')

    return notes


def build_html_body(data: dict) -> str:
    S = HTML_STYLES
    mode = data.get('mode', 'morning')
    date = data.get('date', '')
    weekday = data.get('weekday_kr', '')
    events = data.get('events', []) or []
    ipo = data.get('ipo_changes', []) or []
    tomorrow_listings = data.get('tomorrow_listings', []) or []
    articles = data.get('articles', []) or []
    market = data.get('market', {}) or {}
    indices = market.get('indices', []) or []
    futures = market.get('futures', []) or []
    msci = market.get('msci', []) or []
    # 비하이브 장전 시황이 말한 값으로 교체 (스크랩값은 전날 것일 수 있음). 없으면 스크랩 유지.
    if data.get('kospi_futures_override'):
        futures = [data['kospi_futures_override']]
    if data.get('msci_override'):
        msci = [data['msci_override']]

    if mode == 'morning':
        greet_line1 = '관리자님, 좋은 아침입니다.'
        greet_line2 = f'{date} ({weekday}) 오전 브리핑입니다.'
        today_title = '오늘 일정'
        today_empty = '오늘 등록된 일정이 없습니다.'
        market_title = '시장 체크'
        market_lines = futures + msci + indices
    else:
        greet_line1 = '관리자님, 오늘 마감 브리핑입니다.'
        greet_line2 = f'{date} ({weekday}) 저녁 기준입니다.'
        today_title = '내일 일정'
        today_empty = '내일 등록된 일정이 없습니다.'
        market_title = '오늘 증시 요약'
        market_lines = indices

    out = [f'<div style="{S["wrap"]}">']
    out.append(
        f'<div style="{S["greet"]}">{html_escape(greet_line1)}<br>{html_escape(greet_line2)}</div>'
    )
    if mode == 'evening':
        notice = _evening_close_missing_notice(data)
        if notice:
            out.append(notice)
    # 섹션 순서: 오늘 일정(+내일 상장·공모일정) → 시장 체크 → 해외 → 국내 → 주요 뉴스
    # 토요일 해외 전용 브리핑은 일정 카드를 아예 뺀다 (events가 비어도 "일정 없습니다" 카드가 렌더되므로)
    if not data.get('overseas_only'):
        out.append(_list_card(today_title, events, today_empty))
    if tomorrow_listings:
        out.append(_list_card('내일 상장', tomorrow_listings))
    if ipo:
        out.append(_list_card('공모일정 변경', ipo))
    out.append(_market_card(market_title, market_lines))
    if mode == 'morning':
        overseas = data.get('overseas_summary')
        if overseas:
            # 해외 카드 출처: @futuresnow 우선, 없으면 비하이브
            ov_market = data.get('us_market') or {}
            if not (ov_market.get('video')):
                ov_market = data.get('behive_market') or {}
            out.append(_video_summary_card('🌎 해외 증시·이슈', overseas, ov_market, _US_PALETTE))
        domestic = data.get('domestic_summary')
        if domestic:
            out.append(_video_summary_card('🇰🇷 국내 증시·이슈', domestic,
                                           data.get('behive_market') or {}, _BEHIVE_PALETTE))
    elif mode == 'evening':
        close_summary = data.get('close_summary')
        if close_summary:
            out.append(_video_summary_card('📕 마감시황', close_summary,
                                           data.get('behive_close') or {}, _CLOSE_PALETTE))
    if articles:
        out.append(f'<div style="{S["news_section"]}">주요 뉴스</div>')
        for a in articles:
            out.append(_article_card(a))
    if mode == 'morning':
        out.append(_briefing_footer_note(_morning_missing_summary_notes(data)))
    out.append('</div>')
    return '\n'.join(out)


def cmd_compose(input_file: str) -> int:
    if input_file == '-':
        data = json.loads(sys.stdin.read())
    else:
        data = json.loads(Path(input_file).read_text())
    print(build_html_body(data))
    return 0


def cmd_send(mode: str, subject: str, body_file: str, html: bool = False) -> int:
    if mode not in {'morning', 'evening'}:
        raise SystemExit('mode must be morning or evening')
    if body_file == '-':
        body = sys.stdin.read()
    else:
        body = Path(body_file).read_text()
    if not body.strip():
        raise SystemExit('empty body — refusing to send')
    now = datetime.now(KST)
    today_key = now.strftime('%Y-%m-%d')
    if was_already_sent(mode, today_key):
        print(f'skipped: {mode} briefing already sent for {today_key}')
        return 0
    out = send_mail(subject, body, html=html)
    mark_sent(mode, today_key)
    try:
        added = mark_news_sent_from_pending(mode, today_key, now)
        if added:
            print(f'news history: +{added} items', file=sys.stderr)
    except Exception as e:
        print(f'[briefing_mail:news_history] {type(e).__name__}: {e}', file=sys.stderr)
    try:
        mark_ipo_changes_seen()
    except Exception as e:
        print(f'[briefing_mail:ipo_seen] {type(e).__name__}: {e}', file=sys.stderr)
    print(out)
    return 0


def cmd_legacy(mode: str) -> int:
    """Legacy single-shot path (no LLM summary). Kept for backward compat until cron is switched."""
    if mode not in {'morning', 'evening'}:
        raise SystemExit('usage: briefing_mail.py [morning|evening]')
    today_key = datetime.now(KST).strftime('%Y-%m-%d')
    if was_already_sent(mode, today_key):
        print(f'skipped: {mode} briefing already sent for {today_key}')
        return 0
    subject, body = build_morning() if mode == 'morning' else build_evening()
    out = send_mail(subject, body)
    mark_sent(mode, today_key)
    try:
        mark_ipo_changes_seen()
    except Exception as e:
        print(f'[briefing_mail:ipo_seen] {type(e).__name__}: {e}', file=sys.stderr)
    print(out)
    return 0


USAGE = (
    'usage:\n'
    '  briefing_mail.py prepare {morning|evening} [--final]   # --final: 08:30 최종 발송(US 미게시여도 진행)\n'
    '  briefing_mail.py compose --input {-|path}    # input = augmented prepare JSON with summaries\n'
    '  briefing_mail.py send {morning|evening} --subject "..." --body-file {-|path} [--html]\n'
    '  briefing_mail.py {morning|evening}    # legacy single-shot\n'
)


def main() -> int:
    args = sys.argv[1:]
    if not args:
        raise SystemExit(USAGE)
    cmd = args[0]
    if cmd == 'prepare':
        if len(args) < 2:
            raise SystemExit(USAGE)
        final = '--final' in args[2:]
        return cmd_prepare(args[1], final=final)
    if cmd == 'compose':
        input_file = None
        i = 1
        while i < len(args):
            tok = args[i]
            if tok == '--input' and i + 1 < len(args):
                input_file = args[i + 1]
                i += 2
            else:
                raise SystemExit(f'unknown arg: {tok}\n{USAGE}')
        if not input_file:
            raise SystemExit('compose requires --input\n' + USAGE)
        return cmd_compose(input_file)
    if cmd == 'send':
        if len(args) < 2:
            raise SystemExit(USAGE)
        mode = args[1]
        subject = None
        body_file = None
        html = False
        i = 2
        while i < len(args):
            tok = args[i]
            if tok == '--subject' and i + 1 < len(args):
                subject = args[i + 1]
                i += 2
            elif tok == '--body-file' and i + 1 < len(args):
                body_file = args[i + 1]
                i += 2
            elif tok == '--html':
                html = True
                i += 1
            else:
                raise SystemExit(f'unknown arg: {tok}\n{USAGE}')
        if not subject or not body_file:
            raise SystemExit('send requires --subject and --body-file\n' + USAGE)
        return cmd_send(mode, subject, body_file, html=html)
    if cmd in {'morning', 'evening'}:
        return cmd_legacy(cmd)
    raise SystemExit(f'unknown command: {cmd}\n{USAGE}')


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