#!/usr/bin/env python3
"""종목분석 보고서 — 데이터 수집·SMA·SVG 차트·LLM 코멘트·저장소·큐 워커.

behive_web에서 /stock/<code> 라우트가 import해서 호출.
보고서는 종목별 폴더에 Markdown으로 저장.

저장 구조:
  state/stock_reports/
    005930/
      2026-05-19_15-42.md
      2026-05-19_16-10.md
      queue.json                # {"status": "queued|running|done|failed", ...}

큐는 종목당 1개씩만 running. 동시 요청은 queued 상태로 안내 후 새로고침 시 결과 확인.
"""

from __future__ import annotations

import json
import os
import re
import sys
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path

try:
    from zoneinfo import ZoneInfo
    _KST = ZoneInfo('Asia/Seoul')
except Exception:
    _KST = None

# kiwoom_client 같은 디렉터리
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPT_DIR))
import kiwoom_client as kc  # noqa: E402
import fnguide_client as fg  # noqa: E402
import wisereport_client as wr  # noqa: E402

WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
STATE_DIR = WORKSPACE / 'state'
REPORTS_DIR = STATE_DIR / 'stock_reports'
REPORTS_DIR.mkdir(parents=True, exist_ok=True)

PROMPT_FILE = WORKSPACE / 'prompts' / 'stock_analysis.md'
ALIAS_FILE = WORKSPACE / 'prompts' / 'name_aliases.json'

LLM_MODEL = 'openai/gpt-5.5'
LLM_TIMEOUT_SEC = 120


# ============================================================================
# 시간 헬퍼
# ============================================================================

def now_kst() -> datetime:
    if _KST:
        return datetime.now(_KST)
    return datetime.now()


def session_vol_frac(candles_asc: list[dict]) -> float | None:
    """마지막 봉이 오늘·정규장(09:00~15:30) 진행형이면 세션경과 비율(0.15~1.0), 아니면 None.
    장중 누적 거래량을 하루치로 환산(RVOL·회전율)하는 분모. 휴장/주말은 오늘 봉이 없어 자연 배제.
    NXT(15:30~) 시간대는 KRX 마감 후라 완성봉 취급(None)."""
    if not candles_asc:
        return None
    now = now_kst()
    if candles_asc[-1].get('date') != now.strftime('%Y%m%d'):
        return None
    hm = now.hour * 60 + now.minute
    if not (9 * 60 <= hm < 15 * 60 + 30):
        return None
    return min(1.0, max(0.15, (hm - 9 * 60) / 390.0))  # 09:00~15:30 = 390분


def turnover_today(candles_asc: list[dict], vol_time_frac: float | None = None) -> tuple[float | None, bool]:
    """오늘 회전율(%)과 장중환산 여부. 마지막 봉 turnover_rate 우선, 없으면 최근 완성봉에서
    상장주식수 역산 → 오늘 거래량으로 산출. vol_time_frac 있으면 오늘 누적을 하루치로 투영('예상').
    데이터 없으면 (None, scaled)."""
    if not candles_asc:
        return None, False
    scaled = bool(vol_time_frac and 0 < vol_time_frac < 1.0)
    vols = [c.get('volume') or 0 for c in candles_asc]
    today_v = vols[-1] if vols else 0
    proj_v = (today_v / vol_time_frac) if scaled and today_v else today_v
    last_c = candles_asc[-1]
    if last_c.get('turnover_rate') and not scaled:
        return last_c['turnover_rate'], scaled
    for c in reversed(candles_asc[:-1]):
        tr, cv = c.get('turnover_rate'), c.get('volume')
        if tr and cv:
            listed = cv / (tr / 100.0)
            return (proj_v / listed * 100.0, scaled) if listed > 0 else (None, scaled)
    return None, scaled


def rvol_today(candles_asc: list[dict], vol_time_frac: float | None = None) -> tuple[float | None, bool]:
    """상대거래량(RVOL) = 오늘 거래량 ÷ 직전 20거래일 평균, 장중환산 여부.
    vol_time_frac 있으면 오늘 누적을 하루치로 투영('예상'). 데이터<21봉이면 (None, scaled)."""
    if not candles_asc:
        return None, False
    scaled = bool(vol_time_frac and 0 < vol_time_frac < 1.0)
    vols = [c.get('volume') or 0 for c in candles_asc]
    if len(vols) < 21:
        return None, scaled
    avg20 = sum(vols[-21:-1]) / 20.0
    if avg20 <= 0:
        return None, scaled
    today_v = vols[-1]
    proj_v = (today_v / vol_time_frac) if scaled and today_v else today_v
    return proj_v / avg20, scaled


def ts_for_filename() -> str:
    return now_kst().strftime('%Y-%m-%d_%H-%M-%S')


def ts_for_display() -> str:
    return now_kst().strftime('%Y-%m-%d %H:%M')


# ============================================================================
# SMA 계산 (순수 파이썬, 외부 의존성 0)
# ============================================================================

def sma(series: list[float], window: int) -> list[float | None]:
    """단순이동평균. series는 시간 오름차순 가정. 결과는 같은 길이, 초기 window-1개는 None.

    series 짧으면 window 채우기 전까지 None. 0 나누기·NaN 없이 안전.
    """
    out: list[float | None] = []
    s = 0.0
    for i, v in enumerate(series):
        s += v
        if i >= window:
            s -= series[i - window]
        if i + 1 >= window:
            out.append(s / window)
        else:
            out.append(None)
    return out


# ============================================================================
# 데이터 수집 — 키움 3 TR 합쳐서 보고서 입력 dict 만들기
# ============================================================================

def collect_snapshot(code: str, holding: dict | None = None) -> dict:
    """보고서 1회분 데이터 스냅샷. kiwoom_client 3 함수 호출 + SMA 계산.

    holding: 보유종목이면 {'avg_price', 'qty', 'eval_amount', 'profit_pct'}. 없으면 None.

    반환 dict:
      basic: ka10001 정리 (현재가·시고저·시총·52주·PER/PBR/ROE 등)
      candles_asc: 시간 오름차순 일봉 (차트·SMA용)
      sma5, sma20, sma60: 각 길이 = len(candles_asc)
      flow: 외국인/기관 일별 매매 (최신순 20일)
      flow_summary: 외/기 20일 합산 + 매수일수 통계
      holding: 보유 정보 (있을 때)
      snapshot_at: ISO 시각
    """
    basic = kc.get_stock_basic(code)
    candles_desc = kc.get_daily_candles(code, count=250)  # 최신순
    candles_asc = list(reversed(candles_desc))            # 시간순(차트 그리기용)

    closes = [c['close'] for c in candles_asc]
    sma5 = sma(closes, 5)
    sma20 = sma(closes, 20)
    sma60 = sma(closes, 60)
    sma120 = sma(closes, 120)

    flow = kc.get_investor_flow(code, days=20)
    flow_summary = _summarize_flow(flow)

    peers_data = _collect_peers(basic['code'])

    # FnGuide 펀더멘털 (매출/EPS 시계열·증가율·컨센서스). 조회 실패·ETF면 None.
    fundamentals = fg.get_fundamentals(basic['code'])
    # WISEreport 컨센서스 (연도별 추정·목표가 리비전·서프라이즈). 실패·ETF면 None.
    consensus = wr.get_consensus(basic['code'])

    return {
        'code': basic['code'],
        'name': basic['name'],
        'snapshot_at': now_kst().isoformat(),
        'basic': basic,
        'candles_asc': candles_asc,
        'sma5': sma5,
        'sma20': sma20,
        'sma60': sma60,
        'sma120': sma120,
        'flow': flow,
        'flow_summary': flow_summary,
        'holding': holding,
        'peers': peers_data,
        'fundamentals': fundamentals,
        'consensus': consensus,
    }


def _collect_peers(code: str) -> list[dict]:
    """등록된 peer 종목들의 기본 정보 수집. 실패한 종목은 skip하고 계속.

    각 peer dict: {code, name, price, change, change_pct, per, pbr, roe, error?}
    """
    peers = read_peers(code)
    if not peers:
        return []
    out: list[dict] = []
    for p in peers:
        pc = p['code']
        try:
            b = kc.get_stock_basic(pc)
            out.append({
                'code': pc,
                'name': b['name'] or p['name'],
                'price': b['price'],
                'change': b['change'],
                'change_pct': b['change_pct'],
                'open': b['open'],
                'high': b['high'],
                'low': b['low'],
                'market_cap': b['market_cap'],
                'listed_shares': b['listed_shares'],
                'high_52w': b['high_52w'],
                'low_52w': b['low_52w'],
                'high_52w_dt': b['high_52w_dt'],
                'low_52w_dt': b['low_52w_dt'],
                'turnover_rate': b['turnover_rate'],
                'per': b['per'],
                'pbr': b['pbr'],
                'roe': b['roe'],
                'eps': b['eps'],
                'bps': b['bps'],
                'foreign_holding_pct': b['foreign_holding_pct'],
                'error': None,
            })
        except Exception as e:
            out.append({
                'code': pc,
                'name': p['name'],
                'error': str(e)[:120],
            })
    return out


def _summarize_flow(flow: list[dict]) -> dict:
    """20일치 수급 합산·매수일수. flow는 최신순. 단위는 천 주."""
    if not flow:
        return {'days': 0, 'foreign_net': 0, 'institution_net': 0,
                'individual_net': 0, 'foreign_buy_days': 0,
                'institution_buy_days': 0}
    f_net = sum(r['foreign'] for r in flow)
    i_net = sum(r['institution'] for r in flow)
    p_net = sum(r['individual'] for r in flow)
    f_buy = sum(1 for r in flow if r['foreign'] > 0)
    i_buy = sum(1 for r in flow if r['institution'] > 0)
    return {
        'days': len(flow),
        'foreign_net': f_net,         # 천 주
        'institution_net': i_net,
        'individual_net': p_net,
        'foreign_buy_days': f_buy,
        'institution_buy_days': i_buy,
    }


# ============================================================================
# 저장소 — 보고서 목록·읽기·쓰기·큐 상태
# ============================================================================

def _code_dir(code: str) -> Path:
    code = ''.join(ch for ch in str(code) if ch.isalnum())
    d = REPORTS_DIR / code
    d.mkdir(parents=True, exist_ok=True)
    return d


def _queue_path(code: str) -> Path:
    return _code_dir(code) / 'queue.json'


def read_queue(code: str) -> dict:
    p = _queue_path(code)
    if not p.exists():
        return {'status': 'idle'}
    try:
        return json.loads(p.read_text())
    except Exception:
        return {'status': 'idle'}


def write_queue(code: str, state: dict) -> None:
    p = _queue_path(code)
    state = dict(state)
    state.setdefault('updated_at', now_kst().isoformat())
    p.write_text(json.dumps(state, ensure_ascii=False, indent=2))


def list_reports(code: str) -> list[dict]:
    """보고서 목록 (최신순). 각 항목: {filename, ts, oneline, pct, label}.

    첫 줄 주석에서 메타 파싱: <!-- oneline:... --> / <!-- recommendation: 45% / 중립 -->
    """
    d = _code_dir(code)
    items = []
    for p in sorted(d.glob('*.html'), reverse=True):
        meta = _parse_report_meta(p)
        items.append({
            'filename': p.name,
            'ts': p.stem,
            'path': str(p),
            'oneline': meta.get('oneline', ''),
            'pct': meta.get('pct', None),
            'label': meta.get('label', ''),
        })
    return items


def _parse_report_meta(p: Path) -> dict:
    """파일 첫 6줄에서 <!-- key: value --> 주석 파싱."""
    out: dict = {}
    try:
        head = p.read_text().splitlines()[:6]
    except Exception:
        return out
    for line in head:
        line = line.strip()
        if not (line.startswith('<!--') and line.endswith('-->')):
            continue
        inner = line[4:-3].strip()
        if ':' not in inner:
            continue
        k, v = inner.split(':', 1)
        k = k.strip().lower()
        v = v.strip()
        if k == 'oneline':
            out['oneline'] = v
        elif k == 'recommendation':
            # 형식: "45% / 중립"
            parts = [x.strip() for x in v.split('/', 1)]
            try:
                pct_str = parts[0].rstrip('%').strip()
                out['pct'] = int(pct_str)
            except Exception:
                pass
            if len(parts) > 1:
                out['label'] = parts[1]
    return out


def read_report(code: str, filename: str) -> str | None:
    d = _code_dir(code)
    # path traversal 방지
    safe = ''.join(ch for ch in filename if ch.isalnum() or ch in '_-.')
    if safe != filename or not safe.endswith('.html'):
        return None
    p = d / safe
    if not p.exists() or not p.is_file():
        return None
    return p.read_text()


def save_report(code: str, body_html: str) -> Path:
    """보고서 저장. 같은 날 기존 보고서가 있으면 모두 삭제 후 새 파일 작성 (1일 1개 정책)."""
    d = _code_dir(code)
    today = now_kst().strftime('%Y-%m-%d')
    for old in d.glob(f'{today}_*.html'):
        try:
            old.unlink()
        except OSError:
            pass
    p = d / f'{ts_for_filename()}.html'
    p.write_text(body_html)
    return p


# ============================================================================
# 비교업체 (peers) 저장소
# ============================================================================

PEERS_MAX = 2


class MultipleMatchError(Exception):
    """fuzzy resolve 결과 다중 후보. behive_web의 동명 클래스와 별개 —
    데몬은 behive_web을 __main__으로 실행하므로 두 모듈 간 클래스 객체 분리됨.
    peers handler에서는 sa.MultipleMatchError를 잡아야 정확히 매칭된다.
    """
    def __init__(self, query: str, candidates: list[dict]):
        self.query = query
        self.candidates = candidates
        super().__init__(query)


_ALIAS_FALLBACK = {
    '엘지': 'LG', '에스케이': 'SK', '씨제이': 'CJ', '케이티': 'KT',
    '케이비': 'KB', '지에스': 'GS', '디엘': 'DL', '디비': 'DB',
    '에스비에스': 'SBS', '비지에프': 'BGF', '네이버': 'NAVER',
    '에이치엘': 'HL', '에이치디': 'HD', '오씨아이': 'OCI',
    '엔에이치엔': 'NHN', '비에이치': 'BH', '에이치엠엠': 'HMM', '케이씨씨': 'KCC',
}


