#!/usr/bin/env python3
"""주식 포트폴리오 일일 리포트 — 키움 REST 기반 (조회 전용).

⚠️ 이 스크립트는 키움 REST(`kt00001` 예수금 + `kt00018` 보유종목)를 ground truth로 사용한다.
portfolio.json은 참고용이며, 리포트 숫자는 항상 실시간 API 응답에서 계산된다.

기본 동작: owner 그룹(본인 / 가희 …)별 블록 표시. 그룹 내 같은 종목은 가중평단으로 합침.
owner는 라벨 prefix로 결정(`가희_일반` → 가희, `일반` → 본인).
`--by-account` 옵션: owner 블록 내부에서 계좌별 상세까지 분리 (설계 메모: kiwoom_design.md "혼합 표시").
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from zoneinfo import ZoneInfo

WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
sys.path.insert(0, str(WORKSPACE / 'scripts'))
from kiwoom_client import (  # noqa: E402
    get_balance,
    get_cash_flow_all,
    get_positions_all,
    get_stock_quote,
    get_trade_journal_all,
    get_watchlist_quotes,
    list_accounts,
)

KST = ZoneInfo('Asia/Seoul')
STATE_DIR = WORKSPACE / 'state'
SNAPSHOT_FILE = STATE_DIR / 'portfolio_daily_snapshot.json'
RECIPIENT = 'mini.snowoyh@gmail.com'
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
TELEGRAM_ACCOUNT = 'stock'  # 레이 봇

HTML_STYLES = {
    'wrap': 'font-family:-apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",sans-serif;font-size:14px;color:#222;max-width:680px;line-height:1.55;',
    'greet': 'color:#444;margin-bottom:18px;',
    'summary_block': 'margin-bottom:24px;border:1px solid #cdd5ec;border-radius:8px;padding:16px 18px;background:#f0f4ff;',
    'owner_block': 'margin-bottom:24px;border:1px solid #e5e5e5;border-radius:8px;padding:16px 18px;background:#fafafa;',
    'owner_title': 'font-size:16px;font-weight:700;color:#111;margin-bottom:2px;',
    'owner_labels': 'color:#888;font-size:12px;margin-bottom:12px;',
    'kpi_table': 'width:100%;border-collapse:collapse;margin-bottom:12px;',
    'kpi_label': 'color:#666;padding:3px 0;width:160px;font-size:13px;vertical-align:top;',
    'kpi_value': 'color:#111;padding:3px 0;font-size:13px;font-weight:500;',
    'cash_block': 'background:#fff;border:1px solid #eee;border-radius:6px;padding:8px 10px;margin-bottom:12px;',
    'cash_line': 'color:#555;font-size:12px;margin:2px 0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;',
    'section': 'font-size:13px;font-weight:600;color:#333;margin:14px 0 6px;border-left:3px solid #333;padding-left:8px;',
    'card': 'background:#fff;border:1px solid #eee;border-radius:6px;padding:10px 12px;margin-bottom:8px;',
    'card_traded': 'background:#fffbe6;border:1px solid #ffe7a0;border-radius:6px;padding:10px 12px;margin-bottom:8px;',
    'card_title': 'font-weight:600;color:#111;font-size:14px;margin-bottom:6px;',
    'card_meta': 'color:#888;font-size:12px;font-weight:400;',
    'card_row': 'color:#444;font-size:13px;margin:2px 0;',
    'pos_table': 'width:100%;border-collapse:collapse;margin-top:6px;',
    'pos_label': 'color:#888;padding:4px 12px 4px 0;width:110px;font-size:13px;vertical-align:top;white-space:nowrap;',
    'pos_value': 'color:#222;padding:4px 0;font-size:13px;font-weight:500;',
    'pos': 'color:#d24f4f;font-weight:600;',  # 한국 시세 관례: 상승=빨강
    'neg': 'color:#1565c0;font-weight:600;',  # 하락=파랑
    'flat': 'color:#666;',
    'star': 'color:#d97706;font-weight:600;margin-left:6px;',
    'subhead': 'margin:10px 0 6px;color:#333;font-size:13px;',
}


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


def _color(value) -> str:
    if value is None:
        return HTML_STYLES['flat']
    if value > 0:
        return HTML_STYLES['pos']
    if value < 0:
        return HTML_STYLES['neg']
    return HTML_STYLES['flat']


def _signed(value, suffix: str = '원') -> str:
    return f'<span style="{_color(value)}">{value:+,}{suffix}</span>'


def _signed_pct(value) -> str:
    return f'<span style="{_color(value)}">{value:+.2f}%</span>'


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


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


def save_json(path: Path, data):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2))


def apply_ka10001_corrections(rows: list[dict]) -> int:
    """rows 의 day_change/day_change_pct/pred_close 를 ka10001 정확값으로 덮어씀.

    kt00018 의 `pred_close_pric` 가 장 마감 후 `cur_prc` 와 같은 값으로 들어와
    raw day_change 가 0 이 되는 문제 보정. 종목당 ka10001 1회 호출 (1초 sleep + 429 백오프).

    거래소: 'AL' (KRX+NXT 통합) — NXT 거래시간엔 NXT 우선, 미상장·시간 종료 시 KRX 자동 fallback.
    kt00018 NXT 호출 정책과 일치시켜 NXT 가격 vs KRX 전일종가 섞이는 문제 차단.

    호출 시점은 prev_snap 이 없을 때(첫 가동·어제 발송 누락 등 비정상 케이스) 만.
    prev_snap 이 있으면 `rebase_to_nxt_close` 가 NXT-on-NXT 기준 더 정확한 baseline 으로
    덮어쓰므로 이 보정은 불필요·중복이다.

    Returns: 보정 성공 종목 개수.
    """
    quotes: dict[str, dict] = {}
    codes = sorted({r['code'] for r in rows if r.get('code')})
    for i, code in enumerate(codes):
        if i:
            time.sleep(1.0)  # 키움 ka10001 rate limit (429) 안전 마진
        try:
            quotes[code] = get_stock_quote(code, exchange='AL')
        except Exception as e:
            print(f'[quote-fail] {code}: {e}', file=sys.stderr)
            if '429' in str(e) or '1700' in str(e):
                time.sleep(5.0)
                try:
                    quotes[code] = get_stock_quote(code, exchange='AL')
                    print(f'[quote-retry-ok] {code}', file=sys.stderr)
                except Exception as e2:
                    print(f'[quote-retry-fail] {code}: {e2}', file=sys.stderr)
    applied = 0
    for r in rows:
        q = quotes.get(r['code'])
        if not q:
            continue
        r['day_change'] = q['change']
        r['day_change_pct'] = q['change_pct']
        r['pred_close'] = q['price'] - q['change']
        applied += 1
    return applied


def collect_rows() -> tuple[list[dict], dict, dict]:
    """계좌별 포지션 → 평탄화된 row 리스트 + 계좌 잔고 + 계좌별 당일매매일지.

    NXT 우선 — 한국거래소 마감(15:30) 후에도 NXT는 20:00까지 운영되어 시간외 가격 변동을 반영.
    브리핑 발송 시각 20:10 기준 NXT 종가가 정확. NXT 미상장 종목은 키움이 KRX로 자동 fallback.
    감시페이지(behive_web)와 동일 정책으로 두 화면의 평가금액·손익이 일치한다.

    ka10001 day_change 보정은 호출자(build_report / behive_web)가 prev_snap 가용 여부에 따라
    `apply_ka10001_corrections(rows)` 를 lazy 호출 (A-4 정책).
    """
    positions_by_acc = get_positions_all(exchange='NXT')
    rows: list[dict] = []
    for label, positions in positions_by_acc.items():
        for p in positions:
            rows.append({
                'account': label,
                'stock': p['name'],
                'code': p['code'],
                'qty': p['qty'],
                'avg': p['avg_price'],
                'price': p['cur_price'],
                'pred_close': p.get('pred_close_pric', 0),
                'day_change': p.get('day_change', 0),
                'day_change_pct': p.get('day_change_pct', 0.0),
                'buy_amount': p['pur_amt'],
                'eval_value': p['evlt_amt'],
                'profit': p['evltv_prft'],
                'profit_rate': p['prft_rt'],
                'tdy_buyq': p['tdy_buyq'],
                'tdy_sellq': p['tdy_sellq'],
            })
    balances = {a['label']: get_balance(a['label']) for a in list_accounts()}
    journal_by_label = get_trade_journal_all()
    return rows, balances, journal_by_label


def aggregate_journal(entries_by_label: dict[str, list[dict]]) -> dict[str, dict]:
    """같은 owner 내 여러 계좌의 동일 종목 거래를 합산.

    {label: [trade,...]} → {code: {name, accounts, buy/sell qty·amt·avg(가중), pl_amt, cmsn_tax, prft_rt}}
    """
    merged: dict[str, dict] = {}
    for label, trades in entries_by_label.items():
        for t in trades:
            key = t['code']
            if key not in merged:
                sell_cost = (t['sell_amt'] - t['cmsn_tax'] - t['pl_amt']) if t['sell_qty'] else 0
                merged[key] = {
                    'code': t['code'],
                    'name': t['name'],
                    'accounts': [label],
                    'buy_qty': t['buy_qty'],
                    'buy_amt': t['buy_amt'],
                    'sell_qty': t['sell_qty'],
                    'sell_amt': t['sell_amt'],
                    'pl_amt': t['pl_amt'],
                    'cmsn_tax': t['cmsn_tax'],
                    'sell_cost': sell_cost if sell_cost > 0 else 0,
                }
            else:
                m = merged[key]
                if label not in m['accounts']:
                    m['accounts'].append(label)
                m['buy_qty'] += t['buy_qty']
                m['buy_amt'] += t['buy_amt']
                m['sell_qty'] += t['sell_qty']
                m['sell_amt'] += t['sell_amt']
                m['pl_amt'] += t['pl_amt']
                m['cmsn_tax'] += t['cmsn_tax']
                sell_cost = (t['sell_amt'] - t['cmsn_tax'] - t['pl_amt']) if t['sell_qty'] else 0
                if sell_cost > 0:
                    m['sell_cost'] += sell_cost
    for m in merged.values():
        m['buy_avg'] = (m['buy_amt'] // m['buy_qty']) if m['buy_qty'] else 0
        m['sell_avg'] = (m['sell_amt'] // m['sell_qty']) if m['sell_qty'] else 0
        # 실현 수익률 = 실현손익 / 매도 원가. 오늘 매수 없이 보유분만 매도해도 0%로 떨어지지 않게 한다.
        m['prft_rt'] = (m['pl_amt'] / m['sell_cost'] * 100) if m.get('sell_cost') else 0.0
    return merged


def consolidate(rows: list[dict]) -> list[dict]:
    """같은 종목을 여러 계좌에 보유한 경우 가중평단으로 합침."""
    merged: dict[str, dict] = {}
    for r in rows:
        key = r['code']
        if key not in merged:
            merged[key] = {
                'stock': r['stock'],
                'code': r['code'],
                'accounts': [r['account']],
                'qty': r['qty'],
                'price': r['price'],
                'pred_close': r.get('pred_close', 0),
                'day_change': r.get('day_change', 0),
                'day_change_pct': r.get('day_change_pct', 0.0),
                'buy_amount': r['buy_amount'],
                'eval_value': r['eval_value'],
                'profit': r['profit'],
                'tdy_buyq': r['tdy_buyq'],
                'tdy_sellq': r['tdy_sellq'],
            }
        else:
            m = merged[key]
            m['accounts'].append(r['account'])
            m['qty'] += r['qty']
            m['buy_amount'] += r['buy_amount']
            m['eval_value'] += r['eval_value']
            m['profit'] += r['profit']
            m['tdy_buyq'] += r['tdy_buyq']
            m['tdy_sellq'] += r['tdy_sellq']
    for m in merged.values():
        m['avg'] = m['buy_amount'] // m['qty'] if m['qty'] else 0
        m['profit_rate'] = (m['profit'] / m['buy_amount'] * 100) if m['buy_amount'] else 0.0
        m['traded_today'] = (m['tdy_buyq'] + m['tdy_sellq']) > 0
    return list(merged.values())


def format_row(row: dict, total_value: int, account_tag: str = '') -> list[str]:
    weight = (row['eval_value'] / total_value * 100) if total_value else 0.0
    title = f"[{row['stock']}"
    if account_tag:
        title += f" | {account_tag}"
    if 'accounts' in row and len(row['accounts']) > 1:
        title += f" | {'+'.join(row['accounts'])}"
    title += f" | 비중 {weight:.2f}%]"
    if row.get('traded_today'):
        title += '  ★ 오늘 매매 발생'
    lines = [
        title,
        f"- 보유수량      : {row['qty']:,}주",
        f"- 현재가(평단)   : {row['price']:,}원 ({row['avg']:,}원)",
    ]
    if row.get('pred_close'):
        day_value_change = row['day_change'] * row['qty']
        lines.append(
            f"- 당일 등락     : {row['day_change']:+,}원 ({row['day_change_pct']:+.2f}%) · "
            f"평가금액 변동 {day_value_change:+,}원"
        )
    lines += [
        f"- 매입금액      : {row['buy_amount']:,}원",
        f"- 평가금액      : {row['eval_value']:,}원",
        f"- 평가손익      : {row['profit']:+,}원 ({row['profit_rate']:+.2f}%)",
    ]
    if row.get('tdy_buyq'):
        lines.append(f"- 오늘 매수     : {row['tdy_buyq']:,}주")
    if row.get('tdy_sellq'):
        lines.append(f"- 오늘 매도     : {row['tdy_sellq']:,}주")
    return lines


OWNER_LABELS = {'본인': '관리자님 자산', '가희': '가희님 자산'}


def derive_owner(label: str) -> str:
    """라벨 prefix로 owner 그룹 도출. 언더바 없는 라벨은 '본인'."""
    return label.split('_', 1)[0] if '_' in label else '본인'


def group_by_owner(labels: list[str]) -> dict[str, list[str]]:
    groups: dict[str, list[str]] = {}
    for label in labels:
        groups.setdefault(derive_owner(label), []).append(label)
    return groups


def _normalize_prev_snap(snap_for_day: dict) -> dict:
    """스냅샷을 {owner: {'holdings': {stock: {...}}, 'deposit': int|None}} 형태로 통일.

    - v1 (legacy flat): {stock: {qty, ...}} → {'본인': {'holdings': {...}, 'deposit': None}}
    - v2 (legacy owner): {owner: {stock: {qty, ...}}} → {owner: {'holdings': {...}, 'deposit': None}}
    - v3/v4 (current): {owner: {'holdings': {...}, 'deposit': int, ...}} → 그대로
    - v5: `_` prefix 키는 메타데이터 (예: `_watchlist_nxt`) — 변환 없이 그대로 통과.
    """
    if not snap_for_day:
        return {}
    owner_keys = [k for k in snap_for_day if not str(k).startswith('_')]
    if not owner_keys:
        return dict(snap_for_day)
    sample = snap_for_day[owner_keys[0]]
    # v1: flat — owner 데이터가 곧 holdings 자체 (qty 필드로 식별)
    if isinstance(sample, dict) and 'qty' in sample:
        return {'본인': {'holdings': {k: v for k, v in snap_for_day.items() if not str(k).startswith('_')}, 'deposit': None},
                **{k: v for k, v in snap_for_day.items() if str(k).startswith('_')}}
    # v3+: 메타 그대로 통과, owner 데이터는 normalize
    result: dict = {}
    for k, v in snap_for_day.items():
        if str(k).startswith('_'):
            result[k] = v
        elif isinstance(v, dict) and 'holdings' in v:
            result[k] = v
        else:
            # v2: owner-grouped without holdings key
            result[k] = {'holdings': v, 'deposit': None}
    return result


def snapshot_trade_journal(owner_journal: dict) -> list[dict]:
    """일별 스냅샷에 저장할 owner 단위 당일매매 요약.

    ka10170은 계좌별·종목별 일계 집계라 주문번호/체결시각까지는 없지만,
    월별 실현손익·회전율·수수료/세금·종목별 매매성과 통계에는 충분하다.
    """
    trades: list[dict] = []
    for j in sorted(owner_journal.values(), key=lambda x: (x.get('name', ''), x.get('code', ''))):
        trade = {
            'code': j.get('code', ''),
            'name': j.get('name', ''),
            'accounts': sorted(j.get('accounts', [])),
            'buy_qty': int(j.get('buy_qty', 0) or 0),
            'buy_avg': int(j.get('buy_avg', 0) or 0),
            'buy_amt': int(j.get('buy_amt', 0) or 0),
            'sell_qty': int(j.get('sell_qty', 0) or 0),
            'sell_avg': int(j.get('sell_avg', 0) or 0),
            'sell_amt': int(j.get('sell_amt', 0) or 0),
            'realized_pl': int(j.get('pl_amt', 0) or 0),
            'fees_tax': int(j.get('cmsn_tax', 0) or 0),
            'realized_profit_rate': float(j.get('prft_rt', 0.0) or 0.0),
        }
        if j.get('_pl_estimated'):
            trade['realized_pl_estimated'] = True
            trade['prev_avg'] = int(j.get('prev_avg', 0) or 0)
        trades.append(trade)
    return trades


def enrich_journal_realized_pl(owner_journal: dict, prev_holdings: dict | None) -> None:
    """ka10170 응답에서 pl_amt=0인 매도 항목의 추정 손익 보강 (안전판).

    `ottks_tp='2'`(당일매도 전체)로 호출하면 키움이 보유분 매도 손익까지 정확히 계산해 반환한다 (2026-05-07 명세 검증).
    이 함수는 그래도 어떤 이유로 pl_amt=0이 떨어지는 케이스를 위한 fallback —
    어제 stock.briefing 스냅샷의 avg_price를 매수 원가로 삼아 (sell_avg - prev_avg) × sell_qty 로 직접 계산.
    pl_amt가 0이 아니면 키움 값을 그대로 신뢰 (보강 skip).

    이력: 2026-05-07 이전엔 `ottks_tp='1'`(round-trip만)로 호출해 보유분 매도가 모두 pl_amt=0이 되었고
    이 함수가 메인 경로였음. ottks_tp 수정 후 fallback 으로 격하.
    """
    if not prev_holdings:
        return
    for j in owner_journal.values():
        if j.get('sell_qty', 0) == 0:
            continue
        if j.get('pl_amt', 0) != 0:
            continue  # 키움이 이미 계산해줬으면 그대로 신뢰
        prev = prev_holdings.get(j.get('name')) or {}
        prev_avg = prev.get('avg_price', 0)
        if prev_avg <= 0:
            continue
        sell_amt = j.get('sell_amt', 0)
        sell_qty = j.get('sell_qty', 0)
        cost = prev_avg * sell_qty
        j['pl_amt'] = sell_amt - cost
        j['prft_rt'] = (j['pl_amt'] / cost * 100) if cost else 0.0
        j['_pl_estimated'] = True  # 호출측에서 추정 표시 가능
        j['prev_avg'] = prev_avg  # 렌더러가 '매수 평단' 표시에 사용


def rebase_to_nxt_close(consolidated: list[dict], prev_holdings: dict | None) -> None:
    """consolidated 각 row의 day_change/day_change_pct/pred_close를 어제 NXT 종가 기준으로 재계산.

    kt00018이 NXT exchange로 호출돼도 `pred_close_pric` 필드는 KRX 전일종가로 채워서 반환한다.
    그래서 raw day_change = NXT 현재 - KRX 전일 → 두 거래소 간 갭이 섞인 큰 값이 표시된다.
    여기서 어제 stock.briefing 스냅샷에 저장된 NXT 종가(price 필드)를 baseline으로 덮어써,
    개별 표시와 합계 KPI(total_net - prev_net)가 동일 NXT-on-NXT 기준으로 일치하게 한다.

    - 어제 보유 → baseline = 스냅샷 price (NXT 5/6 마감가 등)
    - 오늘 신규 매수(스냅샷에 없는 종목) → baseline = avg_price (매수가)
    - phantom(당일 round-trip) → baseline 무관, day_change=0
    """
    if not prev_holdings:
        prev_holdings = {}
    for r in consolidated:
        if r.get('phantom') or r.get('qty', 0) <= 0:
            r['pred_close'] = 0
            r['day_change'] = 0
            r['day_change_pct'] = 0.0
            continue
        prev = prev_holdings.get(r['stock']) or {}
        prev_price = prev.get('price', 0)
        baseline = prev_price if prev_price > 0 else r.get('avg', 0)
        cur_price = r.get('price', 0)
        if baseline > 0 and cur_price > 0:
            r['pred_close'] = baseline
            r['day_change'] = cur_price - baseline
            r['day_change_pct'] = ((cur_price - baseline) / baseline) * 100
        else:
            r['pred_close'] = 0
            r['day_change'] = 0
            r['day_change_pct'] = 0.0


def _render_html_position_card(row: dict, total_value: int, account_tag: str = '') -> str:
    S = HTML_STYLES
    weight = (row['eval_value'] / total_value * 100) if total_value else 0.0
    style = S['card_traded'] if row.get('traded_today') else S['card']

    meta_bits = []
    if account_tag:
        meta_bits.append(html_escape(account_tag))
    if 'accounts' in row and len(row['accounts']) > 1:
        meta_bits.append(html_escape('+'.join(row['accounts'])))
    meta_bits.append(f'비중 {weight:.2f}%')
    meta_html = f' <span style="{S["card_meta"]}">({" · ".join(meta_bits)})</span>'
    star_html = f'<span style="{S["star"]}">★ 오늘 매매</span>' if row.get('traded_today') else ''

    rows_html: list[tuple[str, str]] = [
        ('보유수량', f'{row["qty"]:,}주'),
        ('현재가', f'{row["price"]:,}원'),
        ('평단가', f'{row["avg"]:,}원'),
    ]
    if row.get('pred_close'):
        day_value_change = row['day_change'] * row['qty']
        rows_html.append((
            '당일 등락',
            f'{_signed(row["day_change"])} ({_signed_pct(row["day_change_pct"])})'
        ))
        rows_html.append(('평가금액 변동', _signed(day_value_change)))
    rows_html.append(('매입금액', f'{row["buy_amount"]:,}원'))
    rows_html.append(('평가금액', f'{row["eval_value"]:,}원'))
    rows_html.append((
        '평가손익',
        f'{_signed(row["profit"])} ({_signed_pct(row["profit_rate"])})'
    ))
    if row.get('tdy_buyq'):
        rows_html.append(('오늘 매수', f'{row["tdy_buyq"]:,}주'))
    if row.get('tdy_sellq'):
        rows_html.append(('오늘 매도', f'{row["tdy_sellq"]:,}주'))

    table_rows = ''.join(
        f'<tr><td style="{S["pos_label"]}">{html_escape(label)}</td>'
        f'<td style="{S["pos_value"]}">{value}</td></tr>'
        for label, value in rows_html
    )

    return (
        f'<div style="{style}">'
        f'<div style="{S["card_title"]}">{html_escape(row["stock"])}{meta_html}{star_html}</div>'
        f'<table style="{S["pos_table"]}">{table_rows}</table>'
        f'</div>'
    )


def _render_html_owner(idx: int, owner: str, d: dict, balances: dict, prev_key, by_account: bool) -> str:
    S = HTML_STYLES
    title = OWNER_LABELS.get(owner, f'{owner} 자산')
    labels_disp = ' + '.join(d['labels'])
    parts = [
        f'<div style="{S["owner_block"]}">',
        f'<div style="{S["owner_title"]}">{idx}) {html_escape(title)}</div>',
        f'<div style="{S["owner_labels"]}">{html_escape(labels_disp)}</div>',
        f'<table style="{S["kpi_table"]}">',
    ]

    def kpi(label_str: str, value_html: str) -> str:
        return (
            f'<tr><td style="{S["kpi_label"]}">{html_escape(label_str)}</td>'
            f'<td style="{S["kpi_value"]}">{value_html}</td></tr>'
        )

    # behive_web _render_owner_kpi 와 동일한 순서·라벨로 통일.
    # '총'은 계좌 전체(현금 포함)일 때만: 총자산 = 평가금액 + 예수금.
    # 총자산 → 매입금액 → 평가금액 → 평가손익 → 예수금 → [당일 입출금] → 보유종목/당일매매 → 당일 평가손익
    held = [r for r in d['consolidated'] if not r.get('phantom')]
    parts.append(kpi('총자산', f'{d["total_net"]:,}원'))
    parts.append(kpi('매입금액', f'{d["total_cost"]:,}원'))
    parts.append(kpi('평가금액', f'{d["total_value"]:,}원'))
    parts.append(kpi(
        '평가손익',
        f'{_signed(d["total_profit"])} ({_signed_pct(d["total_profit_rate"])})'
    ))
    parts.append(kpi('예수금', f'{d["deposit"]:,}원'))
    # 당일 입출금은 예수금 바로 아래 (현금 잔액 ↔ 그날 현금 이동)
    cash_in = d.get('cash_in', 0)
    cash_out = d.get('cash_out', 0)
    if cash_in or cash_out:
        net = cash_in - cash_out
        nsign = '+' if net >= 0 else ''
        ncolor = '#0a7' if net > 0 else ('#d33' if net < 0 else '#666')
        # 한쪽만 있으면 순액과 같은 값이라 생략 (둘 다 있을 때만 쪼갠다)
        detail = ''
        if cash_in and cash_out:
            detail = (f' <span style="color:#888;font-size:13px;">'
                      f'· 입금 {cash_in:,}원 · 출금 {cash_out:,}원</span>')
        parts.append(kpi(
            '당일 입출금',
            f'<span style="color:{ncolor};">{nsign}{net:,}원</span>{detail}'
        ))
    parts.append(kpi('보유종목/당일매매', f'{len(held)}개 / {len(d["traded_today"])}개'))
    day_pl_total = d.get('day_pl_total')
    if day_pl_total is None:
        if prev_key:
            parts.append(kpi('당일 평가손익', '<span style="color:#888;">전날 스냅샷 없음</span>'))
    else:
        prev_net = d.get('prev_net') or 0
        pct_html = ''
        if prev_net:
            pct = (day_pl_total / prev_net) * 100
            pct_html = f' ({_signed_pct(pct)})'
        suffix = f' <span style="color:#888;font-size:13px;">· {prev_key} 대비</span>' if prev_key else ''
        parts.append(kpi('당일 평가손익', f'{_signed(day_pl_total)}{pct_html}{suffix}'))
    realized_pl = d.get('realized_pl_total', 0)
    realized_fees = d.get('realized_fees_total', 0)
    if d.get('traded_today') and (realized_pl or realized_fees):
        rsign = '+' if realized_pl >= 0 else ''
        rcolor = '#0a7' if realized_pl > 0 else ('#d33' if realized_pl < 0 else '#666')
        fee_part = f' <span style="color:#888;font-size:13px;">· 수수료·세금 {realized_fees:,}원</span>' if realized_fees else ''
        parts.append(kpi(
            '당일 실현손익',
            f'<span style="color:{rcolor};">{rsign}{realized_pl:,}원</span>{fee_part}'
        ))
    parts.append('</table>')

    cash_lines = []
    for label in d['labels']:
        bal = balances.get(label)
        if not bal:
            continue
        cash_lines.append(
            f'<div style="{S["cash_line"]}">'
            f'[{html_escape(label)}] 예수금 {bal["d2_entra"]:,}원'
            f'</div>'
        )
    if cash_lines:
        parts.append(f'<div style="{S["cash_block"]}">' + '\n'.join(cash_lines) + '</div>')

    if d['traded_today']:
        parts.append(f'<div style="{S["section"]}">당일 매매 종목</div>')
        for r in sorted(d['traded_today'], key=lambda x: (not x.get('phantom'), -x['profit'])):
            j = r.get('journal') or {}
            tag = ' <span style="' + S['star'] + '">[당일정산]</span>' if r.get('phantom') else ''
            rows_html: list[str] = []
            prev_avg = j.get('prev_avg', 0)
            if r.get('phantom'):
                # 당일정산: 현재가 → 매도 → 실현 → 매수 순
                if r.get('live_price'):
                    lp = r['live_price']
                    lpct = r.get('live_change_pct', 0.0)
                    rows_html.append(
                        f'<div style="{S["card_row"]}">현재가 {lp:,}원 ({_signed_pct(lpct)})</div>'
                    )
                if j.get('sell_qty'):
                    rows_html.append(
                        f'<div style="{S["card_row"]}">매도 {j["sell_avg"]:,}원 × {j["sell_qty"]:,}주 = {j["sell_amt"]:,}원</div>'
                    )
                    rows_html.append(
                        f'<div style="{S["card_row"]}">실현 {_signed(j["pl_amt"])} ({_signed_pct(j["prft_rt"])}) · '
                        f'수수료·세금 {j["cmsn_tax"]:,}원</div>'
                    )
                if prev_avg > 0 and not j.get('buy_qty'):
                    rows_html.append(
                        f'<div style="{S["card_row"]}">매수 평단 {prev_avg:,}원 (어제 잔고 기준)</div>'
                    )
                if j.get('buy_qty'):
                    rows_html.append(
                        f'<div style="{S["card_row"]}">매수 {j["buy_avg"]:,}원 × {j["buy_qty"]:,}주 = {j["buy_amt"]:,}원</div>'
                    )
            else:
                # 보유 종목 매매: 매수 → 매도 → 실현 → 잔고 평단 (기존 순서)
                if prev_avg > 0 and not j.get('buy_qty'):
                    rows_html.append(
                        f'<div style="{S["card_row"]}">매수 평단 {prev_avg:,}원 (어제 잔고 기준)</div>'
                    )
                if j.get('buy_qty'):
                    rows_html.append(
                        f'<div style="{S["card_row"]}">매수 {j["buy_avg"]:,}원 × {j["buy_qty"]:,}주 = {j["buy_amt"]:,}원</div>'
                    )
                if j.get('sell_qty'):
                    rows_html.append(
                        f'<div style="{S["card_row"]}">매도 {j["sell_avg"]:,}원 × {j["sell_qty"]:,}주 = {j["sell_amt"]:,}원</div>'
                    )
                    rows_html.append(
                        f'<div style="{S["card_row"]}">실현 {_signed(j["pl_amt"])} ({_signed_pct(j["prft_rt"])}) · '
                        f'수수료·세금 {j["cmsn_tax"]:,}원</div>'
                    )
                rows_html.append(
                    f'<div style="{S["card_row"]}">잔고 평단 {r["avg"]:,}원 · 보유 {r["qty"]:,}주</div>'
                )
            parts.append(
                f'<div style="{S["card_traded"]}">'
                f'<div style="{S["card_title"]}">{html_escape(r["stock"])} '
                f'<span style="{S["card_meta"]}">({html_escape("+".join(r["accounts"]))})</span>{tag}</div>'
                + ''.join(rows_html)
                + '</div>'
            )

    if by_account:
        parts.append(f'<div style="{S["section"]}">계좌별 종목 상세</div>')
        for label in d['labels']:
            acc_rows = [r for r in d['rows'] if r['account'] == label]
            if not acc_rows:
                continue
            acc_total = sum(r['eval_value'] for r in acc_rows)
            acc_cost = sum(r['buy_amount'] for r in acc_rows)
            acc_profit = sum(r['profit'] for r in acc_rows)
            acc_rate = (acc_profit / acc_cost * 100) if acc_cost else 0.0
            parts.append(
                f'<div style="{S["subhead"]}">'
                f'· [{html_escape(label)}] {len(acc_rows)}종목 · 평가 {acc_total:,}원 · '
                f'손익 {_signed(acc_profit)} ({_signed_pct(acc_rate)})</div>'
            )
            for r in sorted(acc_rows, key=lambda x: -x['eval_value']):
                r['traded_today'] = (r['tdy_buyq'] + r['tdy_sellq']) > 0
                parts.append(_render_html_position_card(r, acc_total, account_tag=label))
    else:
        # behive_web 와 동일 — 당일 매매 종목도 보유 종목 섹션에 그대로 표시 (양쪽에 노출, ★ 마커로 구분).
        if held:
            parts.append(f'<div style="{S["section"]}">보유 종목 ({len(held)}개)</div>')
            for r in sorted(held, key=lambda x: -x['eval_value']):
                parts.append(_render_html_position_card(r, d['total_value']))

    parts.append('</div>')
    return '\n'.join(parts)


def _render_html_body(owner_data: dict, ordered: list, balances: dict, today_key: str, prev_key, by_account: bool) -> str:
    S = HTML_STYLES
    parts = [
        f'<div style="{S["wrap"]}">',
        f'<div style="{S["greet"]}">관리자님, {html_escape(today_key)} 20:00 기준 보유주식 리포트입니다.</div>',
    ]

    if len(ordered) > 1:
        grand_value = sum(d['total_value'] for d in owner_data.values())
        grand_cost = sum(d['total_cost'] for d in owner_data.values())
        grand_profit = sum(d['total_profit'] for d in owner_data.values())
        grand_rate = (grand_profit / grand_cost * 100) if grand_cost else 0.0
        grand_deposit = sum(d['deposit'] for d in owner_data.values())
        grand_net = sum(d['total_net'] for d in owner_data.values())
        grand_cash_in = sum(d.get('cash_in', 0) for d in owner_data.values())
        grand_cash_out = sum(d.get('cash_out', 0) for d in owner_data.values())
        grand_net_cash_flow = grand_cash_in - grand_cash_out
        # 모든 owner의 prev_net이 있어야 grand 비교 가능 (일부만 있으면 의미 없음)
        if all(d['prev_net'] is not None for d in owner_data.values()):
            grand_prev_net = sum(d['prev_net'] for d in owner_data.values())
            grand_day_pl_total = grand_net - grand_prev_net - grand_net_cash_flow
        else:
            grand_prev_net = None
            grand_day_pl_total = None

        parts.append(f'<div style="{S["summary_block"]}">')
        parts.append(f'<div style="{S["owner_title"]}">전체 합계</div>')
        parts.append(f'<div style="{S["owner_labels"]}">관리자님 + 가희 통합</div>')
        parts.append(f'<table style="{S["kpi_table"]}">')

        def kpi(label_str: str, value_html: str) -> str:
            return (
                f'<tr><td style="{S["kpi_label"]}">{html_escape(label_str)}</td>'
                f'<td style="{S["kpi_value"]}">{value_html}</td></tr>'
            )

        # behive_web KPI 순서·라벨로 통일 (블록 자체가 전체 합산이라 '총' 중복 접두 제거)
        parts.append(kpi('총자산', f'{grand_net:,}원'))
        parts.append(kpi('매입금액', f'{grand_cost:,}원'))
        parts.append(kpi('평가금액', f'{grand_value:,}원'))
        parts.append(kpi(
            '평가손익',
            f'{_signed(grand_profit)} ({_signed_pct(grand_rate)})'
        ))
        parts.append(kpi('예수금', f'{grand_deposit:,}원'))
        # 당일 입출금은 예수금 바로 아래
        if grand_cash_in or grand_cash_out:
            gn = grand_net_cash_flow
            gsign = '+' if gn >= 0 else ''
            gcolor = '#0a7' if gn > 0 else ('#d33' if gn < 0 else '#666')
            # 한쪽만 있으면 순액과 같은 값이라 생략
            gdetail = ''
            if grand_cash_in and grand_cash_out:
                gdetail = (f' <span style="color:#888;font-size:13px;">'
                           f'· 입금 {grand_cash_in:,}원 · 출금 {grand_cash_out:,}원</span>')
            parts.append(kpi(
                '당일 입출금',
                f'<span style="color:{gcolor};">{gsign}{gn:,}원</span>{gdetail}'
            ))
        if grand_day_pl_total is not None:
            pct_html = ''
            if grand_prev_net:
                gpct = (grand_day_pl_total / grand_prev_net) * 100
                pct_html = f' ({_signed_pct(gpct)})'
            suffix = f' <span style="color:#888;font-size:13px;">· {prev_key} 대비</span>' if prev_key else ''
            parts.append(kpi('당일 평가손익', f'{_signed(grand_day_pl_total)}{pct_html}{suffix}'))
        elif prev_key:
            parts.append(kpi('당일 평가손익', '<span style="color:#888;">전날 스냅샷 없음</span>'))
        grand_realized_pl = sum(d.get('realized_pl_total', 0) for d in owner_data.values())
        grand_realized_fees = sum(d.get('realized_fees_total', 0) for d in owner_data.values())
        if any(d.get('traded_today') for d in owner_data.values()) and (grand_realized_pl or grand_realized_fees):
            rsign = '+' if grand_realized_pl >= 0 else ''
            rcolor = '#0a7' if grand_realized_pl > 0 else ('#d33' if grand_realized_pl < 0 else '#666')
            fee_part = f' <span style="color:#888;font-size:13px;">· 수수료·세금 {grand_realized_fees:,}원</span>' if grand_realized_fees else ''
            parts.append(kpi(
                '당일 실현손익',
                f'<span style="color:{rcolor};">{rsign}{grand_realized_pl:,}원</span>{fee_part}'
            ))
        parts.append('</table></div>')

    for idx, owner in enumerate(ordered, start=1):
        parts.append(_render_html_owner(idx, owner, owner_data[owner], balances, prev_key, by_account))

    parts.append('</div>')
    return '\n'.join(parts)


def build_report(by_account: bool = False) -> tuple[str, str, str]:
    rows, balances, journal_by_label = collect_rows()
    if not rows:
        raise RuntimeError('키움 REST 응답에 보유종목이 없습니다.')

    today_key = datetime.now(KST).strftime('%Y-%m-%d')

    # 당일 매수 동반 매도행의 pl을 '전일 보유 평단' 기준으로 재계산 (ka10170 round-trip 오표시 교정).
    # 자산웹과 동일 기준 — 전일 평단은 파일 이력 재생(prev_avg_map, 당일분 제외)에서.
    try:
        import trade_journal as tj
        _prev_avgs = tj.prev_avg_map(today_key)
        for _lb, _trs in journal_by_label.items():
            for _t in _trs:
                _pa = _prev_avgs.get((_lb, _t.get('code')), 0)
                if (_t.get('sell_qty') or 0) > 0 and (_t.get('buy_qty') or 0) > 0 and _pa > 0:
                    tj._apply_rebase(_t, float(_t.get('sell_avg') or 0), int(_t['sell_qty']), _pa)
    except Exception as e:
        print(f'[journal rebase] failed: {e}', file=sys.stderr)

    all_labels = [a['label'] for a in list_accounts()]
    owner_groups = group_by_owner(all_labels)

    # 당일 외부 입출금 (kt00015 tp='1') — day_pl_total 보정용. 페치 실패해도 리포트는 진행.
    try:
        cash_flow_by_label = get_cash_flow_all()
    except Exception as e:
        print(f'[cash-flow] fetch failed: {e}', file=sys.stderr)
        cash_flow_by_label = {}

    snapshots = load_json(SNAPSHOT_FILE, {})
    prev_keys = sorted(k for k in snapshots if k < today_key)
    prev_key = prev_keys[-1] if prev_keys else None
    prev_snap_all = _normalize_prev_snap(snapshots.get(prev_key, {})) if prev_key else {}

    # A-4: prev_snap 없을 때만 ka10001 보정. 정상 운영(prev_snap 있음)에선 rebase_to_nxt_close 가
    # NXT-on-NXT 기준 더 정확한 baseline 으로 덮어쓰므로 보정 불필요.
    if not prev_snap_all:
        applied = apply_ka10001_corrections(rows)
        print(f'[ka10001-fallback] prev_snap 없음 → {applied}/{len({r["code"] for r in rows})}개 종목 보정',
              file=sys.stderr)

    today_snapshot: dict[str, dict] = {}
    owner_data: dict[str, dict] = {}

    for owner, labels in owner_groups.items():
        owner_rows = [r for r in rows if r['account'] in labels]
        consolidated = consolidate(owner_rows)

        # journal 합산 + consolidated 보강 + 잔고에 없는 거래(round-trip·풀매도)는 phantom 카드로 추가
        owner_journal = aggregate_journal({lb: journal_by_label.get(lb, []) for lb in labels})
        # ka10170이 어제부터 보유한 종목 매도분에 pl_amt=0을 박는 케이스 보강 (스냅샷 avg_price 사용)
        prev_holdings_for_owner = prev_snap_all.get(owner, {}).get('holdings') if prev_snap_all else {}
        enrich_journal_realized_pl(owner_journal, prev_holdings_for_owner)
        held_codes = {r['code'] for r in consolidated}
        for r in consolidated:
            j = owner_journal.get(r['code'])
            if j:
                r['journal'] = j
                # journal 기준으로 tdy 수량 정정 (kt00018와 일치하지만 phantom 합산 시 일관성)
                r['tdy_buyq'] = j['buy_qty']
                r['tdy_sellq'] = j['sell_qty']
                r['traded_today'] = (j['buy_qty'] + j['sell_qty']) > 0
        for code, j in owner_journal.items():
            if code in held_codes:
                continue
            # 잔고에 없는 거래 — phantom 엔트리. 잔고 카드(상세)에는 안 들어가고 traded_today 섹션에만 노출.
            consolidated.append({
                'stock': j['name'],
                'code': code,
                'accounts': list(j['accounts']),
                'qty': 0,
                'avg': 0,
                'price': 0,
                'pred_close': 0,
                'day_change': 0,
                'day_change_pct': 0.0,
                'buy_amount': 0,
                'eval_value': 0,
                'profit': 0,
                'profit_rate': 0.0,
                'tdy_buyq': j['buy_qty'],
                'tdy_sellq': j['sell_qty'],
                'traded_today': True,
                'phantom': True,
                'journal': j,
            })

        owner_prev = prev_snap_all.get(owner, {})
        prev_holdings = owner_prev.get('holdings', {}) if owner_prev else {}
        prev_eval = sum(v.get('eval_value', 0) for v in prev_holdings.values()) if prev_holdings else None
        prev_deposit = owner_prev.get('deposit') if owner_prev else None

        # 개별 종목의 day_change/pred_close를 어제 NXT 종가 baseline으로 재계산.
        # 이로써 개별 카드의 '평가금액 변동' 합계 ≈ 합계 KPI '전날대비 순자산'이 NXT-on-NXT로 일치한다.
        rebase_to_nxt_close(consolidated, prev_holdings)

        # 당일 실현손익 합계 (그로스 기준, 수수료·세금 별도)
        realized_pl_total = sum(j.get('pl_amt', 0) for j in owner_journal.values() if j.get('sell_qty', 0) > 0)
        realized_fees_total = sum(j.get('cmsn_tax', 0) for j in owner_journal.values() if j.get('sell_qty', 0) > 0)

        total_cost = sum(r['buy_amount'] for r in consolidated)
        total_value = sum(r['eval_value'] for r in consolidated)
        total_profit = sum(r['profit'] for r in consolidated)
        total_profit_rate = (total_profit / total_cost * 100) if total_cost else 0.0
        traded_today = [r for r in consolidated if r['traded_today']]

        # 전날대비는 순자산(평가금액+예수금) 기준. prev에 deposit이 없으면 비교 불가 (None).
        # 예수금은 d2_entra(D+2 정산예수금) 기준 — 매수 즉시 차감/매도 즉시 가산되어
        # 평가금액 변동과 일관성이 맞는다. entr은 D+2 결제 전까지 그대로라
        # 신규 매수 시 평가↑·예수금 그대로 → 매수원가만큼 손익 부풀려지는 버그 발생.
        owner_deposit = sum(balances.get(lb, {}).get('d2_entra', 0) for lb in labels)
        total_net = total_value + owner_deposit

        # 당일 외부 입출금 집계 — 손익이 아니므로 day_pl_total 에서 제거해야 한다.
        # 입금은 total_net 만 부풀리고, 출금은 깎으므로 raw 차이값에 그대로 반영되어 있다.
        owner_cash_flows: list[dict] = []
        cash_in = 0
        cash_out = 0
        for lb in labels:
            for f in cash_flow_by_label.get(lb, []) or []:
                owner_cash_flows.append({**f, 'label': lb})
                if f['io_tp'] == 'IN':
                    cash_in += f['amount']
                elif f['io_tp'] == 'OUT':
                    cash_out += f['amount']
        net_cash_flow = cash_in - cash_out

        prev_net = (prev_eval + prev_deposit) if (prev_eval is not None and prev_deposit is not None) else None
        # 키 이름은 behive_web 와 통일: day_pl_total = (total_net - prev_net) - net_cash_flow.
        # 외부 입출금은 손익이 아니므로 제거. % 는 표시 시점에 prev_net 으로 사후 계산.
        day_pl_total = (total_net - prev_net - net_cash_flow) if prev_net is not None else None

        snap_holdings = {}
        for r in consolidated:
            snap_holdings[r['stock']] = {
                'qty': r['qty'],
                'avg_price': r['avg'],
                'price': r['price'],
                'eval_value': r['eval_value'],
                'profit': r['profit'],
            }
        today_snapshot[owner] = {
            'holdings': snap_holdings,
            'deposit': owner_deposit,
            # v4 optional fields: daily trade journal for realized P/L statistics.
            # Empty list is intentional — distinguishes "no trades" from legacy snapshots without this key.
            'trade_journal': snapshot_trade_journal(owner_journal),
            'realized_pl_total': realized_pl_total,
            'realized_fees_total': realized_fees_total,
            # v5: 당일 외부 입출금 (kt00015 tp='1'). 자산차트 수직선·툴팁 표시용.
            # 누락 시 0 (legacy snapshot) — behive_web 측 reader가 .get(0)로 처리.
            'cash_in': cash_in,
            'cash_out': cash_out,
            'net_cash_flow': net_cash_flow,
        }

        owner_data[owner] = {
            'labels': labels,
            'rows': owner_rows,
            'consolidated': consolidated,
            'total_cost': total_cost,
            'total_value': total_value,
            'total_profit': total_profit,
            'total_profit_rate': total_profit_rate,
            'traded_today': traded_today,
            'deposit': owner_deposit,
            'total_net': total_net,
            'prev_value': prev_eval,
            'prev_deposit': prev_deposit,
            'prev_net': prev_net,
            'day_pl_total': day_pl_total,
            'prev_snap': prev_holdings,
            'realized_pl_total': realized_pl_total,
            'realized_fees_total': realized_fees_total,
            'cash_in': cash_in,
            'cash_out': cash_out,
            'net_cash_flow': net_cash_flow,
            'cash_flows': owner_cash_flows,
        }

    # 당일정산(phantom) 종목에 현재가 주입 — 매도 시점과 시장 종가 비교용.
    # ka10095 batch 1콜로 처리. 실패해도 리포트는 정상 발송.
    phantom_codes = sorted({
        r['code']
        for d in owner_data.values()
        for r in d['consolidated']
        if r.get('phantom') and r.get('code')
    })
    if phantom_codes:
        try:
            live_quotes = get_watchlist_quotes(phantom_codes)
        except Exception as e:
            print(f'[phantom-quote] fetch failed: {e}', file=sys.stderr)
            live_quotes = {}
        for d in owner_data.values():
            for r in d['consolidated']:
                if r.get('phantom') and r.get('code'):
                    q = live_quotes.get(r['code'])
                    if q:
                        r['live_price'] = q.get('price', 0)
                        r['live_change_pct'] = q.get('change_pct', 0.0)

    # watchlist + interests + 보유 종목 NXT 가격 캡처 — 익일 behive_web ohlc-mini의 NXT 컬럼 baseline.
    # 20:10 KST는 NXT 운영 2시간 경과 시점이라 마감가(06:00)와 100% 일치하지는 않으나 근사치로 사용.
    # NXT 거래량 0 = NXT 미상장 → `_nxt_inactive` 코드 목록으로 분리 저장. behive_web이 KRX→NXT fallback 차단에 사용.
    try:
        wl_codes: set[str] = set()
        for fname in ('behive_watchlist.json', 'behive_interests.json'):
            wlp = WORKSPACE / 'state' / fname
            if not wlp.exists():
                continue
            wld = json.loads(wlp.read_text())
            entries = wld.values() if isinstance(wld, dict) else (wld if isinstance(wld, list) else [])
            for entry in entries:
                if isinstance(entry, dict):
                    code = (entry.get('code') or '').strip()
                    if code:
                        wl_codes.add(code)
        holding_codes: set[str] = set()
        for od in owner_data.values():
            for r in od.get('consolidated', []):
                if r.get('code') and not r.get('phantom'):
                    holding_codes.add(r['code'])
        all_codes = sorted(wl_codes | holding_codes)
        if all_codes:
            nxt_quotes = get_watchlist_quotes(all_codes, exchange='NX')
            active_codes = {code for code, q in nxt_quotes.items() if (q.get('volume') or 0) > 0}
            today_snapshot['_watchlist_nxt'] = {
                code: nxt_quotes[code]['price']
                for code in (wl_codes & active_codes)
                if nxt_quotes[code].get('price')
            }
            inactive = [c for c in all_codes if c not in active_codes]
            if inactive:
                today_snapshot['_nxt_inactive'] = sorted(inactive)
    except Exception as e:
        print(f'[watchlist-nxt] capture failed: {e}', file=sys.stderr)

    snapshots[today_key] = today_snapshot
    save_json(SNAPSHOT_FILE, snapshots)

    ordered = sorted(owner_data.keys(), key=lambda o: (o != '본인', o))

    lines = [f'관리자님, {today_key} 20:00 기준 보유주식 리포트입니다.', '']

    for idx, owner in enumerate(ordered, start=1):
        d = owner_data[owner]
        title = OWNER_LABELS.get(owner, f'{owner} 자산')
        labels_disp = ' + '.join(d['labels'])
        held = [r for r in d['consolidated'] if not r.get('phantom')]
        # behive_web KPI 순서·라벨로 통일. 라벨은 표시폭 17칸 정렬(한글 2칸 기준).
        lines += [
            '==============================',
            f"{idx}) {title} ({labels_disp})",
            '==============================',
            f'총자산           : {d["total_net"]:,}원',
            f'매입금액         : {d["total_cost"]:,}원',
            f'평가금액         : {d["total_value"]:,}원',
            f'평가손익         : {d["total_profit"]:+,}원 ({d["total_profit_rate"]:+.2f}%)',
            f'예수금           : {d["deposit"]:,}원',
            # '보유종목/당일매매'는 표시폭이 정확히 17칸이라 패딩 없이 콜론이 맞는다.
            f'보유종목/당일매매: {len(held)}개 / {len(d["traded_today"])}개',
        ]
        day_pl = d.get('day_pl_total')
        if day_pl is not None:
            prev_net = d.get('prev_net') or 0
            pct = (day_pl / prev_net * 100) if prev_net else 0.0
            suffix = f' ({prev_key} 대비)' if prev_key else ''
            lines.append(f'당일 평가손익    : {day_pl:+,}원 ({pct:+.2f}%){suffix}')
        elif prev_key:
            lines.append(f'당일 평가손익    : 전날 스냅샷 없음 ({prev_key})')
        if d.get('traded_today') and (d.get('realized_pl_total', 0) or d.get('realized_fees_total', 0)):
            fee = d.get('realized_fees_total', 0)
            fee_part = f' (수수료·세금 {fee:,}원)' if fee else ''
            lines.append(f'당일 실현손익    : {d["realized_pl_total"]:+,}원{fee_part}')
        lines.append('')
        for label in d['labels']:
            bal = balances.get(label)
            if not bal:
                continue
            lines.append(f'[{label}] 예수금 {bal["d2_entra"]:,}원')
        lines.append('')

        if d['traded_today']:
            lines.append('— 당일 매매 종목 —')
            # phantom(당일정산) 먼저 → 보유종목 매매 순
            for r in sorted(d['traded_today'], key=lambda x: (not x.get('phantom'), -x['profit'])):
                j = r.get('journal') or {}
                tag = ' [당일정산]' if r.get('phantom') else ''
                lines.append(f"- {r['stock']} ({'+'.join(r['accounts'])}){tag}")
                prev_avg = j.get('prev_avg', 0)
                if r.get('phantom'):
                    # 당일정산: 현재가 → 매도 → 실현 → 매수 순
                    if r.get('live_price'):
                        lines.append(
                            f"    현재가 {r['live_price']:,}원 ({r.get('live_change_pct', 0.0):+.2f}%)"
                        )
                    if j.get('sell_qty'):
                        lines.append(f"    매도 {j['sell_avg']:,}원 × {j['sell_qty']:,}주 = {j['sell_amt']:,}원")
                        lines.append(
                            f"    실현 {j['pl_amt']:+,}원 ({j['prft_rt']:+.2f}%) · 수수료·세금 {j['cmsn_tax']:,}원"
                        )
                    if prev_avg > 0 and not j.get('buy_qty'):
                        lines.append(f"    매수 평단 {prev_avg:,}원 (어제 잔고 기준)")
                    if j.get('buy_qty'):
                        lines.append(f"    매수 {j['buy_avg']:,}원 × {j['buy_qty']:,}주 = {j['buy_amt']:,}원")
                else:
                    # 보유 종목 매매: 매수 → 매도 → 실현 → 잔고 평단 (기존 순서)
                    if prev_avg > 0 and not j.get('buy_qty'):
                        lines.append(f"    매수 평단 {prev_avg:,}원 (어제 잔고 기준)")
                    if j.get('buy_qty'):
                        lines.append(f"    매수 {j['buy_avg']:,}원 × {j['buy_qty']:,}주 = {j['buy_amt']:,}원")
                    if j.get('sell_qty'):
                        lines.append(f"    매도 {j['sell_avg']:,}원 × {j['sell_qty']:,}주 = {j['sell_amt']:,}원")
                        lines.append(
                            f"    실현 {j['pl_amt']:+,}원 ({j['prft_rt']:+.2f}%) · 수수료·세금 {j['cmsn_tax']:,}원"
                        )
                    lines.append(f"    잔고 평단 {r['avg']:,}원 / 보유 {r['qty']:,}주")
            lines.append('')

        if by_account:
            lines.append('— 계좌별 종목 상세 —')
            for label in d['labels']:
                acc_rows = [r for r in d['rows'] if r['account'] == label]
                if not acc_rows:
                    continue
                acc_total = sum(r['eval_value'] for r in acc_rows)
                acc_cost = sum(r['buy_amount'] for r in acc_rows)
                acc_profit = sum(r['profit'] for r in acc_rows)
                acc_rate = (acc_profit / acc_cost * 100) if acc_cost else 0.0
                lines.append(f'· [{label}] {len(acc_rows)}종목 · 평가 {acc_total:,}원 · 손익 {acc_profit:+,}원 ({acc_rate:+.2f}%)')
                lines.append('')
                for r in sorted(acc_rows, key=lambda x: -x['eval_value']):
                    r['traded_today'] = (r['tdy_buyq'] + r['tdy_sellq']) > 0
                    lines += format_row(r, acc_total, account_tag=label)
                    lines.append('')
        else:
            # behive_web 와 동일 — 당일 매매 종목도 보유 종목 섹션에 그대로 표시 (양쪽에 노출, ★ 마커로 구분).
            if held:
                lines.append(f'— 보유 종목 ({len(held)}개) —')
                for r in sorted(held, key=lambda x: -x['eval_value']):
                    lines += format_row(r, d['total_value'])
                    lines.append('')

    if lines and lines[-1] == '':
        lines.pop()

    grand_value = sum(d['total_value'] for d in owner_data.values())
    grand_cost = sum(d['total_cost'] for d in owner_data.values())
    grand_profit = sum(d['total_profit'] for d in owner_data.values())
    grand_rate = (grand_profit / grand_cost * 100) if grand_cost else 0.0
    grand_deposit = sum(d['deposit'] for d in owner_data.values())
    grand_net = sum(d['total_net'] for d in owner_data.values())
    grand_cash_in = sum(d.get('cash_in', 0) for d in owner_data.values())
    grand_cash_out = sum(d.get('cash_out', 0) for d in owner_data.values())
    grand_net_cash_flow = grand_cash_in - grand_cash_out
    if all(d['prev_net'] is not None for d in owner_data.values()):
        grand_day_pl_total = grand_net - sum(d['prev_net'] for d in owner_data.values()) - grand_net_cash_flow
    else:
        grand_day_pl_total = None

    tg_lines = [f'📊 주식 리포트 | {today_key} 20:00']
    tg_lines.append('')
    tg_lines.append(f'평가 {grand_value:,}원 · 예수금 {grand_deposit:,}원')
    tg_lines.append(f'총자산 {grand_net:,}원')
    tg_lines.append(f'{grand_profit:+,}원 ({grand_rate:+.2f}%)')
    if grand_day_pl_total is not None:
        tg_lines.append(f'당일 평가손익 {grand_day_pl_total:+,}원 ({prev_key} 대비)')
    elif prev_key:
        tg_lines.append(f'당일 평가손익 비교 불가 (예수금 데이터 없음, {prev_key})')
    elif owner_data:
        tg_lines.append('전날 이전 스냅샷 없음')
    if grand_cash_in or grand_cash_out:
        # 한쪽만 있으면 순액과 같은 값이라 괄호 생략
        _brk = (f' (입금 {grand_cash_in:,}원 · 출금 {grand_cash_out:,}원)'
                if (grand_cash_in and grand_cash_out) else '')
        tg_lines.append(f'당일 입출금 {grand_net_cash_flow:+,}원{_brk}')
    grand_realized_pl = sum(d.get('realized_pl_total', 0) for d in owner_data.values())
    if any(d.get('traded_today') for d in owner_data.values()) and grand_realized_pl:
        tg_lines.append(f'당일 실현 {grand_realized_pl:+,}원')
    tg_lines.append('')

    for owner in ordered:
        d = owner_data[owner]
        title = OWNER_LABELS.get(owner, owner).replace(' 자산', '')
        tg_lines.append(f'• {title} 총자산 {d["total_net"]:,}원')
        tg_lines.append(f'  평가 {d["total_value"]:,}원 · 예수금 {d["deposit"]:,}원')
        tg_lines.append(f'  {d["total_profit"]:+,}원 ({d["total_profit_rate"]:+.2f}%)')
        if d.get('day_pl_total') is not None:
            prev_net = d.get('prev_net') or 0
            pct = (d['day_pl_total'] / prev_net * 100) if prev_net else 0.0
            tg_lines.append(f'  당일 평가손익 {d["day_pl_total"]:+,}원 ({pct:+.2f}%)')
        elif prev_key:
            tg_lines.append(f'  당일 평가손익 비교 불가')
        if d.get('traded_today') and d.get('realized_pl_total', 0):
            tg_lines.append(f'  당일 실현 {d["realized_pl_total"]:+,}원')
        if d.get('cash_in', 0) or d.get('cash_out', 0):
            tg_lines.append(f'  당일 입출금 {d["net_cash_flow"]:+,}원')

    cross_traded: list[tuple[str, dict]] = []
    for owner in ordered:
        for r in owner_data[owner]['traded_today']:
            cross_traded.append((owner, r))

    if cross_traded:
        tg_lines.append('')
        tg_lines.append('📈 당일 매매')
        # 정렬: phantom(실현손익 큰 순) → 보유(평가손익 큰 순)
        for owner, r in sorted(cross_traded, key=lambda t: (not t[1].get('phantom'), -t[1]['profit'])):
            j = r.get('journal') or {}
            acc_tag = '+'.join(r['accounts'])
            owner_short = OWNER_LABELS.get(owner, owner).replace(' 자산', '')
            phantom = r.get('phantom', False)
            tag = ' (당일정산)' if phantom else ''
            tg_lines.append(f"- {r['stock']} [{owner_short}/{acc_tag}]{tag}")
            if phantom:
                # 당일정산: 현재가 → 매도 → 실현 → 매수 순
                if r.get('live_price'):
                    tg_lines.append(
                        f"  현재가 {r['live_price']:,}원 ({r.get('live_change_pct', 0.0):+.2f}%)"
                    )
                if j.get('sell_qty'):
                    tg_lines.append(
                        f"  매도 {j['sell_avg']:,}원 × {j['sell_qty']:,}주 = {j['sell_amt']:,}원"
                    )
                    tg_lines.append(
                        f"  실현 {j['pl_amt']:+,}원 ({j['prft_rt']:+.2f}%) · 수수료·세금 {j['cmsn_tax']:,}원"
                    )
                if j.get('buy_qty'):
                    tg_lines.append(
                        f"  매수 {j['buy_avg']:,}원 × {j['buy_qty']:,}주 = {j['buy_amt']:,}원"
                    )
            else:
                # 보유 종목 매매: 매수 → 매도 → 실현 (기존 순서)
                if j.get('buy_qty'):
                    tg_lines.append(
                        f"  매수 {j['buy_avg']:,}원 × {j['buy_qty']:,}주 = {j['buy_amt']:,}원"
                    )
                if j.get('sell_qty'):
                    tg_lines.append(
                        f"  매도 {j['sell_avg']:,}원 × {j['sell_qty']:,}주 = {j['sell_amt']:,}원"
                    )
                    tg_lines.append(
                        f"  실현 {j['pl_amt']:+,}원 ({j['prft_rt']:+.2f}%) · 수수료·세금 {j['cmsn_tax']:,}원"
                    )
            # 보유 종목: 잔고 평단·당일손익(평가) 함께 표시
            if not phantom:
                owner_prev = owner_data[owner]['prev_snap']
                prev = owner_prev.get(r['stock']) if owner_prev else None
                day_profit = None
                day_profit_pct = None
                if prev:
                    day_profit = r['profit'] - prev.get('profit', 0)
                    prev_eval = prev.get('eval_value', 0)
                    if prev_eval:
                        day_profit_pct = day_profit / prev_eval * 100
                line = f"  평단 {r['avg']:,}원"
                if day_profit is not None:
                    if day_profit_pct is not None:
                        line += f" · 잔고손익 {day_profit:+,}원 ({day_profit_pct:+.2f}%)"
                    else:
                        line += f" · 잔고손익 {day_profit:+,}원"
                tg_lines.append(line)

    tg_text = '\n'.join(tg_lines)

    html_body = _render_html_body(owner_data, ordered, balances, today_key, prev_key, by_account)

    return '\n'.join(lines), html_body, tg_text


def send_mail(subject: str, body: str, html: bool = False) -> str:
    base = ['gog', 'gmail', 'send', '--to', RECIPIENT, '--subject', subject]
    if html:
        return run(base + ['--body-html', body]).strip()
    return run(base + ['--body-file', '-'], input_text=body).strip()


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


def _notify_self(reason: str, exc: Exception) -> None:
    try:
        send_telegram(f'⚠️ [주식 리포트] {reason}\n{type(exc).__name__}: {exc}')
    except Exception:
        pass


def main() -> int:
    started_at = datetime.now(KST)
    args = sys.argv[1:]
    mode = 'run'
    by_account = False
    no_mail = False
    for a in args:
        if a == '--by-account':
            by_account = True
        elif a == '--no-mail':
            no_mail = True
        elif a in ('run', 'send'):
            mode = a
        else:
            print(f'unknown arg: {a}', file=sys.stderr)
            return 2

    print(
        f"[START] stock_portfolio_report mode={mode} by_account={by_account} "
        f"no_mail={no_mail} ts={started_at.isoformat()} pid={os.getpid()}"
    )

    # send 모드는 휴장일/주말이면 즉시 스킵 — 시세·잔고 무변동이라 메일이 어제와 동일.
    # run 모드(수동·디버깅)는 그대로 통과시켜 사용자가 언제든 미리보기 가능.
    if mode == 'send':
        try:
            from holiday_sync import is_market_day_today
            if not is_market_day_today():
                print(f'[skip] {started_at.isoformat()} KRX 휴장일/주말 — 리포트 발송 스킵')
                return 0
        except Exception as e:
            print(f'[holiday-check-fail] {e} — 평소대로 진행', file=sys.stderr)

    try:
        body, html_body, tg_text = build_report(by_account=by_account)
    except Exception as e:
        import traceback
        print(f'[fatal] build_report 실패: {e}\n{traceback.format_exc()}', file=sys.stderr)
        if mode == 'send':
            _notify_self('build 실패', e)
        print(f"[END] stock_portfolio_report status=build_fail ts={datetime.now(KST).isoformat()}")
        return 1

    subject = f"[주식 리포트] {datetime.now(KST):%Y-%m-%d} 20:00"

    if mode == 'send':
        # --no-mail: 스냅샷만 남기는 스케줄 실행용. 휴장일 self-skip·실패 알림은 그대로 유지해야
        # 하므로 run 모드가 아니라 send 경로에서 메일 호출만 건너뛴다.
        if no_mail:
            print('MAIL_SKIPPED=no_mail_flag')
            print(f"[END] stock_portfolio_report status=ok ts={datetime.now(KST).isoformat()}")
            return 0
        mail_ok = False
        try:
            send_result = send_mail(subject, html_body, html=True)
            mail_ok = True
            print('MAIL_SENT')
            print(send_result)
        except Exception as e:
            print(f'[mail-fail] {e}', file=sys.stderr)
            _notify_self('메일 발송 실패', e)
        print('TELEGRAM_SKIPPED=disabled_for_report')
        end_status = 'ok' if mail_ok else 'mail_fail'
        print(f"[END] stock_portfolio_report status={end_status} ts={datetime.now(KST).isoformat()}")
        return 0 if mail_ok else 1
    else:
        print(body)
        preview_path = Path('/tmp/stock_report_preview.html')
        preview_path.write_text(html_body)
        print(f'\n---HTML--- preview saved: {preview_path}')
        print(f"[END] stock_portfolio_report status=ok ts={datetime.now(KST).isoformat()}")
        return 0


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