#!/usr/bin/env python3
"""투자원금(순입금) 집계 — 조회 전용.

투자원금 = 계좌 개설 이후 누적 입금 − 누적 출금. "내 돈 얼마 넣었나"이며,
`총 매입금액`(현재 보유종목 매입원가)이나 `예수금`(남은 현금)과 전혀 다른 값이다.
이 값이 있어야 `순자산 − 투자원금 = 진짜 총수익`(실현+미실현+배당)이 나온다.
키움 kt00018의 평가손익은 현재 보유분 미실현만 잡아 누적 실현손익·배당을 놓친다.

데이터원은 kt00016(일별계좌수익률상세현황)의 기간내총입금·총출금.
⚠️ kt00016은 **조회기간 1년 제한**이라 연도별 창으로 쪼개 누적한다.

캐시 `state/investment_principal.json`:
  - frozen : 지난 연도 = 확정값. 한 번 적재 후 재조회 안 함 (0인 연도는 저장 생략)
  - current: 올해 창(0101~오늘) 누적. 갱신 대상은 이것뿐 → 계좌당 1콜
  - seen   : 마지막 갱신 시점의 "당일 입출금" 스냅샷 — 이벤트 감지 기준

갱신은 별도 트리거 없이 behive_web 렌더 경로에서 이벤트로만 일어난다.
당일 입출금(이미 kt00015로 조회 중)이 seen과 다를 때만 올해 창을 재조회하므로
입출금 없는 평시에는 kt00016 호출이 0이다. 안전망으로 7일 초과 시 강제 갱신.

CLI: python3 scripts/investment_principal.py {bootstrap|refresh|show}
"""
from __future__ import annotations

import json
import os
import sys
import time
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import kiwoom_client as kc

KST = timezone(timedelta(hours=9))
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
STATE_FILE = WORKSPACE / 'state' / 'investment_principal.json'

# 부트스트랩 하한 연도. 계좌 개설 전 기간은 kt00016이 에러 없이 전 필드 0을 주므로
# 넉넉히 잡아도 안전하다 (현재 최고(最古) 계좌는 2018년 개설).
FLOOR_YEAR = 2010
PACE_SEC = 0.35          # 연속 조회 rate limit 페이싱
STALE_DAYS = 7           # 이벤트 감지가 못 잡은 경우의 안전망

# 적요에 이 중 하나가 들어가면 '내가 넣은/뺀 돈'(원금 반영), 아니면 투자 성과(원금 무관).
#   원금 반영 : 이체입금(지급결제)·이체오픈뱅킹입금·이체입금·대체입금·소액이체인증입금
#               ·ISA가입인증입금 / 이체출금·대체출금
#   원금 무관 : 배당금입금·수익분배금입금·예탁금이용료(이자)입금·공모주환불금입금
#               ·쿠폰현금지급(국내)입금·단주매각대금입금·대여수수료입금
#               / 공모불입출금·청약수수료출금
# ⚠️ 추측이 아니라 실증된 규칙 — 이 분류의 합이 kt00016 기간내총입금/총출금과
# 4계좌 18개 (계좌,연도) 구간 전부에서 오차 0으로 일치했다(2026-07-29).
# 새 적요가 등장하면 reconciled=False로 드러나므로 조용히 틀리지 않는다.
_PRINCIPAL_RMRK_KEYS = ('이체', '대체', '인증')


def is_principal_flow(rmrk: str) -> bool:
    """적요가 투자원금에 반영되는 외부 입출금인지. 배당·이자 등은 False."""
    return any(k in (rmrk or '') for k in _PRINCIPAL_RMRK_KEYS)


# ---- 캐시 I/O ----