def _load_aliases() -> dict[str, str]:
    """한글 음역 → 영문 prefix 매핑 (예: 엘지 → LG). 매번 파일 read (런타임 편집 반영).

    파일 없거나 깨졌으면 코드 내 fallback.
    """
    if ALIAS_FILE.exists():
        try:
            data = json.loads(ALIAS_FILE.read_text())
            # _ 로 시작하는 키(_note 등)는 제외
            return {k: v for k, v in data.items() if not k.startswith('_') and isinstance(v, str)}
        except Exception:
            pass
    return _ALIAS_FALLBACK


def _apply_alias(q: str) -> str:
    """입력 prefix에 한글 음역이 있으면 영문으로 치환. 가장 긴 매칭 우선.
    예: '엘지전자' → 'LG전자', '엘지' → 'LG'.
    """
    aliases = _load_aliases()
    for ko in sorted(aliases.keys(), key=len, reverse=True):
        if q.startswith(ko):
            return aliases[ko] + q[len(ko):]
    return q


def _resolve_fuzzy(query: str) -> dict:
    """종목 매핑 단계:
    1. case-sensitive exact
    2. 6자리 숫자 코드 → 키움 직접 조회
    3. case-insensitive exact (단일 채택)
    4. prefix 단일 채택
    5. substring (단일 채택, 다중이면 MultipleMatchError)
    6. 한글 음역 적용 (예: 엘지전자 → LG전자) 후 1~5 재시도
    7. 키움 직접 조회

    '이름 좀 틀려도' UX: case-insensitive + prefix + 한글 음역까지 자동 매칭.
    """
    q = (query or '').strip()
    if not q:
        raise ValueError('빈 입력')

    def _try_match(qv: str, allow_multiple_raise: bool = True) -> dict | None:
        """주어진 입력 문자열로 cache 매칭 시도. 못 찾으면 None.
        allow_multiple_raise=False면 다중 매칭에서 raise 안 하고 None 반환 (alias 재시도용 X — alias 결과는 단일이 자연스러우니 raise 허용)."""
        cache = kc._load_code_cache()
        # 1) exact (case-sensitive)
        if qv in cache:
            return cache[qv]
        ql = qv.lower()
        # 3) case-insensitive exact
        ci_exact = [info for name, info in cache.items() if name.lower() == ql]
        if len(ci_exact) == 1:
            return ci_exact[0]
        if len(ci_exact) > 1 and allow_multiple_raise:
            raise MultipleMatchError(qv, ci_exact[:30])
        # 4) prefix 단일
        prefix = [info for name, info in cache.items() if name.lower().startswith(ql)]
        if len(prefix) == 1:
            return prefix[0]
        # 5) substring
        matched = [info for name, info in cache.items() if ql in name.lower()]
        if len(matched) == 1:
            return matched[0]
        if len(matched) > 1 and allow_multiple_raise:
            def sort_key(info: dict) -> tuple:
                n = info['name'].lower()
                if n == ql:
                    return (0, n)
                if n.startswith(ql):
                    return (1, n)
                return (2, n)
            matched.sort(key=sort_key)
            raise MultipleMatchError(qv, matched[:30])
        return None

    # 2) 6자리 코드 우선
    if q.isdigit() and len(q) == 6:
        try:
            return kc.resolve_stock_code(q)
        except KeyError:
            raise ValueError(f'코드 "{q}" 조회 실패')

    # 1~5 시도 (다중 매칭이면 raise)
    try:
        hit = _try_match(q)
        if hit is not None:
            return hit
    except MultipleMatchError:
        # 다중이라도 alias 적용 시 단일이 될 수 있으니 alias 먼저 시도, 그래도 안 되면 raise
        aliased = _apply_alias(q)
        if aliased != q:
            try:
                hit2 = _try_match(aliased)
                if hit2 is not None:
                    return hit2
            except MultipleMatchError:
                pass
        # alias 적용해도 못 찾으면 원본 다중 매칭으로 raise
        raise

    # 6) 한글 음역 적용 후 재시도
    aliased = _apply_alias(q)
    if aliased != q:
        hit = _try_match(aliased)
        if hit is not None:
            return hit

    # 7) 키움 직접 조회
    try:
        return kc.resolve_stock_code(q)
    except (KeyError, AttributeError):
        raise ValueError(f'"{q}"와 일치하는 종목을 찾을 수 없어요. 종목명 또는 6자리 코드를 입력해주세요.')


def _peers_path(code: str) -> Path:
    return _code_dir(code) / 'peers.json'


def read_peers(code: str) -> list[dict]:
    """등록된 비교업체 list. 각 {code, name, added_at}."""
    p = _peers_path(code)
    if not p.exists():
        return []
    try:
        data = json.loads(p.read_text())
        return list(data.get('peers') or [])
    except Exception:
        return []


def _write_peers(code: str, peers: list[dict]) -> None:
    p = _peers_path(code)
    p.write_text(json.dumps({'peers': peers}, ensure_ascii=False, indent=2))


def add_peer(stock_code: str, query: str) -> dict:
    """query를 fuzzy resolve해 peer 추가. 반환: 추가된 peer dict.

    에러:
    - ValueError: 빈 입력·조회 실패·자기 자신 추가·중복·정원 초과
    - MultipleMatchError: 다중 매칭 — caller가 처리 (sa.MultipleMatchError로 잡을 것)
    """
    stock_code = ''.join(ch for ch in str(stock_code) if ch.isalnum())
    info = _resolve_fuzzy(query)
    peer_code = (info.get('code') or '').strip()
    peer_name = (info.get('name') or '').strip()
    if not peer_code:
        raise ValueError(f'"{query}" 조회 실패')
    if peer_code == stock_code:
        raise ValueError('자기 자신은 비교업체로 추가할 수 없어요.')
    peers = read_peers(stock_code)
    if any(p['code'] == peer_code for p in peers):
        raise ValueError(f'이미 추가된 종목입니다: {peer_name}')
    if len(peers) >= PEERS_MAX:
        raise ValueError(f'최대 {PEERS_MAX}개까지 추가할 수 있어요. 먼저 다른 종목을 빼주세요.')
    peer = {'code': peer_code, 'name': peer_name, 'added_at': now_kst().isoformat()}
    peers.append(peer)
    _write_peers(stock_code, peers)
    return peer


def remove_peer(stock_code: str, peer_code: str) -> bool:
    """peer 1개 제거. 반환: 실제로 제거됐는지."""
    stock_code = ''.join(ch for ch in str(stock_code) if ch.isalnum())
    peer_code = ''.join(ch for ch in str(peer_code) if ch.isalnum())
    peers = read_peers(stock_code)
    new_peers = [p for p in peers if p['code'] != peer_code]
    if len(new_peers) == len(peers):
        return False
    _write_peers(stock_code, new_peers)
    return True


# ============================================================================
# SVG 차트 — 1년 캔들 + SMA 5/20/60 + 거래량 막대
# ============================================================================

CHART_W = 1000
CHART_H = 660      # X축 날짜 라벨 아래 여백 최소화 (라벨 baseline VOL_BOT+26=642)
CHART_PAD_L = 20   # 좌측 라벨 없음 (가격 라벨 우측으로 이동)
CHART_PAD_R = 140  # 우측 가격·거래량 라벨 영역 (28px 폰트 = 6자리+쉼표 약 140px)
CHART_PAD_T = 60   # SMA legend 영역 (y=4~50). OHLC 팝업은 차트 안 floating tooltip.
PRICE_PANEL_H = 420
VOL_PANEL_H = 120
PANEL_GAP = 16

PRICE_TOP = CHART_PAD_T
PRICE_BOT = CHART_PAD_T + PRICE_PANEL_H
VOL_TOP = PRICE_BOT + PANEL_GAP
VOL_BOT = VOL_TOP + VOL_PANEL_H


CHART_RANGE_DAYS = {'1Y': 250, '6M': 120, '3M': 63, '1M': 21}

# 사용자 수평선 색상 팔레트 — 정렬(내림차순) 인덱스로 순환. SVG·칩 UI가 같은 매핑 사용.
# 모두 밝은 톤이라 태그 위 어두운 글자(#1a0f1f) 가독성 확보.
HLINE_COLORS = ['#e879f9', '#38bdf8', '#fbbf24', '#4ade80', '#fb7185', '#a78bfa', '#22d3ee', '#facc15']


def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None = None,
                     intraday: bool = False, hlines: list | None = None) -> str:
    """캔들 + SMA 3선 + 거래량 막대(+일봉 20일 거래량 이동평균선) 인라인 SVG.

    range_key: '1Y' (250일) | '6M' (120일) | '3M' (63일) | '1M' (21일). 같은 snapshot 데이터에서 슬라이스만.
    ref_price: 기준가(전일종가 등) — 지정 시 노란 점선 기준선 + 마지막 종가의 대비 등락률 표시 (분봉용).
    hlines: 사용자가 등록한 수평선 가격 리스트(지지/저항 등). 표시 구간 안에 드는 값만 그린다(스케일 미확장).
    회전율 값은 차트에 그리지 않고 caller가 turnover_today()로 별도 표시 (차트 아래).
    snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용.
    외부 JS 의존성 0. CSS는 inline.
    """
    candles_full = snap['candles_asc']
    if not candles_full:
        return '<div style="padding:24px;color:#888">차트 데이터 없음</div>'

    n_take = CHART_RANGE_DAYS.get(range_key, 250)
    candles = candles_full[-n_take:]
    sma5 = (snap.get('sma5') or [])[-n_take:]
    sma20 = (snap.get('sma20') or [])[-n_take:]
    sma60 = (snap.get('sma60') or [])[-n_take:]
    sma120 = (snap.get('sma120') or [])[-n_take:]

    # ---- 스케일 ----
    highs = [c['high'] for c in candles]
    lows = [c['low'] for c in candles]
    # SMA 값도 가격축 범위에 포함 (선이 잘리지 않도록)
    sma_vals = [v for v in (sma5 + sma20 + sma60 + sma120) if v is not None]
    y_max = max(max(highs), max(sma_vals) if sma_vals else 0)
    y_min = min(min(lows), min(sma_vals) if sma_vals else float('inf'))
    if ref_price:
        y_max = max(y_max, ref_price)   # 기준선이 항상 보이게 스케일에 포함
        y_min = min(y_min, ref_price)
    if y_min == y_max:
        y_max = y_min + 1
    # 위·아래 패딩 5%
    span = y_max - y_min
    y_max += span * 0.05
    y_min -= span * 0.02

    vols = [c['volume'] for c in candles]
    v_max = max(vols) if vols else 1

    n = len(candles)
    plot_w = CHART_W - CHART_PAD_L - CHART_PAD_R
    slot = plot_w / n
    body_w = max(1.2, slot * 0.7)

    def price_y(price: float) -> float:
        # y 위에서 아래: 큰 가격이 위
        frac = (price - y_min) / (y_max - y_min)
        return PRICE_BOT - frac * (PRICE_BOT - PRICE_TOP)

    def vol_y(v: float) -> float:
        frac = (v / v_max) if v_max > 0 else 0
        return VOL_BOT - frac * (VOL_BOT - VOL_TOP)

    def slot_x(i: int) -> float:
        return CHART_PAD_L + slot * (i + 0.5)

    # crosshair 인터랙션용 메타 — 클라이언트가 봉 index/가격 매핑할 때 사용.
    import json as _json
    import html as _h
    meta_obj = {
        'candles': [
            {'d': c['date'], 'o': c['open'], 'h': c['high'], 'l': c['low'], 'c': c['close']}
            for c in candles
        ],
        'ymin': y_min,
        'ymax': y_max,
        # 분봉일 때만 전일종가(ref_price) — 크로스헤어 툴팁의 '전일대비 등락률'용 (현재 등락율과 일치).
        'prevClose': (int(ref_price) if (intraday and ref_price) else 0),
        'priceTop': PRICE_TOP,
        'priceBot': PRICE_BOT,
        'volTop': VOL_TOP,
        'volBot': VOL_BOT,
        'padL': CHART_PAD_L,
        'padR': CHART_PAD_R,
        'chartW': CHART_W,
        'chartH': CHART_H,
    }
    meta_attr = _h.escape(_json.dumps(meta_obj, separators=(',', ':')), quote=True)

    parts: list[str] = []
    parts.append(
        f'<svg viewBox="0 0 {CHART_W} {CHART_H}" '
        f'xmlns="http://www.w3.org/2000/svg" '
        f'style="width:100%;height:auto;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,sans-serif;font-size:14px;background:#0f1419;touch-action:none;" '
        f'data-meta="{meta_attr}">'
    )

    # ---- 가격 패널 배경 + 격자 ----
    parts.append(
        f'<rect x="{CHART_PAD_L}" y="{PRICE_TOP}" '
        f'width="{plot_w}" height="{PRICE_PANEL_H}" fill="#0a0d12" stroke="#2a2f3a"/>'
    )
    # 가격 가로 격자 5개 + Y축 라벨
    for k in range(6):
        frac = k / 5
        y = PRICE_TOP + frac * PRICE_PANEL_H
        price = y_max - frac * (y_max - y_min)
        parts.append(
            f'<line x1="{CHART_PAD_L}" y1="{y:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{y:.1f}" '
            f'stroke="#1d222b" stroke-dasharray="2 3"/>'
        )
        parts.append(
            f'<text x="{CHART_W - CHART_PAD_R + 6}" y="{y+10:.1f}" text-anchor="start" fill="#7a8493" font-size="28">'
            f'{int(round(price)):,}</text>'
        )

    # ---- 캔들 ----
    for i, c in enumerate(candles):
        x = slot_x(i)
        o, h, l, cl = c['open'], c['high'], c['low'], c['close']
        up = cl >= o
        color = '#ef4444' if up else '#3b82f6'   # 한국 관습: 상승=빨강, 하락=파랑
        # 심지
        parts.append(
            f'<line x1="{x:.2f}" y1="{price_y(h):.2f}" x2="{x:.2f}" y2="{price_y(l):.2f}" '
            f'stroke="{color}" stroke-width="1"/>'
        )
        # 몸통
        top = price_y(max(o, cl))
        bot = price_y(min(o, cl))
        height = max(0.8, bot - top)
        parts.append(
            f'<rect x="{x-body_w/2:.2f}" y="{top:.2f}" width="{body_w:.2f}" height="{height:.2f}" '
            f'fill="{color}" stroke="{color}"/>'
        )

    # ---- SMA 선 ----
    def sma_path(values: list[float | None], color: str, name: str) -> str:
        pts = []
        for i, v in enumerate(values):
            if v is None:
                if pts and pts[-1] is not None:
                    pts.append(None)  # 끊김 표시
                continue
            pts.append((slot_x(i), price_y(v)))
        # path 구성 — None 만나면 새 sub-path
        d_parts: list[str] = []
        prev_was_pt = False
        for p in pts:
            if p is None:
                prev_was_pt = False
                continue
            x, y = p
            if not prev_was_pt:
                d_parts.append(f'M{x:.2f},{y:.2f}')
                prev_was_pt = True
            else:
                d_parts.append(f'L{x:.2f},{y:.2f}')
        if not d_parts:
            return ''
        d = ' '.join(d_parts)
        return f'<path d="{d}" fill="none" stroke="{color}" stroke-width="1.4" opacity="0.95"/>'

    parts.append(sma_path(sma5, '#fbbf24', 'SMA5'))    # 노랑
    parts.append(sma_path(sma20, '#34d399', 'SMA20'))  # 초록
    parts.append(sma_path(sma60, '#a78bfa', 'SMA60'))  # 보라
    parts.append(sma_path(sma120, '#22d3ee', 'SMA120'))  # 청록

    # ---- 전일종가 기준선 + 대비 등락률 (ref_price 지정 시 — 분봉) ----
    if ref_price:
        ry = price_y(ref_price)
        parts.append(
            f'<line x1="{CHART_PAD_L}" y1="{ry:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{ry:.1f}" '
            f'stroke="#eab308" stroke-width="1.5" stroke-dasharray="7 5" opacity="0.85"/>'
        )
        parts.append(
            f'<text x="{CHART_W - CHART_PAD_R + 6}" y="{ry-4:.1f}" text-anchor="start" fill="#eab308" font-size="24">전일</text>'
        )
        # 우상단 '전일대비 X%' 텍스트는 제거(관리자 요청) — 전일종가 기준선만 유지.

    # SMA 범례
    legend_y = PRICE_TOP + 14
    legend_items = [('5일', '#fbbf24'), ('20일', '#34d399'), ('60일', '#a78bfa'), ('120일', '#22d3ee')]
    lx = CHART_PAD_L + 8
    for name, color in legend_items:
        parts.append(
            f'<rect x="{lx}" y="{legend_y-8}" width="10" height="3" fill="{color}"/>'
        )
        parts.append(
            f'<text x="{lx+14}" y="{legend_y-2}" fill="#cfd5df" font-size="20">{name}</text>'
        )
        lx += 96

    # ---- 거래량 패널 배경 ----
    parts.append(
        f'<rect x="{CHART_PAD_L}" y="{VOL_TOP}" '
        f'width="{plot_w}" height="{VOL_PANEL_H}" fill="#0a0d12" stroke="#2a2f3a"/>'
    )
    # 거래량 막대
    for i, c in enumerate(candles):
        x = slot_x(i)
        v = c['volume']
        if v <= 0:
            continue
        bar_top = vol_y(v)
        bar_h = VOL_BOT - bar_top
        up = c['close'] >= c['open']
        color = '#ef4444' if up else '#3b82f6'
        parts.append(
            f'<rect x="{x-body_w/2:.2f}" y="{bar_top:.2f}" width="{body_w:.2f}" height="{bar_h:.2f}" '
            f'fill="{color}" opacity="0.7"/>'
        )
    # 거래량 20일 이동평균선 — 일봉에서만 (분봉은 '20분 평균'이 돼 의미 없음)
    vols_full = [c['volume'] for c in candles_full]
    if not intraday:
        vma = (sma(vols_full, 20))[-n_take:]
        vma_pts = [f'{slot_x(i):.2f},{vol_y(v):.2f}' for i, v in enumerate(vma) if v is not None]
        if len(vma_pts) >= 2:
            parts.append(
                f'<polyline points="{" ".join(vma_pts)}" fill="none" stroke="#f5a623" '
                f'stroke-width="2" opacity="0.9"/>'
            )

    # 거래량 Y 레이블 (max 만) — 우측
    parts.append(
        f'<text x="{CHART_W - CHART_PAD_R + 6}" y="{VOL_TOP+22}" text-anchor="start" fill="#7a8493" font-size="28">'
        f'{_fmt_vol(v_max)}</text>'
    )

    # 20MA 범례 (거래량 패널 하단 좌측) — 일봉 전용. RVOL·회전율 텍스트는 차트에 안 그림(차트 아래 표시).
    if not intraday:
        parts.append(
            f'<text x="{CHART_PAD_L+8}" y="{VOL_BOT-8}" text-anchor="start" fill="#f5a623" font-size="24">'
            f'— 거래량 20일평균</text>'
        )

    # ---- X축 날짜 (시작·중간·끝) ----
    for i in (0, n // 2, n - 1):
        x = slot_x(i)
        # 첫/끝 라벨이 plot 밖으로 안 잘리도록 anchor 분기.
        anchor = 'start' if i == 0 else ('end' if i == n - 1 else 'middle')
        parts.append(
            f'<text x="{x:.1f}" y="{VOL_BOT+26}" text-anchor="{anchor}" fill="#7a8493" font-size="28">'
            f'{_fmt_date(candles[i]["date"])}</text>'
        )

    # ---- 구간 고점선 + 현재가 태그 (고점·현재가 강조) ----
    # 고점: 표시 구간 내 최고 고가에 노란 점선 + 우측 '고 N' 라벨.
    hi_price = max(highs)
    hy = price_y(hi_price)
    parts.append(
        f'<line x1="{CHART_PAD_L}" y1="{hy:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{hy:.1f}" '
        f'stroke="#fbbf24" stroke-width="1" stroke-dasharray="4 4" opacity="0.55"/>'
    )
    # 고가 금액 — 가격패널 좌하단 안 (구간별 최고 고가). 상단 우측 대신 아래로 이동.
    parts.append(
        f'<text x="{CHART_PAD_L + 8}" y="{PRICE_BOT - 10:.1f}" text-anchor="start" fill="#fbbf24" '
        f'font-size="26" font-weight="600">고가 {int(round(hi_price)):,}</text>'
    )
    # 현재가(마지막 종가): 얇은 기준선 + 우측 축 강조 태그(상승=빨강/하락=파랑).
    cur_price = candles[-1]['close']
    cy = price_y(cur_price)
    last_up = candles[-1]['close'] >= candles[-1]['open']
    cur_color = '#ef4444' if last_up else '#3b82f6'
    parts.append(
        f'<line x1="{CHART_PAD_L}" y1="{cy:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{cy:.1f}" '
        f'stroke="{cur_color}" stroke-width="1" stroke-dasharray="2 3" opacity="0.5"/>'
    )
    tag_cy = min(max(cy, PRICE_TOP + 15), PRICE_BOT - 15)
    parts.append(
        f'<rect x="{CHART_W - CHART_PAD_R + 2}" y="{tag_cy-15:.1f}" width="{CHART_PAD_R-6}" height="30" '
        f'fill="{cur_color}" rx="3"/>'
    )
    parts.append(
        f'<text x="{CHART_W - CHART_PAD_R + 8}" y="{tag_cy+8:.1f}" text-anchor="start" fill="#fff" '
        f'font-size="26" font-weight="700">{int(round(cur_price)):,}</text>'
    )

    # ---- 사용자 수평선 (hlines) — 지지/저항 등 수동 등록 가격선 ----
    # 표시 구간(y_min~y_max) 안에 드는 값만 그린다. 스케일은 확장하지 않음(캔들 가독성 우선).
    # 좌측에 분홍 가격 태그를 둔다(우측은 가격축·현재가 태그가 이미 점유).
    for _hi, hp in enumerate(hlines or []):
        try:
            hp = float(hp)
        except (TypeError, ValueError):
            continue
        if hp < y_min or hp > y_max:
            continue
        hcolor = HLINE_COLORS[_hi % len(HLINE_COLORS)]
        hly = price_y(hp)
        parts.append(
            f'<line x1="{CHART_PAD_L}" y1="{hly:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{hly:.1f}" '
            f'stroke="{hcolor}" stroke-width="1.6" stroke-dasharray="6 4" opacity="0.9"/>'
        )
        htag_y = min(max(hly, PRICE_TOP + 15), PRICE_BOT - 15)
        parts.append(
            f'<rect x="{CHART_PAD_L+2}" y="{htag_y-15:.1f}" width="122" height="30" '
            f'fill="{hcolor}" rx="3" opacity="0.95"/>'
        )
        parts.append(
            f'<text x="{CHART_PAD_L+8}" y="{htag_y+8:.1f}" text-anchor="start" fill="#1a0f1f" '
            f'font-size="26" font-weight="700">{int(round(hp)):,}</text>'
        )

    parts.append('</svg>')
    return ''.join(parts)


def _fmt_vol(v: int) -> str:
    if v >= 100_000_000:
        return f'{v/100_000_000:.1f}억'
    if v >= 10_000:
        return f'{v/10_000:.0f}만'
    return f'{v:,}'


# ============================================================================
# LLM 호출 — openclaw capability model run subprocess
# ============================================================================

def load_prompt_template() -> str:
    """매 호출마다 prompts/stock_analysis.md를 읽어 반환.

    런타임 편집 즉시 반영 위해 캐시 없음 (파일 작아서 부담 없음).
    파일 없거나 {data_block} 자리 표시자 누락 시 코드 내 FALLBACK 사용.
    """
    if PROMPT_FILE.exists():
        try:
            text = PROMPT_FILE.read_text()
            if '{data_block}' in text:
                return text
            sys.stderr.write(f'[stock_analysis] {PROMPT_FILE} 에 {{data_block}} 자리표시자 없음 → fallback\n')
        except Exception as e:
            sys.stderr.write(f'[stock_analysis] {PROMPT_FILE} 읽기 실패 ({e}) → fallback\n')
    return _PROMPT_FALLBACK


_PROMPT_FALLBACK = """당신은 한국 주식 종목분석가입니다. 아래 키움증권 실시간 데이터를 보고
관리자(개인 투자자)를 위한 분석 보고서를 작성합니다.

## 작성 규칙
- 한국어, 존댓말, 관리자님 호칭.
- 4개 섹션 순서·제목 그대로 사용 (▣ 최근 가격 흐름 / ▣ 수급 해석 / ▣ 거래량·체결 강도 / ▣ 주의 포인트 / 리스크).
- 각 섹션 5문장 이상, 수치 근거 인용.
- 비교업체(peers) 데이터는 종목의 기본정보(가격·PER/PBR/ROE 등 재무비율) 관련 맥락에서만 간단히 짚어주시고, 4개 분석 섹션(가격 흐름·수급·거래량·주의 포인트) 안에서는 비교 코멘트를 넣지 마세요.
- 마지막에 한줄요약·투자의견 JSON을 정확한 형식으로 출력 (파서가 읽음).

## 종목 데이터
{data_block}

## 출력 형식 (이 순서·구분자 그대로)
ONELINE: <30자 이내 한 줄 요약. 추세/수급/거래량 핵심>

▣ 최근 가격 흐름
<5문장 이상>

▣ 수급 해석
<5문장 이상>

▣ 거래량·체결 강도
<5문장 이상>

▣ 주의 포인트 / 리스크
<5문장 이상>

VERDICT_JSON: {{"recommendation_pct": <0~100 정수>, "label": "<추천 안 함|관망|중립|매수 검토|강력 추천 중 하나>", "one_line": "<한 줄 결론 40자 이내>"}}

추천도 기준:
- 0~20: 추천 안 함 (단기 매도/접근 비추천)
- 21~40: 관망 (부정 신호 우세)
- 41~60: 중립
- 61~80: 매수 검토 (긍정 신호 우세)
- 81~100: 강력 추천 (강한 매수 신호)
"""


def _build_data_block(snap: dict) -> str:
    """LLM 입력용 데이터 텍스트 (Markdown). 토큰 절약 위해 간결하게."""
    b = snap['basic']
    fs = snap['flow_summary']
    flow = snap['flow'][:20]  # 20일치
    candles = snap['candles_asc']

    # 가격 추이 핵심 — 최근 20일 종가 (간격 1) + 60일·120일·1년 전 종가
    closes = [c['close'] for c in candles]
    dates = [c['date'] for c in candles]

    def at(idx_from_end: int) -> str:
        i = len(closes) - 1 - idx_from_end
        if i < 0:
            return 'N/A'
        return f'{_fmt_date(dates[i])} {closes[i]:,}원'

    sma5 = snap['sma5'][-1]
    sma20 = snap['sma20'][-1]
    sma60 = snap['sma60'][-1]

    lines = []
    lines.append(f'### 종목: {snap["name"]} ({snap["code"]})')
    lines.append(f'- 현재가: {b["price"]:,}원 ({b["change"]:+,} / {b["change_pct"]:+.2f}%)')
    lines.append(f'- 오늘 시고저: 시 {b["open"]:,} / 고 {b["high"]:,} / 저 {b["low"]:,}')
    lines.append(f'- 시가총액: {_fmt_market_cap(b["market_cap"])} · 상장주식수: {b["listed_shares"]:,}주')
    lines.append(f'- 52주 고저: 고 {b["high_52w"]:,}원 ({_fmt_date(b["high_52w_dt"])}) / 저 {b["low_52w"]:,}원 ({_fmt_date(b["low_52w_dt"])})')
    lines.append(f'- 회전율(오늘): {b["turnover_rate"]:.2f}% · 외국인 보유비율: {b["foreign_holding_pct"]:.2f}%')
    lines.append(f'- PER {b["per"]} · PBR {b["pbr"]} · ROE {b["roe"]} · EPS {b["eps"]:,} · BPS {b["bps"]:,}')
    lines.append('')
    lines.append('### 이동평균 (최신 기준)')
    lines.append(f'- SMA5: {round(sma5):,}원' if sma5 else '- SMA5: N/A')
    lines.append(f'- SMA20: {round(sma20):,}원' if sma20 else '- SMA20: N/A')
    lines.append(f'- SMA60: {round(sma60):,}원' if sma60 else '- SMA60: N/A')
    lines.append('')
    lines.append('### 가격 추이')
    lines.append(f'- 오늘: {at(0)}')
    lines.append(f'- 5일전: {at(5)}')
    lines.append(f'- 20일전: {at(20)}')
    lines.append(f'- 60일전: {at(60)}')
    lines.append(f'- 120일전: {at(120)}')
    lines.append(f'- 1년전: {at(min(240, len(closes)-1))}')
    lines.append('')
    lines.append('### 최근 20일 외국인·기관 수급 (단위: 주, +는 순매수)')
    lines.append(f'- 외국인 20일 누적: {fs["foreign_net"]*1000:+,}주 · 매수일 {fs["foreign_buy_days"]}/20일')
    lines.append(f'- 기관 20일 누적: {fs["institution_net"]*1000:+,}주 · 매수일 {fs["institution_buy_days"]}/20일')
    lines.append(f'- 개인 20일 누적: {fs["individual_net"]*1000:+,}주')
    lines.append('')
    lines.append('일별 수급 (최신 → 옛, 외/기/개, 단위: 주):')
    for r in flow:
        lines.append(f'  {r["date"]}: 외 {r["foreign"]*1000:+,} / 기 {r["institution"]*1000:+,} / 개 {r["individual"]*1000:+,}')
    lines.append('')
    lines.append('### 최근 20일 거래량 (단위: 주)')
    for c in candles[-20:][::-1]:
        lines.append(f'  {c["date"]}: 종가 {c["close"]:,} / 거래량 {c["volume"]:,} / 회전 {c["turnover_rate"]}%')

    if snap.get('holding'):
        h = snap['holding']
        lines.append('')
        lines.append('### 내 포지션')
        lines.append(f'- 평균단가: {h.get("avg_price", 0):,}원')
        lines.append(f'- 보유수량: {h.get("qty", 0):,}주')
        lines.append(f'- 수익률: {h.get("profit_pct", 0):+.2f}%')

    fund = snap.get('fundamentals')
    if fund:
        annual = fund.get('annual') or []
        cons = fund.get('consensus') or {}
        if annual:
            lines.append('')
            lines.append('### 연간 실적·성장성 (FnGuide, 단위: 매출/영업이익 억원·EPS 원, 증가율 YoY%)')
            for r in annual[-5:]:
                def _f(v):
                    return f'{round(v):,}' if isinstance(v, (int, float)) else 'N/A'
                def _g(v):
                    return f'{v:+.1f}%' if isinstance(v, (int, float)) else 'N/A'
                lines.append(
                    f'  {r.get("period","")}: 매출 {_f(r.get("sales"))}({_g(r.get("sales_growth"))}) · '
                    f'영업이익 {_f(r.get("oper_profit"))}({_g(r.get("oper_profit_growth"))}) · '
                    f'EPS {_f(r.get("eps"))}({_g(r.get("eps_growth"))}) · ROE {r.get("roe","N/A")}'
                )
        if cons:
            lines.append('')
            lines.append('### 컨센서스 (FnGuide, 증권사 추정 종합)')
            lines.append(
                f'- 목표주가 {_fmt_num(round(cons["target_price"])) if isinstance(cons.get("target_price"),(int,float)) else "N/A"}원 · '
                f'투자의견 {cons.get("opinion_label","N/A")}({cons.get("opinion","N/A")}/5) · '
                f'추정 EPS {_fmt_num(round(cons["eps"])) if isinstance(cons.get("eps"),(int,float)) else "N/A"}원 · '
                f'추정 PER {cons.get("per","N/A")}배 · 참여기관 {int(cons["organ_count"]) if isinstance(cons.get("organ_count"),(int,float)) else "N/A"}개'
            )

    cons = snap.get('consensus')
    if cons:
        annual = cons.get('annual') or []
        rev = cons.get('revision') or {}
        sup = (cons.get('surprise') or {}).get('items') or {}
        if annual:
            lines.append('')
            lines.append('### WISEreport 연도별 추정 재무 (IFRS연결, A=실적 E=추정, 매출/영업이익 억원·EPS 원)')
            for r in annual:
                def _f(v):
                    return f'{round(v):,}' if isinstance(v, (int, float)) else 'N/A'
                ae = 'E' if r.get('is_estimate') else 'A'
                yoy = r.get('sales_yoy')
                lines.append(
                    f'  {r.get("period","")}({ae}): 매출 {_f(r.get("sales"))}'
                    f'(YoY {yoy:+.1f}%)' if isinstance(yoy, (int, float)) else
                    f'  {r.get("period","")}({ae}): 매출 {_f(r.get("sales"))}'
                )
                lines[-1] += (f' · 영업이익 {_f(r.get("op"))} · EPS {_f(r.get("eps"))} · ROE {r.get("roe","N/A")}')
        if rev and (rev.get('target_change_pct') is not None or rev.get('est_change_pct') is not None):
            lines.append('')
            lines.append('### 컨센서스 리비전 (최근 3개월 추이)')
            lines.append(
                f'- 목표주가 {rev.get("target_first")} → {rev.get("target_last")} '
                f'({rev.get("target_change_pct")}%) · '
                f'{rev.get("item_name","추정")} {rev.get("est_first")} → {rev.get("est_last")} '
                f'({rev.get("est_change_pct")}%)'
            )
        if sup:
            lines.append('')
            lines.append('### 어닝 서프라이즈 (최신 확정연도 실적 vs 직전 컨센서스 괴리율)')
            for label, v in sup.items():
                if v.get('fy0_surprise_pct') is None:
                    continue
                lines.append(f'- {label} {v.get("fy0_year","")}: 실적 {v.get("fy0_actual")} '
                             f'(서프라이즈 {v.get("fy0_surprise_pct"):+.2f}%)')

    peers = snap.get('peers') or []
    if peers:
        lines.append('')
        lines.append('### 비교업체 (사용자가 등록한 종목)')
        for p in peers:
            if p.get('error'):
                lines.append(f'- {p["name"]} ({p["code"]}): 조회 실패 ({p["error"]})')
                continue
            lines.append(
                f'- {p["name"]} ({p["code"]}): {p["price"]:,}원 '
                f'({p["change"]:+,} / {p["change_pct"]:+.2f}%) · '
                f'PER {p["per"]} · PBR {p["pbr"]} · ROE {p["roe"]}%'
            )

    return '\n'.join(lines)


def call_llm(prompt: str, timeout: int = LLM_TIMEOUT_SEC) -> str:
    """openclaw capability model run subprocess. 실패 시 RuntimeError raise."""
    cmd = [
        'openclaw', 'capability', 'model', 'run',
        '--prompt', prompt,
        '--model', LLM_MODEL,
        '--json',
    ]
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    except subprocess.TimeoutExpired:
        raise RuntimeError(f'LLM 호출 timeout ({timeout}s)')
    if p.returncode != 0:
        raise RuntimeError(f'LLM 호출 실패 rc={p.returncode}: {p.stderr[:300]}')
    try:
        data = json.loads(p.stdout)
    except json.JSONDecodeError as e:
        raise RuntimeError(f'LLM 응답 JSON 파싱 실패: {e}; stdout[:300]={p.stdout[:300]}')
    if not data.get('ok'):
        raise RuntimeError(f'LLM 응답 ok=false: {data}')
    outs = data.get('outputs') or []
    if not outs:
        raise RuntimeError('LLM 응답 outputs 비어있음')
    text = (outs[0].get('text') or '').strip()
    if not text:
        raise RuntimeError('LLM 응답 text 비어있음')
    return text


# ============================================================================
# LLM 응답 파싱 — ONELINE / 4섹션 / VERDICT_JSON
# ============================================================================

def parse_llm_output(text: str) -> dict:
    """LLM raw text를 dict로. 형식 위반 시 best-effort fallback.

    반환: {oneline, sections[4], verdict: {recommendation_pct, label, one_line}, raw}
    """
    lines = text.splitlines()
    oneline = ''
    sections: list[dict] = []
    current: dict | None = None
    verdict = {'recommendation_pct': 50, 'label': '중립', 'one_line': ''}

    i = 0
    while i < len(lines):
        line = lines[i]
        stripped = line.strip()

        if stripped.startswith('ONELINE:'):
            oneline = stripped[len('ONELINE:'):].strip()
            i += 1
            continue
        if stripped.startswith('VERDICT_JSON:'):
            # 같은 줄 JSON 시도
            json_part = stripped[len('VERDICT_JSON:'):].strip()
            if not json_part:
                # 다음 줄들 합쳐서 JSON 찾기
                buf = []
                j = i + 1
                while j < len(lines):
                    buf.append(lines[j])
                    j += 1
                json_part = '\n'.join(buf).strip()
            # 코드펜스 제거
            if json_part.startswith('```'):
                json_part = json_part.split('\n', 1)[1] if '\n' in json_part else ''
                if json_part.endswith('```'):
                    json_part = json_part.rsplit('```', 1)[0]
                json_part = json_part.strip()
            try:
                v = json.loads(json_part)
                verdict['recommendation_pct'] = max(0, min(100, int(v.get('recommendation_pct', 50))))
                verdict['label'] = str(v.get('label') or _verdict_label(verdict['recommendation_pct']))
                verdict['one_line'] = str(v.get('one_line') or '')
            except Exception:
                pass
            break  # VERDICT_JSON 뒤는 없음

        if stripped.startswith('▣'):
            if current:
                sections.append(current)
            current = {'title': stripped, 'body': ''}
            i += 1
            continue

        if current is not None:
            current['body'] += line + '\n'
        i += 1

    if current:
        sections.append(current)

    # body trim
    for s in sections:
        s['body'] = s['body'].strip()

    # 라벨 보정 (LLM이 비표준 라벨 주면 % 기준으로 결정)
    if verdict['label'] not in ('추천 안 함', '관망', '중립', '매수 검토', '강력 추천'):
        verdict['label'] = _verdict_label(verdict['recommendation_pct'])

    return {
        'oneline': oneline,
        'sections': sections,
        'verdict': verdict,
        'raw': text,
    }


def _verdict_label(pct: int) -> str:
    if pct <= 20:
        return '추천 안 함'
    if pct <= 40:
        return '관망'
    if pct <= 60:
        return '중립'
    if pct <= 80:
        return '매수 검토'
    return '강력 추천'


# ============================================================================
# 보고서 HTML 빌더
# ============================================================================

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


def _fmt_num(v) -> str:
    try:
        n = int(v)
        return f'{n:,}'
    except Exception:
        return str(v)


def _fmt_pct_signed(v: float) -> str:
    return f'{v:+.2f}%'


_EM_RE = re.compile(r'\*\*(.+?)\*\*')


def _emphasize(escaped: str) -> str:
    """이스케이프된 텍스트의 **강조** → 붉은 강조 글씨. 입력은 이미 _esc 처리된 상태."""
    return _EM_RE.sub(r'<span class="rpt-em">\1</span>', escaped)


def _fmt_eok_jo(eok) -> str:
    """억 단위 금액 → 조 단위. 1,997,447억 → 199.7조. 1조 미만은 소수 둘째자리. 비숫자는 —."""
    if not isinstance(eok, (int, float)):
        return '—'
    jo = eok / 10000
    return f'{jo:,.2f}조' if abs(jo) < 1 else f'{jo:,.1f}조'


def _fmt_market_cap(eok) -> str:
    """키움 mac(억원) → 사람이 읽는 '2,084조 1,983억원'. 0/None은 '—'."""
    if not isinstance(eok, (int, float)) or eok <= 0:
        return '—'
    eok = int(eok)
    jo, rem = divmod(eok, 10000)
    if jo and rem:
        return f'{jo:,}조 {rem:,}억원'
    if jo:
        return f'{jo:,}조원'
    return f'{rem:,}억원'


def _fmt_date(s) -> str:
    """날짜를 yy/mm/dd로. 20260519 · 2026-05-19 · 2026/05/19 → 26/05/19. 형식 다르면 원본."""
    s = str(s or '').strip()
    d = s.replace('-', '').replace('/', '').replace('.', '')
    if len(d) == 8 and d.isdigit():
        return f'{d[2:4]}/{d[4:6]}/{d[6:]}'
    return s


def _fmt_period(s) -> str:
    """회계기간 2021/12 · 2021.12 · 202112 → 21/12. 형식 다르면 원본."""
    s = str(s or '').strip()
    d = s.replace('-', '').replace('/', '').replace('.', '')
    if len(d) == 6 and d.isdigit():
        return f'{d[2:4]}/{d[4:6]}'
    return s


def _fmt_card_ts(ts: str) -> str:
    """2026-05-19_17-09-50 → 2026-05-19  17:09. 보고서 카드용."""
    if not ts:
        return ''
    if '_' in ts:
        d, t = ts.split('_', 1)
        # t는 17-09-50. 분까지만, : 로
        tp = t.split('-')
        return f'{d}  {tp[0]}:{tp[1]}' if len(tp) >= 2 else f'{d}  {t}'
    return ts


_KV_HINTS = {
    '현재가': '지금 거래되고 있는 가격이에요. 옆 숫자는 어제 종가 대비 얼마나 움직였는지(절대값/%)예요.',
    '오늘 시가': '오늘 장 시작할 때 첫 거래 가격이에요.',
    '오늘 고가': '오늘 장중 가장 높았던 가격이에요.',
    '오늘 저가': '오늘 장중 가장 낮았던 가격이에요.',
    '시가총액': '회사 전체 주식 수 × 현재가. 회사의 시장 규모예요. 클수록 대형주.',
    '상장주식수': '시장에 풀려 있는 총 주식 수예요.',
    '52주 최고': '최근 1년(약 250 거래일) 안에 가장 비쌌던 가격이에요.',
    '52주 최저': '최근 1년 안에 가장 쌌던 가격이에요.',
    '회전율 (오늘)': '오늘 거래량 ÷ 상장주식수 × 100. 거래가 활발할수록 높아요. 보통 1% 넘으면 활기.',
    'PER': '주가수익비율 (Price / EPS). 주가가 1주당 이익의 몇 배인지. 낮을수록 저평가(일반론). 업종마다 평균이 달라요.',
    'PBR': '주가순자산비율 (Price / BPS). 주가가 1주당 순자산의 몇 배인지. 1보다 낮으면 청산가치 이하라는 의미예요.',
    'ROE': '자기자본이익률. 회사가 가진 자본으로 1년에 얼마를 벌었는지(%). 높을수록 자본 활용 효율이 좋아요. 10% 이상이면 양호.',
    'EPS': '주당순이익. 1주당 회사가 번 순이익(원).',
    'BPS': '주당순자산. 1주당 회사가 가진 순자산 가치(원).',
    '외국인 보유비율': '전체 발행 주식 중 외국인이 보유한 비율(%).',
    # ── 성장성·펀더멘털 (FnGuide) ──
    '매출액(조)': '회사가 1년에 물건·서비스를 팔아 벌어들인 총액(조원). 회사 덩치가 커지는지 보는 지표.',
    '매출증가율': '작년보다 매출이 몇 % 늘었는지. +면 성장, −면 역성장. 꾸준히 +면 좋아요.',
    '영업이익(조)': '본업으로 번 이익(조원). 매출에서 원가·판관비를 뺀 것. 회사가 장사를 잘하는지.',
    'EPS(원)': '주당순이익. 1주가 1년에 번 순이익(원). 높을수록, 그리고 늘어날수록 좋아요.',
    'EPS증가율': '작년보다 주당순이익이 몇 % 늘었는지. 이익이 실제로 커지는지 보는 핵심 지표.',
    '목표주가': '증권사들이 "이 정도까진 오를 만하다"고 본 가격의 평균. 현재가보다 높으면 상승 기대.',
    '투자의견': '증권사들의 종합 의견. 1=매도 … 5=적극매수. 숫자가 클수록 사라는 쪽.',
    '추정 EPS': '증권사들이 예상한 올해(미래) 주당순이익. 앞으로 얼마 벌지 전망치예요.',
    '추정 PER': '추정 이익 기준 PER(주가÷예상EPS). 미래 이익 대비 주가가 비싼지 싼지.',
    '참여 기관수': '이 컨센서스(평균)를 만든 증권사 수. 많을수록 신뢰도가 높아요.',
    # ── 컨센서스 (WISEreport) ──
    'YoY': '전년 대비(Year over Year) 증가율(%). 1년 전 같은 기간과 비교한 변화예요.',
    '목표주가 리비전': '최근 3개월간 증권사 목표주가 평균이 오르고(+) 있는지 내리고(−) 있는지. 오르는 중이면 시장 기대가 좋아지는 신호.',
    '추정치 리비전': '최근 3개월간 예상 실적(EPS 등)이 상향(+)/하향(−)되는 중인지. 상향이면 전망이 밝아지는 거예요.',
    '서프라이즈': '실제 발표 실적이 직전 예상치보다 잘 나왔으면(+) 어닝 서프라이즈, 못 나왔으면(−) 어닝 쇼크.',
    '실적(조)': '실제로 확정된 작년 실적 금액(조원). 예상치가 아니라 진짜 나온 숫자.',
}


def _lbl(label: str) -> str:
    """라벨에 설명(ⓘ 탭 팝업)이 있으면 감싸고, 없으면 그냥 텍스트. 새 표 헤더용."""
    hint = _KV_HINTS.get(label)
    if hint:
        return f'<span class="info-label" data-info="{_esc(hint)}">{_esc(label)}</span>'
    return _esc(label)


def _kv_row(label: str, value_html: str) -> str:
    """th 라벨. hint 있으면 누름/뗌 팝업으로 띄우는 span. 없으면 plain text."""
    hint = _KV_HINTS.get(label)
    if hint:
        label_html = (
            f'<span class="info-label" data-info="{_esc(hint)}">{_esc(label)}</span>'
        )
    else:
        label_html = _esc(label)
    return f'<tr><th>{label_html}</th><td>{value_html}</td></tr>'


def _verdict_color(pct: int) -> str:
    if pct <= 20:
        return '#ef4444'   # 빨강
    if pct <= 40:
        return '#f97316'   # 주황
    if pct <= 60:
        return '#9ca3af'   # 회색
    if pct <= 80:
        return '#22c55e'   # 초록
    return '#16a34a'       # 진초록


def _render_verdict_bar(pct: int, label: str, one_line: str) -> str:
    """추천도 게이지 막대 SVG."""
    pct = max(0, min(100, int(pct)))
    color = _verdict_color(pct)
    bar_w = 600
    bar_h = 28
    fill_w = bar_w * pct / 100
    # 구간 경계선 (20·40·60·80%)
    boundaries = []
    for b in (20, 40, 60, 80):
        x = bar_w * b / 100
        boundaries.append(
            f'<line x1="{x}" y1="0" x2="{x}" y2="{bar_h}" stroke="#0f1419" stroke-width="1"/>'
        )
    svg = (
        f'<svg viewBox="0 0 {bar_w} {bar_h+22}" xmlns="http://www.w3.org/2000/svg" '
        f'style="width:100%;max-width:600px;height:auto;display:block;margin:6px 0;">'
        f'<rect x="0" y="0" width="{bar_w}" height="{bar_h}" fill="#1f2937" rx="4"/>'
        f'<rect x="0" y="0" width="{fill_w}" height="{bar_h}" fill="{color}" rx="4"/>'
        + ''.join(boundaries)
        + f'<text x="{bar_w/2}" y="{bar_h/2+5}" text-anchor="middle" fill="#ffffff" '
          f'font-weight="bold" font-size="14">{pct}% · {_esc(label)}</text>'
        + f'<text x="0" y="{bar_h+16}" fill="#7a8493" font-size="10">0 추천 안 함</text>'
        + f'<text x="{bar_w}" y="{bar_h+16}" text-anchor="end" fill="#7a8493" font-size="10">100 강력 추천</text>'
        + '</svg>'
    )
    return svg


def _fmt_growth(v) -> str:
    """증가율(%) → 색상 포함 셀. 빨강=증가/파랑=감소 (국내 관행)."""
    if not isinstance(v, (int, float)):
        return '<span style="color:#7a8493">—</span>'
    color = '#ef4444' if v > 0 else ('#3b82f6' if v < 0 else '#9ca3af')
    return f'<span style="color:{color};font-weight:700">{v:+.1f}%</span>'


def _render_fundamentals_html(fund: dict | None) -> str:
    """FnGuide 성장성·컨센서스 섹션. fund 없으면 빈 문자열(섹션 자체 생략)."""
    if not fund:
        return ''
    annual = fund.get('annual') or []
    consensus = fund.get('consensus') or {}

    # ---- 연간 성장성 표 (최근 5개 연도) ----
    table_html = ''
    if annual:
        rows = annual[-5:]
        body = []
        for r in rows:
            sales = r.get('sales')
            oper = r.get('oper_profit')
            eps = r.get('eps')
            roe = r.get('roe')
            body.append(
                '<tr>'
                f'<td>{_esc(_fmt_period(r.get("period","")))}</td>'
                f'<td style="text-align:right">{_fmt_eok_jo(sales)}</td>'
                f'<td style="text-align:right">{_fmt_growth(r.get("sales_growth"))}</td>'
                f'<td style="text-align:right">{_fmt_eok_jo(oper)}</td>'
                f'<td style="text-align:right">{_fmt_num(round(eps)) if isinstance(eps,(int,float)) else "—"}</td>'
                f'<td style="text-align:right">{_fmt_growth(r.get("eps_growth"))}</td>'
                f'<td style="text-align:right">{(str(roe)+"%") if isinstance(roe,(int,float)) else "—"}</td>'
                '</tr>'
            )
        table_html = (
            '<div class="tbl-scroll"><table class="flowtable">'
            '<thead><tr>'
            f'<th>기간</th><th>{_lbl("매출액(조)")}</th><th>{_lbl("매출증가율")}</th>'
            f'<th>{_lbl("영업이익(조)")}</th><th>{_lbl("EPS(원)")}</th>'
            f'<th>{_lbl("EPS증가율")}</th><th>{_lbl("ROE")}</th>'
            '</tr></thead>'
            '<tbody>' + ''.join(body) + '</tbody></table></div>'
        )

    # ---- 컨센서스 카드 ----
    cons_html = ''
    if consensus:
        tp = consensus.get('target_price')
        op_label = consensus.get('opinion_label')
        op = consensus.get('opinion')
        ce = consensus.get('eps')
        cper = consensus.get('per')
        oc = consensus.get('organ_count')
        parts = []
        if isinstance(tp, (int, float)):
            parts.append(f'<tr><th>{_lbl("목표주가")}</th><td style="font-weight:700;color:#fbbf24">{_fmt_num(round(tp))}원</td></tr>')
        if op_label:
            parts.append(f'<tr><th>{_lbl("투자의견")}</th><td>{_esc(op_label)} '
                         f'<span style="color:#7a8493;font-size:11px">({op}/5)</span></td></tr>')
        if isinstance(ce, (int, float)):
            parts.append(f'<tr><th>{_lbl("추정 EPS")}</th><td>{_fmt_num(round(ce))}원</td></tr>')
        if isinstance(cper, (int, float)):
            parts.append(f'<tr><th>{_lbl("추정 PER")}</th><td>{cper}배</td></tr>')
        if isinstance(oc, (int, float)):
            parts.append(f'<tr><th>{_lbl("참여 기관수")}</th><td>{int(oc)}개</td></tr>')
        if parts:
            cdate = consensus.get('date')
            date_html = (f'<span style="color:#7a8493;font-size:11px"> · 기준 {_esc(_fmt_date(cdate))}</span>'
                         if cdate else '')
            cons_html = (
                f'<h4 style="margin:10px 0 4px">📋 컨센서스{date_html}</h4>'
                '<table class="kvtable">' + ''.join(parts) + '</table>'
            )

    if not table_html and not cons_html:
        return ''
    note = ('<p style="color:#7a8493;font-size:11px;margin-top:6px">'
            '출처: FnGuide 컴퍼니가이드 · 증가율은 전년대비(YoY)</p>')
    return (
        '<section class="rpt-section">'
        '<h3>📈 성장성 · 컨센서스</h3>'
        '<p class="rpt-cap">회사가 해마다 얼마나 더 벌고 있는지(성장성)와, 증권사들이 본 적정 주가·이익 전망(컨센서스)이에요. '
        '밑줄 친 항목 이름(<b>ⓘ</b>)을 누르면 쉬운 설명이 떠요.</p>'
        + table_html + cons_html + note +
        '</section>'
    )


def _render_consensus_html(cons: dict | None) -> str:
    """WISEreport 컨센서스 섹션 — 연도별 추정표 + 목표가 리비전 + 어닝 서프라이즈."""
    if not cons:
        return ''
    annual = cons.get('annual') or []
    rev = cons.get('revision') or {}
    sup = cons.get('surprise') or {}

    parts = []

    # ---- 목표주가 리비전 (3개월 추이) ----
    if rev:
        tchg = rev.get('target_change_pct')
        echg = rev.get('est_change_pct')
        bits = []
        if isinstance(rev.get('target_first'), (int, float)) and isinstance(rev.get('target_last'), (int, float)):
            bits.append(
                f'목표주가 {_fmt_num(round(rev["target_first"]))} → '
                f'<b>{_fmt_num(round(rev["target_last"]))}원</b> {_fmt_growth(tchg)}'
            )
        if isinstance(rev.get('est_first'), (int, float)) and isinstance(rev.get('est_last'), (int, float)):
            nm = _esc(rev.get('item_name') or '추정')
            bits.append(f'{nm} {_fmt_num(round(rev["est_first"]))} → '
                        f'<b>{_fmt_num(round(rev["est_last"]))}</b> {_fmt_growth(echg)}')
        if bits:
            n = rev.get('n_points')
            parts.append(
                '<h4 style="margin:10px 0 4px">🔁 목표주가·추정치 리비전 '
                f'<span style="color:#7a8493;font-size:11px">(최근 {n}주)</span></h4>'
                '<p style="margin:2px 0">' + ' · '.join(bits) + '</p>'
            )

    # ---- 어닝 서프라이즈 ----
    items = sup.get('items') or {}
    sup_rows = []
    for label, v in items.items():
        if v.get('fy0_surprise_pct') is None and v.get('fy0_actual') is None:
            continue
        yr = v.get('fy0_year') or ''
        act = v.get('fy0_actual')
        sp = v.get('fy0_surprise_pct')
        sup_rows.append(
            '<tr>'
            f'<td>{_esc(label)}</td>'
            f'<td style="text-align:right">{_fmt_eok_jo(act)}</td>'
            f'<td style="text-align:right">{_fmt_growth(sp)}</td>'
            '</tr>'
        )
    if sup_rows:
        any_year = next((v.get('fy0_year') for v in items.values() if v.get('fy0_year')), '')
        parts.append(
            f'<h4 style="margin:10px 0 4px">🎯 어닝 서프라이즈 '
            f'<span style="color:#7a8493;font-size:11px">({_esc(any_year)} 실적 vs 직전 컨센서스)</span></h4>'
            '<table class="flowtable">'
            f'<thead><tr><th>항목</th><th>{_lbl("실적(조)")}</th><th>{_lbl("서프라이즈")}</th></tr></thead>'
            '<tbody>' + ''.join(sup_rows) + '</tbody></table>'
        )

    # ---- 연도별 추정 재무 (A 실적 + E 추정) ----
    if annual:
        body = []
        for r in annual:
            est = r.get('is_estimate')
            tag = ('<span style="color:#fbbf24;font-size:11px"> (E)</span>' if est
                   else '<span style="color:#7a8493;font-size:11px"> (A)</span>')
            sales = r.get('sales'); op = r.get('op'); eps = r.get('eps'); roe = r.get('roe')
            body.append(
                '<tr' + (' style="opacity:.85"' if est else '') + '>'
                f'<td>{_esc(_fmt_period(r.get("period","")))}{tag}</td>'
                f'<td style="text-align:right">{_fmt_eok_jo(sales)}</td>'
                f'<td style="text-align:right">{_fmt_growth(r.get("sales_yoy"))}</td>'
                f'<td style="text-align:right">{_fmt_eok_jo(op)}</td>'
                f'<td style="text-align:right">{_fmt_num(round(eps)) if isinstance(eps,(int,float)) else "—"}</td>'
                f'<td style="text-align:right">{(str(roe)+"%") if isinstance(roe,(int,float)) else "—"}</td>'
                '</tr>'
            )
        parts.append(
            '<h4 style="margin:10px 0 4px">📅 연도별 추정 재무 '
            '<span style="color:#7a8493;font-size:11px">(IFRS연결 · A 실적 / E 추정)</span></h4>'
            '<div class="tbl-scroll"><table class="flowtable">'
            f'<thead><tr><th>기간</th><th>{_lbl("매출액(조)")}</th><th>{_lbl("YoY")}</th>'
            f'<th>{_lbl("영업이익(조)")}</th><th>{_lbl("EPS(원)")}</th><th>{_lbl("ROE")}</th></tr></thead>'
            '<tbody>' + ''.join(body) + '</tbody></table></div>'
        )

    if not parts:
        return ''
    note = ('<p style="color:#7a8493;font-size:11px;margin-top:6px">'
            '출처: FnGuide WISEreport 컨센서스(증권사 추정 3개월 평균). '
            '추정 절대값은 데이터원 원본이며 실제 스케일과 다를 수 있습니다(방향성·괴리율 위주로 해석).</p>')
    return (
        '<section class="rpt-section">'
        '<h3>🔮 컨센서스 (추정·리비전·서프라이즈)</h3>'
        '<p class="rpt-cap">증권사들이 본 <b>미래 실적 전망</b>이에요. '
        '<b>리비전</b>=그 전망이 최근 3개월간 좋아지는지(+)/나빠지는지(−), '
        '<b>서프라이즈</b>=지난 실적이 예상보다 잘 나왔는지(+)/못 나왔는지(−), '
        '<b>연도별 추정</b>=해마다 얼마 벌지 예상치(E)와 실제(A). 밑줄 항목(<b>ⓘ</b>)을 누르면 설명이 떠요.</p>'
        + ''.join(parts) + note +
        '</section>'
    )


def build_report_html(snap: dict, parsed: dict) -> str:
    """전체 보고서 HTML 단편 빌드. behive_web 페이지에 그대로 임베드.

    상단 6줄 주석에 메타정보 (oneline·recommendation·generated_at) 박아 list_reports가 파싱.
    """
    b = snap['basic']
    fs = snap['flow_summary']
    code = snap['code']
    name = snap['name']
    ts = ts_for_display()
    verdict = parsed['verdict']
    pct = verdict['recommendation_pct']
    label = verdict['label']
    oneline = parsed['oneline'] or verdict.get('one_line', '')
    holding = snap.get('holding')

    # 메타 주석 (첫 6줄 안에) — list_reports가 파싱
    head_meta = (
        f'<!-- oneline: {_esc(oneline)} -->\n'
        f'<!-- recommendation: {pct}% / {_esc(label)} -->\n'
        f'<!-- generated_at: {snap.get("snapshot_at","")} -->\n'
    )

    # ---- 기본 정보 카드 ----
    # peers가 있으면 본 종목 + peer 컬럼을 한 표에 가로 배치, 없으면 기존 kvtable 유지.
    peers = snap.get('peers') or []
    change_color = '#ef4444' if b['change'] > 0 else ('#3b82f6' if b['change'] < 0 else '#9ca3af')

    def _peer_price_cell(p: dict) -> str:
        if p.get('error'):
            return '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
        pc = '#ef4444' if p['change'] > 0 else ('#3b82f6' if p['change'] < 0 else '#9ca3af')
        return (f'<span style="color:{pc};font-weight:700">{p["price"]:,}원</span> '
                f'<span style="color:{pc};font-size:11px">({_fmt_pct_signed(p["change_pct"])})</span>')

    def _peer_simple(p: dict, field: str, suffix: str) -> str:
        if p.get('error'):
            return '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
        v = p.get(field)
        return f'{v}{suffix}' if v is not None else '—'

    def _peer_int(p: dict, field: str, suffix: str) -> str:
        if p.get('error'):
            return '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
        v = p.get(field)
        return f'{v:,}{suffix}' if isinstance(v, (int, float)) else '—'

    def _peer_mac(p: dict) -> str:
        if p.get('error'):
            return '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
        v = p.get('market_cap')
        return _fmt_market_cap(v) if isinstance(v, (int, float)) else '—'

    def _peer_pct(p: dict, field: str) -> str:
        if p.get('error'):
            return '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
        v = p.get(field)
        return f'{v:.2f}%' if isinstance(v, (int, float)) else '—'

    def _peer_52w(p: dict, value_field: str, date_field: str) -> str:
        if p.get('error'):
            return '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
        v = p.get(value_field)
        if not isinstance(v, (int, float)):
            return '—'
        dt = p.get(date_field)
        date_html = f' <span style="color:#7a8493;font-size:11px">({_fmt_date(dt)})</span>' if dt else ''
        return f'{v:,}원{date_html}'

    # (label, self_value_html, peer_value_fn or None)
    basic_rows: list[tuple[str, str, callable | None]] = [
        ('현재가', f'<span style="color:{change_color};font-weight:700">{b["price"]:,}원</span> '
                   f'<span style="color:{change_color}">({b["change"]:+,} / {_fmt_pct_signed(b["change_pct"])})</span>',
                   _peer_price_cell),
        ('오늘 시가', f'{b["open"]:,}원', lambda p: _peer_int(p, 'open', '원')),
        ('오늘 고가', f'{b["high"]:,}원', lambda p: _peer_int(p, 'high', '원')),
        ('오늘 저가', f'{b["low"]:,}원', lambda p: _peer_int(p, 'low', '원')),
        ('시가총액', _fmt_market_cap(b["market_cap"]), _peer_mac),
        ('상장주식수', f'{b["listed_shares"]:,}주', lambda p: _peer_int(p, 'listed_shares', '주')),
        ('52주 최고', f'{b["high_52w"]:,}원 <span style="color:#7a8493;font-size:11px">({_fmt_date(b["high_52w_dt"])})</span>',
                    lambda p: _peer_52w(p, 'high_52w', 'high_52w_dt')),
        ('52주 최저', f'{b["low_52w"]:,}원 <span style="color:#7a8493;font-size:11px">({_fmt_date(b["low_52w_dt"])})</span>',
                    lambda p: _peer_52w(p, 'low_52w', 'low_52w_dt')),
        ('회전율 (오늘)', f'{b["turnover_rate"]:.2f}%', lambda p: _peer_pct(p, 'turnover_rate')),
        ('PER', f'{b["per"]}배', lambda p: _peer_simple(p, 'per', '배')),
        ('PBR', f'{b["pbr"]}배', lambda p: _peer_simple(p, 'pbr', '배')),
        ('ROE', f'{b["roe"]}%', lambda p: _peer_simple(p, 'roe', '%')),
        ('EPS', f'{b["eps"]:,}원', lambda p: _peer_int(p, 'eps', '원')),
        ('BPS', f'{b["bps"]:,}원', lambda p: _peer_int(p, 'bps', '원')),
        ('외국인 보유비율', f'{b["foreign_holding_pct"]:.2f}%', lambda p: _peer_pct(p, 'foreign_holding_pct')),
    ]
    if peers:
        # 본 종목 + peer 컬럼이 있는 가로 표. 비교 불가 행은 peer 셀에 dash.
        header_cells = [
            '<th></th>',
            f'<th>{_esc(name)} <span style="color:#fbbf24;font-size:11px">[본 종목]</span></th>',
        ]
        for p in peers:
            pname = _esc(p['name'])
            pcode = _esc(p['code'])
            header_cells.append(f'<th>{pname} <span style="color:#7a8493;font-size:11px">({pcode})</span></th>')
        body_rows: list[str] = []
        for label, self_v, peer_fn in basic_rows:
            hint = _KV_HINTS.get(label)
            label_html = (
                f'<span class="info-label" data-info="{_esc(hint)}">{_esc(label)}</span>'
                if hint else _esc(label)
            )
            cells = [f'<th class="lbl">{label_html}</th>', f'<td>{self_v}</td>']
            for p in peers:
                cells.append(f'<td>{peer_fn(p) if peer_fn else "—"}</td>')
            body_rows.append('<tr>' + ''.join(cells) + '</tr>')
        basic_html = (
            '<table class="basicpeertable">'
            '<thead><tr>' + ''.join(header_cells) + '</tr></thead>'
            '<tbody>' + ''.join(body_rows) + '</tbody>'
            '</table>'
        )
    else:
        basic_html = '<table class="kvtable">' + ''.join(
            _kv_row(k, v) for k, v, _ in basic_rows
        ) + '</table>'

    # ---- 차트 SVG (3개 기간 미리 렌더) ----
    chart_1y = render_svg_chart(snap, '1Y')
    chart_6m = render_svg_chart(snap, '6M')
    chart_1m = render_svg_chart(snap, '1M')
    # 오늘 회전율·RVOL 값 — 차트 아래 표시. 장중 생성 시 종가 기준 환산('예상').
    _vfrac = session_vol_frac(snap.get('candles_asc') or [])
    _cands = snap.get('candles_asc') or []
    _turn, _tscaled = turnover_today(_cands, _vfrac)
    _rvol, _rscaled = rvol_today(_cands, _vfrac)
    _cap_bits = []
    if _rvol is not None:
        _cap_bits.append(f'<b>RVOL</b> x{_rvol:.1f}')
    if _turn is not None:
        _cap_bits.append(f'<b>회전율</b> {_turn:.2f}%')
    _turn_cap = (
        f'<div class="chart-cap">{" · ".join(_cap_bits)}{" (예상)" if (_tscaled or _rscaled) else ""}</div>'
        if _cap_bits else ''
    )
    chart_block = (
        '<div class="chart-tabs">'
        '<button type="button" data-range="1Y" class="active">1년</button>'
        '<button type="button" data-range="6M">6개월</button>'
        '<button type="button" data-range="1M">1개월</button>'
        '</div>'
        f'<div class="chart-panel active" data-range="1Y">{chart_1y}</div>'
        f'<div class="chart-panel" data-range="6M">{chart_6m}</div>'
        f'<div class="chart-panel" data-range="1M">{chart_1m}</div>'
        + _turn_cap
    )

    # ---- 외국인/기관 수급 표 (최근 20일) ----
    flow_rows = []
    for r in snap['flow']:
        f_color = '#ef4444' if r['foreign'] > 0 else ('#3b82f6' if r['foreign'] < 0 else '#9ca3af')
        i_color = '#ef4444' if r['institution'] > 0 else ('#3b82f6' if r['institution'] < 0 else '#9ca3af')
        p_color = '#ef4444' if r['individual'] > 0 else ('#3b82f6' if r['individual'] < 0 else '#9ca3af')
        flow_rows.append(
            f'<tr>'
            f'<td>{_esc(_fmt_date(r["date"]))}</td>'
            f'<td style="text-align:right">{r["close"]:,}</td>'
            f'<td style="text-align:right;color:{f_color}">{r["foreign"]*1000:+,}</td>'
            f'<td style="text-align:right;color:{i_color}">{r["institution"]*1000:+,}</td>'
            f'<td style="text-align:right;color:{p_color}">{r["individual"]*1000:+,}</td>'
            f'</tr>'
        )
    flow_html = (
        '<table class="flowtable">'
        '<thead><tr><th>일자</th><th>종가</th><th>외국인</th><th>기관</th><th>개인</th></tr></thead>'
        '<tbody>' + ''.join(flow_rows) + '</tbody>'
        '</table>'
        f'<p class="flowsum"><b>20일 누적</b><br>'
        f'<b style="color:{"#ef4444" if fs["foreign_net"]>0 else "#3b82f6"}">외국인 {fs["foreign_net"]*1000:+,}주</b> '
        f'(매수일 {fs["foreign_buy_days"]}/20)<br>'
        f'<b style="color:{"#ef4444" if fs["institution_net"]>0 else "#3b82f6"}">기관 {fs["institution_net"]*1000:+,}주</b> '
        f'(매수일 {fs["institution_buy_days"]}/20)<br>'
        f'개인 {fs["individual_net"]*1000:+,}주</p>'
    )

    # 비교업체는 기본 정보 표에 컬럼으로 통합됨 (위 basic_html 참조).

    # ---- 보유 정보 (있을 때) ----
    holding_html = ''
    if holding:
        pp = holding.get('profit_pct', 0)
        pp_color = '#ef4444' if pp > 0 else ('#3b82f6' if pp < 0 else '#9ca3af')
        holding_html = (
            '<section class="rpt-section">'
            '<h3>💼 내 포지션</h3>'
            '<table class="kvtable">'
            f'<tr><th>평균단가</th><td>{holding.get("avg_price",0):,}원</td></tr>'
            f'<tr><th>보유수량</th><td>{holding.get("qty",0):,}주</td></tr>'
            f'<tr><th>수익률</th><td style="color:{pp_color};font-weight:700">{pp:+.2f}%</td></tr>'
            '</table></section>'
        )

    # ---- LLM 코멘트 섹션 ----
    comment_html_parts = ['<section class="rpt-section"><h3>✍️ 분석 코멘트</h3>']
    for s in parsed['sections']:
        title = _esc(s['title'])
        # 본문 단락 분리 — 빈 줄 기준
        paras = [p.strip() for p in s['body'].split('\n\n') if p.strip()]
        if not paras:
            paras = [s['body']]
        body_html = ''.join(f'<p>{_emphasize(_esc(para)).replace(chr(10), "<br>")}</p>' for para in paras)
        comment_html_parts.append(
            f'<div class="rpt-llm-block"><h4>{title}</h4>{body_html}</div>'
        )
    comment_html_parts.append('</section>')
    comment_html = ''.join(comment_html_parts)

    # ---- 투자의견 게이지 ----
    verdict_color = _verdict_color(pct)
    verdict_html = (
        '<section class="rpt-section rpt-verdict">'
        '<h3>🎯 투자의견</h3>'
        f'{_render_verdict_bar(pct, label, verdict.get("one_line",""))}'
        f'<p class="verdict-line">→ {_esc(verdict.get("one_line",""))}</p>'
        '<p class="rpt-disclaimer">※ 참고용 · LLM 자동 생성 · 최종 판단은 관리자님</p>'
        '</section>'
    )

    # ---- 조립 ----
    # 한 화면 구성: 결론(요약+투자의견)을 맨 위로, 성장성·컨센서스·포지션은 2단 그리드로
    # 나란히, 폭이 필요한 기본정보·차트·수급·코멘트는 전체폭.
    grid_inner = (
        _render_fundamentals_html(snap.get('fundamentals'))
        + _render_consensus_html(snap.get('consensus'))
        + holding_html
    )
    grid_html = f'<div class="rpt-cols">{grid_inner}</div>' if grid_inner.strip() else ''

    body = (
        head_meta
        + '<article class="rpt">'
        + f'<header class="rpt-header">'
        + f'<h2>📑 {_esc(name)} <span class="rpt-code">({_esc(code)})</span></h2>'
        + f'<p class="rpt-ts">작성: {_esc(ts)}</p>'
        + '</header>'
        + (f'<div class="rpt-oneline">📌 {_esc(oneline)}</div>' if oneline else '')
        + verdict_html
        + f'<section class="rpt-section"><h3>📊 기본 정보</h3>{basic_html}</section>'
        + grid_html
        + f'<section class="rpt-section"><h3>📈 캔들 + 이평선 + 거래량</h3>{chart_block}</section>'
        + f'<section class="rpt-section"><h3>💰 외국인·기관 수급 (최근 20일, 단위: 주)</h3>{flow_html}</section>'
        + comment_html
        + '</article>'
    )
    return body


# ============================================================================
# 보고서 생성기 — collect → llm → parse → build → save
# ============================================================================

def generate_report(code: str, holding: dict | None = None) -> dict:
    """보고서 1개 생성 후 저장. 반환: {ok, path, error}.

    동기 호출 — 보통 워커 thread에서 부르므로 caller가 시간을 안다.
    """
    try:
        snap = collect_snapshot(code, holding=holding)
        prompt = load_prompt_template().format(data_block=_build_data_block(snap))
        text = call_llm(prompt)
        parsed = parse_llm_output(text)
        html = build_report_html(snap, parsed)
        p = save_report(code, html)
        return {'ok': True, 'path': str(p), 'filename': p.name}
    except Exception as e:
        return {'ok': False, 'error': str(e)}


# ============================================================================
# 큐 + 워커
# ============================================================================

_queue_locks: dict[str, threading.Lock] = {}
_queue_locks_global = threading.Lock()


def _code_lock(code: str) -> threading.Lock:
    with _queue_locks_global:
        lk = _queue_locks.get(code)
        if lk is None:
            lk = threading.Lock()
            _queue_locks[code] = lk
        return lk


def enqueue(code: str, holding: dict | None = None) -> dict:
    """새 보고서 요청. 이미 running이면 무시. 새 thread로 generate_report 실행.

    반환: {status: 'queued'|'already_running', queue: <queue dict>}
    """
    state = read_queue(code)
    if state.get('status') == 'running':
        return {'status': 'already_running', 'queue': state}

    write_queue(code, {'status': 'running', 'started_at': now_kst().isoformat()})

    def _worker():
        lk = _code_lock(code)
        with lk:
            try:
                result = generate_report(code, holding=holding)
                if result['ok']:
                    write_queue(code, {
                        'status': 'done',
                        'last_filename': result['filename'],
                        'finished_at': now_kst().isoformat(),
                    })
                else:
                    write_queue(code, {
                        'status': 'failed',
                        'error': result.get('error', 'unknown'),
                        'finished_at': now_kst().isoformat(),
                    })
            except Exception as e:
                write_queue(code, {
                    'status': 'failed',
                    'error': f'worker exception: {e}',
                    'finished_at': now_kst().isoformat(),
                })

    t = threading.Thread(target=_worker, daemon=True, name=f'stock-analysis-{code}')
    t.start()
    return {'status': 'queued', 'queue': read_queue(code)}


# ============================================================================
# 종목 상세 페이지 — /stock/<code> 응답 HTML 전체
# ============================================================================

_PAGE_CSS = """
*{box-sizing:border-box}
body{background:#0f1419;color:#cfd5df;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;margin:0;padding:0;font-size:14px;line-height:1.6}
.page-wrap{width:100%;max-width:1400px;margin:0 auto;padding:14px 16px 60px}
@media(max-width:640px){.page-wrap{padding:10px 12px 40px}}
.page-header{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;padding:6px 0 14px;border-bottom:1px solid #2a2f3a;margin-bottom:18px}
.page-header h1{font-size:22px;margin:0;color:#fff}
.page-header .code{color:#7a8493;font-weight:normal;font-size:16px;margin-left:6px}
.page-header .nav-row{display:flex;gap:8px;flex-wrap:wrap}
.page-header a.back{color:#7a8493;text-decoration:none;padding:6px 12px;border:1px solid #2a2f3a;border-radius:6px;font-size:13px;white-space:nowrap}
.page-header a.back:hover{color:#fff;border-color:#3f4654}
.page-header a.back.primary{color:#fbbf24;border-color:rgba(251,191,36,0.35);background:rgba(251,191,36,0.06)}
.page-header a.back.primary:hover{background:rgba(251,191,36,0.12);border-color:#fbbf24}
.generate-form{margin:0 0 18px}
.generate-btn{display:block;width:100%;max-width:520px;margin:0 auto;padding:14px 18px;background:#22c55e;color:#fff;border:0;border-radius:8px;cursor:pointer;font-size:15px;font-weight:700;letter-spacing:0.02em}
.generate-btn:hover{background:#16a34a}
.generate-btn[disabled]{background:#374151;cursor:not-allowed}
.queue-banner{margin:0 0 16px;padding:12px 16px;border-radius:8px;font-size:13px}
.queue-running{background:#1e3a8a;border-left:4px solid #60a5fa}
.queue-failed{background:#7f1d1d;border-left:4px solid #ef4444}
.error-banner{margin:0 0 16px;padding:12px 16px;border-radius:8px;background:#7f1d1d;border-left:4px solid #ef4444;color:#fee;font-size:13px;line-height:1.5}
.section-title{font-size:13px;color:#7a8493;text-transform:uppercase;letter-spacing:0.05em;margin:18px 0 12px;font-weight:600}
.report-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
.report-card{display:block;padding:16px 18px;border-radius:10px;color:#cfd5df;text-decoration:none;background:#161b22;border:1px solid #1f2937;transition:transform 0.08s,border-color 0.12s}
.report-card:hover{background:#1f2937;border-color:#3f4654;transform:translateY(-1px)}
.report-card .ts{font-size:12px;color:#7a8493;display:block;margin-bottom:6px}
.report-card .oneline{font-size:14px;font-weight:500;margin-bottom:10px;color:#fff;line-height:1.4;min-height:38px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.report-card .pct{display:inline-block;font-size:12px;padding:3px 10px;border-radius:12px;color:#fff;font-weight:600}
.empty-state{padding:60px 20px;text-align:center;color:#7a8493;background:#161b22;border-radius:10px}
.empty-state h3{margin:0 0 8px;color:#cfd5df}
.rpt-header h2{margin:0;font-size:22px;color:#fff}
.rpt-code{color:#7a8493;font-size:16px;font-weight:normal}
.rpt-ts{color:#7a8493;margin:4px 0 0;font-size:13px}
.rpt-oneline{background:#1f2937;padding:14px 18px;border-radius:8px;margin:18px 0;font-size:16px;font-weight:600;border-left:4px solid #fbbf24}
.rpt-section{margin:14px 0;padding:16px;background:#161b22;border-radius:8px}
.rpt-section h3{margin:0 0 10px;font-size:16px;color:#fff;border-bottom:1px solid #2a2f3a;padding-bottom:8px}
.rpt-cap{margin:0 0 12px;color:#9aa4b2;font-size:12px;line-height:1.6;background:#0f1419;padding:9px 12px;border-radius:6px;border-left:3px solid #3f4654}
.rpt-cap b{color:#cfd5df;font-weight:600}
/* 보조 섹션 2단(넓으면 다단) 그리드 — 한 화면에 모이게 */
.rpt-cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:14px;align-items:start;margin:14px 0}
.rpt-cols>.rpt-section{margin:0;height:100%}
.kvtable{border-collapse:collapse;width:100%;font-size:12px}
.kvtable th{text-align:left;padding:6px 9px;color:#7a8493;font-weight:500;width:160px;border-bottom:1px solid #1f2937;font-size:12px}
.kvtable td{padding:6px 9px;border-bottom:1px solid #1f2937}
.tbl-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
.tbl-scroll>table{min-width:100%;width:max-content}
.flowtable{border-collapse:collapse;width:100%;font-size:12px}
.flowtable th{padding:5px 9px;color:#7a8493;border-bottom:1px solid #2a2f3a;text-align:left;white-space:nowrap}
.flowtable td{padding:5px 9px;border-bottom:1px solid #1a1f28;white-space:nowrap}
.flowsum{margin:10px 0 0;padding:10px;background:#0f1419;border-radius:6px;font-size:13px;line-height:1.8}
.rpt-llm-block{margin:16px 0;padding:14px;background:#0f1419;border-radius:6px}
.rpt-llm-block h4{margin:0 0 10px;color:#fbbf24;font-size:14px}
.rpt-llm-block p{margin:8px 0;text-align:justify}
.rpt-em{color:#ff6b6b;font-weight:600}
.rpt-verdict{background:linear-gradient(135deg,#1f2937 0%,#161b22 100%)}
.verdict-line{font-size:16px;font-weight:600;margin:10px 0 6px;color:#fff}
.rpt-disclaimer{color:#7a8493;font-size:12px;margin:6px 0 0}
.rpt-content{max-width:1100px;margin:0 auto}
.chart-tabs{display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap}
.chart-tabs button{padding:6px 14px;background:#1f2937;color:#7a8493;border:1px solid #2a2f3a;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;font-family:inherit}
.chart-tabs button:hover{color:#cfd5df;border-color:#3f4654}
.chart-tabs button.active{background:rgba(251,191,36,0.12);color:#fbbf24;border-color:#fbbf24}
.chart-panel{display:none}
.chart-panel.active{display:block}
.chart-cap{margin:8px 0 4px;padding:8px 10px 0;font-size:9px;line-height:1.5;color:#7a8493;border-top:1px solid #1d222b}
.chart-cap b{color:#9aa4b2;font-weight:600}
.kvtable th{vertical-align:top}
.info-label{cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;border-bottom:1px dotted #5b6473;display:inline-flex;align-items:center;gap:4px;color:#7a8493;padding:1px 2px}
.info-label::after{content:'ⓘ';font-size:11px;color:#5b6473;font-weight:normal}
.info-label:active,.info-label.holding{color:#fbbf24;border-bottom-color:#fbbf24}
.info-label:active::after,.info-label.holding::after{color:#fbbf24}
.info-popup{position:fixed;left:0;top:0;max-width:280px;padding:10px 14px;background:#1f2937;color:#fff;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 28px rgba(0,0,0,0.55);border-left:3px solid #fbbf24;pointer-events:none;z-index:1500;opacity:0;visibility:hidden;transition:opacity 0.12s,transform 0.12s;transform:translateY(4px);font-weight:normal}
.info-popup.show{opacity:1;visibility:visible;transform:translateY(0)}
.info-popup::after{content:'';position:absolute;left:var(--arrow-x,50%);transform:translateX(-50%);border:7px solid transparent}
.info-popup.above::after{bottom:-7px;border-top-color:#1f2937;border-bottom-width:0}
.info-popup.below::after{top:-7px;border-bottom-color:#1f2937;border-top-width:0}
.peers-manage{margin:0 0 20px;padding:16px;background:#161b22;border-radius:10px;border:1px solid #1f2937}
.peers-help{margin:0 0 12px;color:#7a8493;font-size:12px;line-height:1.5}
.peers-empty,.peers-full{margin:0 0 10px;color:#7a8493;font-size:13px}
.peer-chips{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px}
.peer-chip{display:inline-flex;align-items:center;gap:8px;padding:6px 6px 6px 12px;background:#1f2937;border:1px solid #2a2f3a;border-radius:20px;font-size:13px}
.peer-chip a{color:#fbbf24;text-decoration:none;font-weight:600}
.peer-chip a:hover{text-decoration:underline}
.peer-code{color:#7a8493;font-size:11px;font-variant-numeric:tabular-nums}
.peer-del{background:transparent;border:0;color:#7a8493;cursor:pointer;font-size:18px;line-height:1;padding:2px 8px;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center}
.peer-del:hover{background:rgba(239,68,68,0.18);color:#ef4444}
.peer-add{display:flex;gap:8px}
.peer-add input{flex:1;padding:9px 12px;background:#0f1419;color:#cfd5df;border:1px solid #2a2f3a;border-radius:6px;font-size:14px;font-family:inherit;min-width:0}
.peer-add input:focus{outline:0;border-color:#fbbf24}
.peer-add button{padding:9px 16px;background:rgba(251,191,36,0.12);color:#fbbf24;border:1px solid rgba(251,191,36,0.35);border-radius:6px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap}
.peer-add button:hover{background:rgba(251,191,36,0.2)}
.peertable{border-collapse:collapse;width:100%;font-size:13px}
.peertable th{padding:6px 10px;color:#7a8493;border-bottom:1px solid #2a2f3a;text-align:left;font-weight:600}
.peertable th:not(:first-child){text-align:right}
.peertable td{padding:7px 10px;border-bottom:1px solid #1a1f28}
.peers-note{margin:10px 0 0;color:#7a8493;font-size:11px}
.basicpeertable{border-collapse:collapse;width:100%;font-size:12px;table-layout:fixed}
.basicpeertable th.lbl{text-align:left;padding:6px 9px;color:#7a8493;font-weight:500;width:130px;border-bottom:1px solid #1f2937;font-size:12px;vertical-align:top}
.basicpeertable thead th{padding:8px 10px;color:#cfd5df;border-bottom:1px solid #2a2f3a;font-weight:600;text-align:left;background:#0f1419}
.basicpeertable thead th:nth-child(2){color:#fbbf24}
.basicpeertable td{padding:7px 10px;border-bottom:1px solid #1f2937;vertical-align:top;word-break:break-word}
.bottom-nav{position:fixed;left:0;right:0;bottom:0;background:rgba(15,20,25,0.94);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-top:1px solid #2a2f3a;padding:10px 14px;display:flex;justify-content:center;gap:10px;z-index:100}
.bottom-nav a{flex:1;max-width:260px;text-align:center;color:#7a8493;text-decoration:none;padding:11px 14px;border:1px solid #2a2f3a;border-radius:10px;font-size:14px;font-weight:600;white-space:nowrap;background:#161b22}
.bottom-nav a:hover{color:#fff;border-color:#3f4654}
.bottom-nav a.primary{color:#fbbf24;border-color:rgba(251,191,36,0.35);background:rgba(251,191,36,0.08)}
.bottom-nav a.primary:hover{background:rgba(251,191,36,0.16);border-color:#fbbf24}
body.has-bottom-nav .page-wrap{padding-bottom:96px}
@supports(padding:env(safe-area-inset-bottom)){.bottom-nav{padding-bottom:calc(10px + env(safe-area-inset-bottom))}body.has-bottom-nav .page-wrap{padding-bottom:calc(96px + env(safe-area-inset-bottom))}}
"""

_CHART_TOGGLE_JS = """
document.querySelectorAll('.chart-tabs').forEach(function(tabs){
  tabs.addEventListener('click', function(e){
    var btn = e.target.closest('button[data-range]');
    if (!btn) return;
    var range = btn.dataset.range;
    var scope = tabs.parentElement;
    if (!scope) return;
    scope.querySelectorAll('.chart-tabs button').forEach(function(b){
      b.classList.toggle('active', b === btn);
    });
    scope.querySelectorAll('.chart-panel').forEach(function(p){
      p.classList.toggle('active', p.dataset.range === range);
    });
  });
});
"""

# 누름·뗌(press-and-hold) 팝업 — 라벨 위 또는 아래에 떠서 손가락 가리지 않게.
# 떼면 사라짐. 모바일 long-press 콜아웃·텍스트선택은 user-select:none·preventDefault로 차단.
_INFO_POPUP_JS = """
(function(){
  var popup = document.createElement('div');
  popup.className = 'info-popup';
  document.body.appendChild(popup);
  var current = null;
  function position(el){
    var rect = el.getBoundingClientRect();
    var pop = popup.getBoundingClientRect();
    var vw = window.innerWidth, vh = window.innerHeight;
    var left = rect.left + rect.width/2 - pop.width/2;
    var marg = 8;
    if (left < marg) left = marg;
    if (left + pop.width > vw - marg) left = vw - marg - pop.width;
    var labelCenter = rect.left + rect.width/2;
    var arrowX = labelCenter - left;
    if (arrowX < 14) arrowX = 14;
    if (arrowX > pop.width - 14) arrowX = pop.width - 14;
    popup.style.setProperty('--arrow-x', arrowX + 'px');
    var top = rect.top - pop.height - 14;
    if (top < marg) {
      top = rect.bottom + 14;
      popup.classList.remove('above'); popup.classList.add('below');
    } else {
      popup.classList.remove('below'); popup.classList.add('above');
    }
    if (top + pop.height > vh - marg) top = vh - marg - pop.height;
    popup.style.left = left + 'px';
    popup.style.top = top + 'px';
  }
  function show(el){
    if (current === el) return;
    current = el;
    el.classList.add('holding');
    popup.textContent = el.dataset.info || '';
    // 측정 위해 일단 보이게
    popup.classList.add('show');
    // 다음 프레임에 위치 (텍스트 후 크기 확정)
    requestAnimationFrame(function(){ position(el); });
  }
  function hide(){
    if (!current) return;
    current.classList.remove('holding');
    current = null;
    popup.classList.remove('show');
  }
  document.addEventListener('pointerdown', function(e){
    var el = e.target.closest('.info-label');
    if (!el) return;
    e.preventDefault();
    try { el.setPointerCapture(e.pointerId); } catch(_) {}
    show(el);
    var release = function(){
      hide();
      window.removeEventListener('pointerup', release);
      window.removeEventListener('pointercancel', release);
      try { el.releasePointerCapture(e.pointerId); } catch(_) {}
    };
    window.addEventListener('pointerup', release);
    window.addEventListener('pointercancel', release);
  }, {passive: false});
  // 스크롤 중 떼지 못한 케이스: 스크롤 시작하면 숨김
  window.addEventListener('scroll', hide, {passive: true});
})();
"""


def _running_meta_refresh(queue_state: dict) -> str:
    """running 상태일 때 페이지 자동 새로고침 meta tag."""
    if queue_state.get('status') == 'running':
        return '<meta http-equiv="refresh" content="6">'
    return ''


def _resolve_stock_name(code: str, reports: list[dict]) -> str:
    """종목명 — 보고서 메타에서 우선, 없으면 키움 단발 호출."""
    if reports:
        try:
            body = read_report(code, reports[0]['filename']) or ''
            import re
            m = re.search(r'<h2>[^<]*?([\w가-힣\s]+)<', body)
            if m:
                return m.group(1).strip().lstrip('📑').strip()
        except Exception:
            pass
    try:
        q = kc.get_stock_quote(code, exchange='KRX')
        return q.get('name') or ''
    except Exception:
        return ''


def _queue_banner_html(queue_state: dict) -> str:
    """큐 상태 배너. running·failed일 때만 출력."""
    status = queue_state.get('status')
    if status == 'running':
        started = queue_state.get('started_at', '')
        return (
            '<div class="queue-banner queue-running">'
            f'⏳ 새 보고서 생성 중입니다... (시작: {_esc(started[11:19] if started else "")}, '
            '약 30~60초 소요. 6초마다 자동 새로고침)'
            '</div>'
        )
    if status == 'failed':
        err = queue_state.get('error', '')
        return (
            '<div class="queue-banner queue-failed">'
            f'❌ 직전 생성 실패: {_esc(err[:200])}'
            '</div>'
        )
    return ''


def render_stock_page(code: str, name: str = '', selected_filename: str | None = None,
                      holding: dict | None = None, error_msg: str | None = None) -> str:
    """종목 상세 페이지 라우터.

    - selected_filename 있음 → 보고서 본문 화면
    - 없음 → 보고서 선택 화면 (카드 그리드)
    - error_msg: POST 후 redirect로 전달된 에러 메시지 (배너로 표시)
    """
    clean_code = ''.join(ch for ch in str(code) if ch.isalnum())
    reports = list_reports(clean_code)
    queue_state = read_queue(clean_code)

    if not name:
        name = _resolve_stock_name(clean_code, reports)

    if selected_filename:
        return _render_report_view(clean_code, name, selected_filename, reports, queue_state)
    return _render_report_list(clean_code, name, reports, queue_state, error_msg=error_msg)


def _render_report_list(code: str, name: str, reports: list[dict], queue_state: dict,
                         error_msg: str | None = None) -> str:
    """1단계 — 보고서 선택 화면."""
    refresh_meta = _running_meta_refresh(queue_state)
    is_running = queue_state.get('status') == 'running'

    # 카드 그리드
    cards: list[str] = []
    for r in reports:
        pct_html = ''
        if r['pct'] is not None:
            pct_html = (
                f'<span class="pct" style="background:{_verdict_color(r["pct"])}">'
                f'{r["pct"]}% · {_esc(r["label"] or "")}</span>'
            )
        ts_disp = _fmt_card_ts(r['ts'])
        cards.append(
            f'<a class="report-card" href="/stock/{_esc(code)}?r={_esc(r["filename"])}">'
            f'<span class="ts">{_esc(ts_disp)}</span>'
            f'<span class="oneline">{_esc(r["oneline"] or "(요약 없음)")}</span>'
            f'{pct_html}'
            '</a>'
        )

    if cards:
        list_html = (
            f'<h2 class="section-title">📚 과거 보고서 ({len(reports)}개)</h2>'
            f'<div class="report-grid">{"".join(cards)}</div>'
        )
    else:
        list_html = (
            '<div class="empty-state">'
            '<h3>아직 보고서가 없어요</h3>'
            '<p>위의 <b>📝 새 보고서 만들기</b> 버튼을 눌러 첫 보고서를 생성하세요. 30~60초 정도 걸려요.</p>'
            '</div>'
        )

    generate_form = (
        f'<form class="generate-form" method="POST" action="/stock/{_esc(code)}/generate">'
        + (
            '<button type="submit" class="generate-btn" disabled>🔄 생성 중...</button>'
            if is_running
            else '<button type="submit" class="generate-btn">📝 새 보고서 만들기</button>'
        )
        + '</form>'
    )

    # ---- 비교업체 관리 ----
    peers_now = read_peers(code)
    peer_chips: list[str] = []
    for p in peers_now:
        peer_chips.append(
            '<span class="peer-chip">'
            f'<a href="/stock/{_esc(p["code"])}">{_esc(p["name"])}</a>'
            f'<span class="peer-code">{_esc(p["code"])}</span>'
            f'<form method="POST" action="/stock/{_esc(code)}/peers/delete" style="display:inline">'
            f'<input type="hidden" name="peer_code" value="{_esc(p["code"])}">'
            '<button type="submit" title="삭제" class="peer-del">×</button>'
            '</form>'
            '</span>'
        )
    can_add = len(peers_now) < PEERS_MAX
    peers_section = (
        '<section class="peers-manage">'
        '<h2 class="section-title">🔗 비교업체 ({0}/{1})</h2>'.format(len(peers_now), PEERS_MAX)
        + '<p class="peers-help">여기에 추가한 종목은 새 보고서를 만들 때마다 항상 비교 표·LLM 코멘트에 포함됩니다.</p>'
        + (f'<div class="peer-chips">{"".join(peer_chips)}</div>' if peer_chips else '<p class="peers-empty">아직 등록된 비교업체가 없어요.</p>')
        + (
            f'<form class="peer-add" method="POST" action="/stock/{_esc(code)}/peers/add">'
            '<input type="text" name="stock" placeholder="종목명 또는 6자리 코드 (예: SK하이닉스, 000660)" autocomplete="off" required>'
            '<button type="submit">+ 추가</button>'
            '</form>'
            if can_add
            else f'<p class="peers-full">최대 {PEERS_MAX}개까지 등록할 수 있어요. 먼저 하나 빼주세요.</p>'
        )
        + '</section>'
    )

    return (
        '<!doctype html><html lang="ko"><head>'
        '<meta charset="utf-8">'
        f'<title>{_esc(name or code)} 분석 — Behive</title>'
        '<meta name="viewport" content="width=device-width,initial-scale=1">'
        f'{refresh_meta}'
        f'<style>{_PAGE_CSS}</style>'
        '</head><body>'
        '<div class="page-wrap">'
        '<div class="page-header">'
        f'<h1>📑 {_esc(name or "(이름 미상)")} <span class="code">({_esc(code)})</span></h1>'
        '<div class="nav-row">'
        '<a href="/" class="back">← 워치리스트로</a>'
        '</div></div>'
        + (f'<div class="error-banner">⚠️ {_esc(error_msg)}</div>' if error_msg else '')
        + _queue_banner_html(queue_state)
        + generate_form
        + peers_section
        + list_html
        + '</div></body></html>'
    )


def _render_report_view(code: str, name: str, selected_filename: str,
                        reports: list[dict], queue_state: dict) -> str:
    """2단계 — 선택된 보고서 본문 화면."""
    target = next((r for r in reports if r['filename'] == selected_filename), None)
    if not target:
        # 잘못된 파일명 → 목록으로 fallback
        return _render_report_list(code, name, reports, queue_state)

    body = read_report(code, target['filename']) or '(보고서 파일 읽기 실패)'
    refresh_meta = _running_meta_refresh(queue_state)

    return (
        '<!doctype html><html lang="ko"><head>'
        '<meta charset="utf-8">'
        f'<title>{_esc(name or code)} 보고서 — Behive</title>'
        '<meta name="viewport" content="width=device-width,initial-scale=1">'
        f'{refresh_meta}'
        f'<style>{_PAGE_CSS}</style>'
        '</head><body class="has-bottom-nav">'
        '<div class="page-wrap">'
        '<div class="page-header">'
        f'<h1>📑 {_esc(name or "(이름 미상)")} <span class="code">({_esc(code)})</span></h1>'
        '</div>'
        + _queue_banner_html(queue_state)
        + f'<div class="rpt-content">{body}</div>'
        + '</div>'
        + '<nav class="bottom-nav">'
        + f'<a href="/stock/{_esc(code)}" class="primary">← 보고서 목록</a>'
        + '<a href="/">← 워치리스트로</a>'
        + '</nav>'
        + f'<script>{_CHART_TOGGLE_JS}{_INFO_POPUP_JS}</script>'
        + '</body></html>'
    )