@contextmanager
def _lock():
    """STATE_FILE 직렬화 — behive_web _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
    import fcntl
    lock_path = STATE_FILE.with_suffix(STATE_FILE.suffix + '.lock')
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    f = open(lock_path, 'a')
    try:
        fcntl.flock(f.fileno(), fcntl.LOCK_EX)
        yield
    finally:
        try:
            fcntl.flock(f.fileno(), fcntl.LOCK_UN)
        finally:
            f.close()


def _load() -> dict | None:
    """캐시 로드. 파일 없거나 깨졌으면 None (호출측이 '미집계'로 처리)."""
    if not STATE_FILE.exists():
        return None
    try:
        d = json.loads(STATE_FILE.read_text())
    except Exception:
        return None
    if not isinstance(d, dict) or 'current' not in d:
        return None
    d.setdefault('frozen', {})
    d.setdefault('seen', {})
    d['current'].setdefault('labels', {})
    return d


def _save(d: dict) -> None:
    d['refreshed_at'] = datetime.now(KST).isoformat(timespec='seconds')
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    tmp = STATE_FILE.with_suffix('.json.tmp')
    tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
    os.replace(tmp, STATE_FILE)


# ---- 조회 ----

def _today_str() -> str:
    return datetime.now(KST).strftime('%Y%m%d')


def _this_year() -> int:
    return datetime.now(KST).year


def _window(year: int) -> tuple[str, str]:
    """연도별 조회 창. 올해는 오늘까지, 지난 연도는 12/31까지 (1년 제한 내)."""
    if year == _this_year():
        return f'{year}0101', _today_str()
    return f'{year}0101', f'{year}1231'


def _fetch_year(label: str, year: int) -> dict:
    """한 (계좌, 연도) 창의 합계(kt00016) + 입출금 행(kt00015). 2콜.

    flows 행의 `pr`은 원금 반영 여부. 분류 합이 kt00016 합계와 맞는지 `rec`에 남겨
    새 적요가 등장해 분류가 어긋나면 화면에서 드러나게 한다.
    """
    fr, to = _window(year)
    r = kc.get_period_return(label, fr, to)
    time.sleep(PACE_SEC)
    try:
        raw = kc.get_cash_flow(label, strt_dt=fr, end_dt=to)
    except Exception as e:
        sys.stderr.write(f'cash flow {label} {year} 실패: {e}\n')
        raw = None
    flows = None if raw is None else [
        {'d': f['date'], 'io': f['io_tp'], 'amt': f['amount'],
         'rk': f['rmrk'], 'pr': is_principal_flow(f['rmrk'])}
        for f in raw
    ]
    rec = None
    if flows is not None:
        pin = sum(f['amt'] for f in flows if f['io'] == 'IN' and f['pr'])
        pout = sum(f['amt'] for f in flows if f['io'] == 'OUT' and f['pr'])
        rec = (pin == r['cash_in'] and pout == r['cash_out'])
    return {
        'in': r['cash_in'], 'out': r['cash_out'],
        'qin': r['qty_in'], 'qout': r['qty_out'],
        'nf': r['net_fr'], 'nt': r['net_to'], 'pl': r['eval_pl'],
        'flows': flows, 'rec': rec,
    }


def _is_empty(y: dict) -> bool:
    """계좌 개설 전(또는 전면 무활동) 연도 — 저장 생략 대상."""
    return not any(y[k] for k in ('in', 'out', 'qin', 'qout', 'nf', 'nt', 'pl'))


def _labels(labels: list[str] | None = None) -> list[str]:
    all_lb = [a['label'] for a in kc.list_accounts()]
    if labels is None:
        return all_lb
    wanted = set(labels)
    return [lb for lb in all_lb if lb in wanted]


def _flow_totals(cash_flow_by_label: dict) -> dict:
    """behive_web의 kt00015 당일 입출금 → {label: {'in': n, 'out': n}}."""
    out: dict[str, dict] = {}
    for lb, flows in (cash_flow_by_label or {}).items():
        ci = sum(f['amount'] for f in (flows or []) if f.get('io_tp') == 'IN')
        co = sum(f['amount'] for f in (flows or []) if f.get('io_tp') == 'OUT')
        out[lb] = {'in': ci, 'out': co}
    return out


# ---- 공개 API ----

def bootstrap(labels: list[str] | None = None) -> dict:
    """FLOOR_YEAR ~ 올해 전수 조회로 캐시를 새로 만든다 (계좌×연도, 4×17≈68콜 ≈ 25초).

    조기 종료 없음 — 잔고가 0으로 비었던 휴면 연도가 중간에 끼어도 그 앞 연도를 놓치지 않는다.
    ⚠️ 1회성 CLI 전용. 렌더 경로에서 절대 호출하지 말 것.
    """
    lbs = _labels(labels)
    this_year = _this_year()
    frozen: dict[str, dict] = {}
    current: dict[str, dict] = {}
    for lb in lbs:
        frozen[lb] = {}
        for year in range(FLOOR_YEAR, this_year + 1):
            y = _fetch_year(lb, year)
            time.sleep(PACE_SEC)
            if year == this_year:
                current[lb] = y
            elif not _is_empty(y):
                frozen[lb][year_key(year)] = y
    with _lock():
        d = _load() or {}
        d['frozen'] = {**(d.get('frozen') or {}), **frozen}
        cur = d.get('current') or {}
        if cur.get('year') != this_year:
            cur = {'year': this_year, 'labels': {}}
        cur['labels'] = {**(cur.get('labels') or {}), **current}
        d['current'] = cur
        d['seen'] = {**(d.get('seen') or {}), **_seen_now(lbs)}
        _save(d)
    return _load()


def year_key(year: int) -> str:
    return str(year)


def _seen_now(labels: list[str]) -> dict:
    """현재 당일 입출금을 seen 형식으로. (CLI refresh/bootstrap 용 — kt00015 계좌당 1콜)"""
    today = datetime.now(KST).strftime('%Y-%m-%d')
    totals = _flow_totals(kc.get_cash_flow_all(None, labels))
    return {lb: {'date': today, **totals.get(lb, {'in': 0, 'out': 0})} for lb in labels}


def refresh_current(labels: list[str], cash_flow_by_label: dict | None = None) -> None:
    """올해 창만 재조회 (라벨당 1콜) + 연도 롤오버 시 작년을 확정값으로 이관.

    cash_flow_by_label: 호출측이 이미 들고 있는 kt00015 당일 입출금.
      None이면 직접 조회. seen 기록에 쓴다 — kt00016 호출 **전** 시점의 값이어야
      그 사이 들어온 입금이 seen에 앞서 반영돼 영구 미갱신되는 일이 없다.
    """
    lbs = _labels(labels)
    if not lbs:
        return
    if cash_flow_by_label is None:
        seen = _seen_now(lbs)
    else:
        today = datetime.now(KST).strftime('%Y-%m-%d')
        totals = _flow_totals(cash_flow_by_label)
        seen = {lb: {'date': today, **totals.get(lb, {'in': 0, 'out': 0})} for lb in lbs}

    this_year = _this_year()
    with _lock():
        d = _load()
        if d is None:
            return  # 부트스트랩 전 — 렌더 경로를 68콜로 막지 않는다
        cur = d['current']
        prev_year = cur.get('year')
        # 연도 롤오버: current에 담긴 작년치는 to_dt가 12/31이 아니라 미완결이므로
        # 전체 창(0101~1231)으로 다시 받아 확정 이관한다.
        if prev_year and prev_year != this_year:
            for lb in lbs:
                y = _fetch_year(lb, prev_year)
                time.sleep(PACE_SEC)
                if not _is_empty(y):
                    d['frozen'].setdefault(lb, {})[year_key(prev_year)] = y
            cur = {'year': this_year, 'labels': {}}
        for lb in lbs:
            cur['labels'][lb] = _fetch_year(lb, this_year)
            time.sleep(PACE_SEC)
        cur['year'] = this_year
        d['current'] = cur
        d['seen'].update(seen)
        _save(d)


def needs_refresh(labels: list[str], cash_flow_by_label: dict) -> bool:
    """올해 창 재조회가 필요한지 판정 — 순수 캐시 읽기(키움 콜 0).

    캐시가 없으면 False — 부트스트랩 미실행 상태에서 페이지 로드를 68콜로 막지 않는다.
    """
    d = _load()
    if d is None:
        return False
    if d['current'].get('year') != _this_year():
        return True
    today = datetime.now(KST).strftime('%Y-%m-%d')
    totals = _flow_totals(cash_flow_by_label)
    for lb in labels:
        if lb not in totals:
            continue  # owner 부분 갱신 — 조회 안 된 계좌는 판단 보류
        s = d['seen'].get(lb) or {}
        base = s if s.get('date') == today else {'in': 0, 'out': 0}
        if totals[lb]['in'] != base.get('in', 0) or totals[lb]['out'] != base.get('out', 0):
            return True
    ts = d.get('refreshed_at')
    if not ts:
        return True
    try:
        age = datetime.now(KST) - datetime.fromisoformat(ts)
    except Exception:
        return True
    return age > timedelta(days=STALE_DAYS)


def get_principal(labels: list[str]) -> dict:
    """캐시만 읽어 누적 원금 산출 (키움 콜 0).

    반환 principal이 None이면 '미집계' — 요청 라벨 중 부트스트랩 안 된 계좌가 있다는 뜻.
    (0으로 떨어뜨리면 그 계좌 입금이 조용히 빠져 원금이 과소계상된다.)
    qty_flow=True는 주식 입출고(타사대체) 이력 — 현금 기준 원금이 과소계상됨을 알림.
    by_label은 계좌별 순입금 {label: 원} — 자세히보기 토스트용.
    """
    miss = {'principal': None, 'cash_in': None, 'cash_out': None,
            'qty_flow': False, 'by_label': {}}
    d = _load()
    if d is None:
        return miss
    cur = d['current']['labels']
    total_in = total_out = 0
    qty_flow = False
    by_label: dict[str, int] = {}
    for lb in labels:
        if lb not in cur:
            return miss
        recs = list((d['frozen'].get(lb) or {}).values()) + [cur[lb]]
        lb_in = lb_out = 0
        for y in recs:
            lb_in += y.get('in', 0)
            lb_out += y.get('out', 0)
            qty_flow = qty_flow or bool(y.get('qin') or y.get('qout'))
        by_label[lb] = lb_in - lb_out
        total_in += lb_in
        total_out += lb_out
    return {
        'principal': total_in - total_out,
        'cash_in': total_in,
        'cash_out': total_out,
        'qty_flow': qty_flow,
        'by_label': by_label,
    }


def get_flows(labels: list[str]) -> dict:
    """입출금 내역 (최신순) — 캐시만 읽는다 (키움 콜 0).

    반환:
      - rows: [{date, io('IN'|'OUT'), amount, rmrk, principal(bool), label}] 최신순
      - other_in / other_out: 원금 무관 입출금 합계 (배당·이자 등)
      - reconciled: 전 구간에서 적요 분류 합이 kt00016 합계와 일치했는지
                    False면 새 적요가 등장해 분류가 어긋났다는 신호
      - available: 내역이 캐시에 있는지 (부트스트랩 전 or 조회 실패 시 False)
    """
    d = _load()
    if d is None:
        return {'rows': [], 'other_in': 0, 'other_out': 0,
                'reconciled': True, 'available': False}
    cur = d['current']['labels']
    rows: list[dict] = []
    reconciled = True
    available = False
    for lb in labels:
        recs = list((d['frozen'].get(lb) or {}).values())
        if lb in cur:
            recs.append(cur[lb])
        for y in recs:
            flows = y.get('flows')
            if flows is None:
                continue
            available = True
            if y.get('rec') is False:
                reconciled = False
            for f in flows:
                rows.append({'date': f['d'], 'io': f['io'], 'amount': f['amt'],
                             'rmrk': f['rk'], 'principal': f['pr'], 'label': lb})
    rows.sort(key=lambda r: (r['date'], r['label']), reverse=True)
    return {
        'rows': rows,
        'other_in': sum(r['amount'] for r in rows if r['io'] == 'IN' and not r['principal']),
        'other_out': sum(r['amount'] for r in rows if r['io'] == 'OUT' and not r['principal']),
        'reconciled': reconciled,
        'available': available,
    }


# ---- CLI ----

def _show() -> int:
    d = _load()
    if d is None:
        print(f'캐시 없음: {STATE_FILE}\n  → python3 scripts/investment_principal.py bootstrap')
        return 1
    from stock_portfolio_report import derive_owner

    print(f'갱신: {d.get("refreshed_at")}  (올해 창 {d["current"].get("year")})\n')
    lbs = list(d['current']['labels'].keys())
    bad = 0
    for lb in lbs:
        years = sorted((d['frozen'].get(lb) or {}).items())
        years.append((year_key(d['current']['year']), d['current']['labels'][lb]))
        print(f'===== {lb} =====')
        tin = tout = 0
        for yk, y in years:
            # 항등식: 순자산초 + 입금 − 출금 + 평가손익 = 순자산말
            err = y['nf'] + y['in'] - y['out'] + y['pl'] - y['nt']
            bad += 1 if err else 0
            tin += y['in']
            tout += y['out']
            q = f'  입고 {y["qin"]:,} 출고 {y["qout"]:,}' if (y['qin'] or y['qout']) else ''
            nf = len(y.get('flows') or []) if y.get('flows') is not None else None
            rc = {True: '분류OK', False: '분류불일치!', None: '내역없음'}[y.get('rec')]
            print(f'  {yk}: 입금 {y["in"]:>12,}  출금 {y["out"]:>11,}  '
                  f'순자산 {y["nf"]:>12,}→{y["nt"]:>12,}  손익 {y["pl"]:>12,}  '
                  f'항등식오차 {err:,}  내역 {nf if nf is not None else "-":>3}행 {rc}{q}')
        print(f'  >> 누적 입금 {tin:,} − 출금 {tout:,} = 순입금 {tin - tout:,}\n')

    print('항등식 검증:', '전 행 오차 0 ✅' if not bad else f'⚠️ 오차 있는 행 {bad}개')
    fl = get_flows(lbs)
    print(f'적요 분류 검증: {"전 구간 일치 ✅" if fl["reconciled"] else "⚠️ 불일치 구간 있음 — 새 적요 확인 필요"}'
          f'  (내역 {len(fl["rows"])}행, 원금무관 입금 {fl["other_in"]:,}원 / 출금 {fl["other_out"]:,}원)')

    # 라이브 교차검증 — 원금 대비 총수익 vs 실제 순자산
    print('\n===== 원금 대비 총수익 (라이브 교차검증) =====')
    owners: dict[str, list[str]] = {}
    for lb in lbs:
        owners.setdefault(derive_owner(lb), []).append(lb)
    for owner, olbs in owners.items():
        pr = get_principal(olbs)
        net = 0
        for lb in olbs:
            net += sum(p['evlt_amt'] for p in kc.get_positions(lb)) + kc.get_balance(lb)['d2_entra']
        p = pr['principal']
        ret = net - p
        warn = '  ⚠️ 주식 입출고 있음(원금 과소계상)' if pr['qty_flow'] else ''
        print(f'[{owner}] 투자원금 {p:,}원 (입금 {pr["cash_in"]:,} − 출금 {pr["cash_out"]:,}){warn}')
        print(f'   순자산 {net:,}원 → 총수익 {ret:+,}원 ({ret / p * 100:+.2f}%)' if p else '   원금 0')
    return 0 if not bad else 1


def main(argv: list[str]) -> int:
    cmd = argv[1] if len(argv) > 1 else 'show'
    if cmd == 'bootstrap':
        lbs = _labels()
        n = len(lbs) * (_this_year() - FLOOR_YEAR + 1)
        print(f'{len(lbs)}계좌 × {_this_year() - FLOOR_YEAR + 1}년 = {n}콜 (약 {n * PACE_SEC:.0f}초)...')
        bootstrap()
        print(f'완료 → {STATE_FILE}\n')
        return _show()
    if cmd == 'refresh':
        refresh_current(_labels())
        print(f'올해 창 갱신 완료 → {STATE_FILE}\n')
        return _show()
    if cmd == 'show':
        return _show()
    print(__doc__)
    return 2


if __name__ == '__main__':
    raise SystemExit(main(sys.argv))
