#!/usr/bin/env python3
"""골디 인박스 처리기 — agents/budget/inbox/incoming/ envelope 검증·디스패치.

처리 흐름:
  1. incoming/*.json 정렬 스캔
  2. envelope 검증 (필수 키, schema_version, topic 등록 여부)
  3. idempotency: state/inbox_state.json 의 processed 에 message_id 있으면 skip → processed/
  4. topic 핸들러 호출 (현재 securities_balance 만)
  5. 성공 → processed/, 실패 → failed/ + 골디 텔레그램 자가 알림
  6. state 갱신 (processed 누적, 최근 1000개 유지)

CLI:
  python3 inbox_handler.py [--dry-run]
    --dry-run  검증만 수행. 후잉 분개 webhook·파일 이동·state 저장은 하지 않음

monthly_settlement.py 에서도 import 해서 process_inbox() 호출.
"""
from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import sys
import traceback
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

KST = ZoneInfo('Asia/Seoul')
ROOT = Path('/Users/snowoyh/.openclaw')
WORKSPACE = ROOT / 'agents' / 'budget' / 'workspace'
INBOX_DIR = ROOT / 'agents' / 'budget' / 'inbox'
INCOMING_DIR = INBOX_DIR / 'incoming'
PROCESSED_DIR = INBOX_DIR / 'processed'
FAILED_DIR = INBOX_DIR / 'failed'
STATE_FILE = WORKSPACE / 'state' / 'inbox_state.json'
CONFIG_PATH = ROOT / 'openclaw.json'
CREDENTIALS = ROOT / 'credentials' / 'whooing.json'
BALANCE_SCRIPT = WORKSPACE / 'skills' / 'whooing-sync' / 'scripts' / 'whooing_balance.py'
TELEGRAM_ACCOUNT = 'budget'

KNOWN_TOPICS = {'securities_balance'}
SUPPORTED_SCHEMA = {1, 2}

SECURITIES_ASSET_NAME = '증권(효원)'   # v1(단일 소유자) 경로 및 self 자산명
SECURITIES_GAIN_NAME = '주식평가수익'
SECURITIES_LOSS_NAME = '주식평가손실'
# 소유자 → 후잉 자산명 (v2). 손익 계정(주식평가수익/손실)은 양쪽 공용.
OWNER_ASSET = {'self': '증권(효원)', 'gahee': '증권(가희)'}

RECONCILE_NOISE_FLOOR = 10_000      # |차액| < 1만원: 노이즈, 분개 skip
RECONCILE_HARD_CAP = 100_000_000    # |차액| > 1억원: 분개 거부 (안전 가드)
PAYLOAD_AMOUNT_CAP = 10_000_000_000  # 100억 초과 금액: payload 거부

PROCESSED_RETENTION_DAYS = 30  # processed/ 보관 기간
FAILED_BACKLOG_THRESHOLD = 5   # failed/ 적체 alert 임계값


class ValidationError(Exception):
    pass


def load_state() -> dict:
    if STATE_FILE.exists():
        try:
            return json.loads(STATE_FILE.read_text())
        except Exception:
            pass
    return {'processed': []}


def save_state(state: dict) -> None:
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))


_TELEGRAM_DRY_RUN = False


def send_telegram(text: str) -> bool:
    if _TELEGRAM_DRY_RUN:
        print(f'[telegram-dry-run] {text}', file=sys.stderr)
        return True
    try:
        cfg = json.loads(CONFIG_PATH.read_text())
        acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
        token = acct['botToken']
        chat_ids = acct.get('allowFrom') or []
    except Exception:
        return False
    if not chat_ids:
        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
        except Exception:
            ok = False
    return ok


def validate_envelope(env: dict) -> None:
    required = {'message_id', 'from', 'to', 'topic', 'created_at', 'schema_version', 'payload'}
    missing = required - set(env)
    if missing:
        raise ValidationError(f"envelope 키 누락: {sorted(missing)}")
    if env['to'] != 'budget':
        raise ValidationError(f"to != 'budget': {env['to']}")
    if env['topic'] not in KNOWN_TOPICS:
        raise ValidationError(f"미등록 topic: {env['topic']}")
    if env['schema_version'] not in SUPPORTED_SCHEMA:
        raise ValidationError(f"미지원 schema_version: {env['schema_version']}")
    if not isinstance(env['payload'], dict):
        raise ValidationError('payload 가 dict 가 아님')


def _validate_amount_block(block: dict, where: str) -> None:
    for key in ('deposit', 'eval_amount', 'total'):
        if key not in block:
            raise ValidationError(f'{where}.{key} 누락')
        v = block[key]
        if not isinstance(v, int) or v < 0:
            raise ValidationError(f'{where}.{key} 비정상: {v!r}')
        if v > PAYLOAD_AMOUNT_CAP:
            raise ValidationError(f'{where}.{key} 100억 초과 (가드): {v:,}')


def validate_securities_payload(payload: dict) -> None:
    for key in ('as_of', 'accounts', 'totals', 'owner_scope'):
        if key not in payload:
            raise ValidationError(f'payload 키 누락: {key}')

    as_of = payload['as_of']
    try:
        as_of_dt = datetime.strptime(as_of, '%Y-%m-%d').date()
    except Exception:
        raise ValidationError(f'as_of 형식 오류 (YYYY-MM-DD): {as_of}')
    if as_of_dt.day not in (1, 10, 20):
        raise ValidationError(f'as_of 는 매월 1·10·20일이어야 함: {as_of}')

    _validate_amount_block(payload['totals'], 'totals')

    if not isinstance(payload['accounts'], list) or not payload['accounts']:
        raise ValidationError('accounts 비어있음')
    sum_total = sum(int(a.get('total', 0)) for a in payload['accounts'])
    if sum_total != payload['totals']['total']:
        raise ValidationError(
            f'accounts 합계 불일치: {sum_total:,} vs totals.total={payload["totals"]["total"]:,}'
        )

    # v2: 소유자별 집계(by_owner) 검증. 미등록 소유자·구조 오류 차단.
    by_owner = payload.get('by_owner')
    if by_owner is not None:
        if not isinstance(by_owner, dict) or not by_owner:
            raise ValidationError('by_owner 비어있음')
        for owner, block in by_owner.items():
            if owner not in OWNER_ASSET:
                raise ValidationError(f'by_owner 미등록 소유자: {owner}')
            if not isinstance(block, dict):
                raise ValidationError(f'by_owner.{owner} 가 dict 가 아님')
            _validate_amount_block(block, f'by_owner.{owner}')


def fetch_whooing_balance(as_of: str | None = None) -> dict:
    cmd = ['python3', str(BALANCE_SCRIPT), '--json']
    if as_of:
        cmd += ['--as-of', as_of]
    p = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
    if p.returncode != 0:
        raise RuntimeError(f'whooing_balance.py 실패: {p.stderr.strip() or p.stdout.strip()}')
    return json.loads(p.stdout)


def get_whooing_balance_for(name: str, current: dict) -> int | None:
    for sec in current.get('sections', []) or []:
        items = (sec.get('groups', {}).get('자산', {}) or {}).get('items', []) or []
        for it in items:
            if it.get('name') == name:
                return int(it.get('money', 0))
    return None


def post_whooing(payload: dict, dry_run: bool = False) -> tuple[bool, str]:
    if dry_run:
        return True, 'dry-run'
    try:
        cred = json.loads(CREDENTIALS.read_text())
    except Exception as e:
        return False, f'credentials 읽기 실패: {e}'
    url = (cred.get('webhook_url') or '').strip()
    if not url:
        return False, 'webhook_url 비어있음'
    encoded = urllib.parse.urlencode(payload, quote_via=urllib.parse.quote).encode()
    req = urllib.request.Request(url, data=encoded, method='POST')
    req.add_header('Content-Type', 'application/x-www-form-urlencoded')
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            body = r.read().decode('utf-8', errors='replace')
            ok = (200 <= r.status < 300) and body.strip().lower().startswith('done')
            return ok, body
    except Exception as e:
        return False, str(e)


def _reconcile_asset(asset_name: str, owner: str, target_total: int,
                     as_of: str, message_id: str, current_balance: dict,
                     dry_run: bool = False) -> dict:
    """자산 하나를 목표총액으로 reconcile + 후잉 자동 분개. 반환 result dict.
    action ∈ {aligned, journaled, noise, skipped, rejected, journal_failed}.
    """
    current = get_whooing_balance_for(asset_name, current_balance)
    base = {
        'topic': 'securities_balance',
        'owner': owner,
        'asset_name': asset_name,
        'as_of': as_of,
        'target_total': target_total,
    }
    if current is None:
        return {
            **base,
            'action': 'skipped',
            'note': f'후잉 자산에 "{asset_name}" 항목 없음',
            'delta': None, 'before': None, 'after': None, 'journal_ok': None,
        }
    delta = target_total - current
    if delta == 0:
        return {**base, 'action': 'aligned', 'delta': 0,
                'before': current, 'after': current, 'journal_ok': True,
                'note': '차액 0 — 분개 불필요'}
    if abs(delta) < RECONCILE_NOISE_FLOOR:
        return {**base, 'action': 'noise', 'delta': delta,
                'before': current, 'after': current, 'journal_ok': True,
                'note': f'차액 {delta:+,}원 — 1만원 미만, 분개 skip'}
    if abs(delta) > RECONCILE_HARD_CAP:
        return {**base, 'action': 'rejected', 'delta': delta,
                'before': current, 'after': current, 'journal_ok': False,
                'note': f'차액 {delta:+,}원 — 1억원 초과, 분개 거부 (가드)'}

    entry_date = as_of.replace('-', '')
    ym = as_of[:7]
    if delta > 0:
        item = f'{ym} 평가차익'
        left, right, money = asset_name, SECURITIES_GAIN_NAME, delta
    else:
        item = f'{ym} 평가차손'
        left, right, money = SECURITIES_LOSS_NAME, asset_name, -delta

    journal = {
        'entry_date': entry_date,
        'item': item,
        'money': str(money),
        'left': left,
        'right': right,
        'memo': f'레이 inbox reconcile (msg={message_id[:8]})',
    }
    ok, body = post_whooing(journal, dry_run=dry_run)
    return {
        **base,
        'action': 'journaled' if ok else 'journal_failed',
        'delta': delta,
        'before': current,
        'after': current + delta if ok else current,
        'journal_ok': ok,
        'note': body[:200] if not dry_run else 'dry-run',
        'journal': journal,
    }


def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bool = False) -> list[dict]:
    """소유자별 reconcile. v2(by_owner)는 소유자마다, v1은 totals.total→증권(효원) 단건.
    반환: result dict 리스트.
    """
    payload = envelope['payload']
    as_of = payload['as_of']
    msg_id = envelope['message_id']
    by_owner = payload.get('by_owner')

    if by_owner:
        results = []
        for owner, block in sorted(by_owner.items()):  # 'gahee' < 'self' — 결정적 순서
            results.append(_reconcile_asset(
                OWNER_ASSET[owner], owner, int(block['total']),
                as_of, msg_id, current_balance, dry_run=dry_run,
            ))
        return results

    # v1 하위호환: 단일 소유자(self) → 증권(효원)
    return [_reconcile_asset(
        SECURITIES_ASSET_NAME, 'self', int(payload['totals']['total']),
        as_of, msg_id, current_balance, dry_run=dry_run,
    )]


def move_to(src: Path, dest_dir: Path, dry_run: bool = False) -> None:
    if dry_run:
        return
    dest_dir.mkdir(parents=True, exist_ok=True)
    target = dest_dir / src.name
    if target.exists():
        target = dest_dir / f'{src.stem}__{datetime.now(KST).strftime("%H%M%S")}{src.suffix}'
    shutil.move(str(src), str(target))


def gc_processed(retention_days: int = PROCESSED_RETENTION_DAYS, dry_run: bool = False) -> dict:
    """processed/ 에서 mtime 이 retention_days 초과한 *.json 삭제.
    반환: {removed: [filename, ...], kept: int}.
    """
    if not PROCESSED_DIR.exists():
        return {'removed': [], 'kept': 0}
    cutoff = datetime.now(KST).timestamp() - retention_days * 86400
    removed: list[str] = []
    kept = 0
    for fpath in PROCESSED_DIR.glob('*.json'):
        try:
            if fpath.stat().st_mtime < cutoff:
                if not dry_run:
                    fpath.unlink()
                removed.append(fpath.name)
            else:
                kept += 1
        except OSError:
            kept += 1
    return {'removed': removed, 'kept': kept}


def count_failed_backlog() -> int:
    """failed/ 의 *.json 개수. 적체 alert 판단용."""
    if not FAILED_DIR.exists():
        return 0
    return sum(1 for _ in FAILED_DIR.glob('*.json'))


def process_inbox(current_balance: dict | None = None, dry_run: bool = False) -> dict:
    """반환: {processed, failed, reconcile}. settlement 에서 호출 시 current_balance 주입.
    current_balance 가 None 이면 첫 securities_balance 처리 시 lazy fetch.
    dry_run=True 시 후잉 webhook + 텔레그램 자가 알림 모두 stdout 으로만 출력.
    """
    global _TELEGRAM_DRY_RUN
    _TELEGRAM_DRY_RUN = dry_run

    INCOMING_DIR.mkdir(parents=True, exist_ok=True)
    state = load_state()
    processed_set = set(state.get('processed', []))

    summary: dict = {
        'processed': [], 'failed': [], 'reconcile': {},
        'gc_removed': [], 'failed_backlog': 0,
    }

    # 매 호출마다 GC 한 번 + failed 적체 카운트 (incoming 비어있어도 수행)
    gc_result = gc_processed(dry_run=dry_run)
    summary['gc_removed'] = gc_result['removed']

    files = sorted(INCOMING_DIR.glob('*.json'))
    if not files:
        summary['failed_backlog'] = count_failed_backlog()
        return summary

    balance = current_balance

    for fpath in files:
        try:
            env = json.loads(fpath.read_text())
        except Exception as e:
            move_to(fpath, FAILED_DIR, dry_run=dry_run)
            summary['failed'].append({'file': fpath.name, 'reason': f'JSON 파싱 실패: {e}'})
            send_telegram(f'⚠️ 골디 inbox: JSON 파싱 실패\n{fpath.name}\n{e}')
            continue

        msg_id = env.get('message_id', '?')

        if msg_id in processed_set:
            move_to(fpath, PROCESSED_DIR, dry_run=dry_run)
            continue

        try:
            validate_envelope(env)
            topic = env['topic']

            if topic == 'securities_balance':
                validate_securities_payload(env['payload'])
                if balance is None:
                    balance = fetch_whooing_balance()
                results = handle_securities_balance(env, balance, dry_run=dry_run)
                summary['reconcile'].setdefault(topic, []).extend(results)

                # 소유자 하나라도 rejected/journal_failed 면 failed 로.
                # 재실행 시 이미 분개된 소유자는 fresh 조회로 delta≈0 aligned → 중복분개 없음.
                bad = [r for r in results if r['action'] in ('rejected', 'journal_failed')]
                if bad:
                    move_to(fpath, FAILED_DIR, dry_run=dry_run)
                    reason = '; '.join(f'{r["asset_name"]}: {r["note"]}' for r in bad)
                    summary['failed'].append({'file': fpath.name, 'reason': reason, 'message_id': msg_id})
                    send_telegram(f'⚠️ 골디 inbox: 분개 {"/".join(r["action"] for r in bad)}\n{fpath.name}\n{reason}')
                    continue

                # 분개 성공분은 후잉 잔액 in-memory 업데이트 (동일 run 내 정합성 위해)
                if balance is not None:
                    for r in results:
                        if r.get('journal_ok') and r['action'] == 'journaled':
                            for sec in balance.get('sections', []):
                                items = (sec.get('groups', {}).get('자산', {}) or {}).get('items', []) or []
                                for it in items:
                                    if it.get('name') == r['asset_name']:
                                        it['money'] = r['after']
            else:
                raise ValidationError(f'핸들러 없음: {topic}')

            move_to(fpath, PROCESSED_DIR, dry_run=dry_run)
            processed_set.add(msg_id)
            summary['processed'].append({'file': fpath.name, 'topic': topic, 'message_id': msg_id})

        except ValidationError as e:
            move_to(fpath, FAILED_DIR, dry_run=dry_run)
            summary['failed'].append({'file': fpath.name, 'reason': str(e), 'message_id': msg_id})
            send_telegram(f'⚠️ 골디 inbox: 검증 실패\n{fpath.name}\n{e}')
        except Exception as e:
            move_to(fpath, FAILED_DIR, dry_run=dry_run)
            tb = traceback.format_exc()[-500:]
            summary['failed'].append({
                'file': fpath.name, 'reason': f'{type(e).__name__}: {e}',
                'message_id': msg_id, 'traceback': tb,
            })
            send_telegram(f'⚠️ 골디 inbox: 처리 예외\n{fpath.name}\n{type(e).__name__}: {e}')

    if not dry_run and summary['processed']:
        new_processed = list(processed_set)[-1000:]
        state['processed'] = new_processed
        save_state(state)

    # 처리 후 failed 적체 재카운트 (이번 사이클의 신규 failed 포함)
    summary['failed_backlog'] = count_failed_backlog()
    return summary


def format_summary(summary: dict) -> str:
    """결산 메일 본문에 추가할 마크다운. reconcile 결과 + 실패 요약 + 적체 alert."""
    backlog = summary.get('failed_backlog', 0)
    has_alert = backlog >= FAILED_BACKLOG_THRESHOLD
    has_content = (
        summary['processed'] or summary['failed'] or summary.get('reconcile') or has_alert
    )
    if not has_content:
        return ''

    lines = ['## 인박스', '']

    for result in summary.get('reconcile', {}).get('securities_balance', []):
        action = result.get('action')
        before = result.get('before')
        after = result.get('after')
        delta = result.get('delta')
        label = result.get('asset_name', '증권(효원)')
        if action == 'aligned':
            lines.append(f'- **{label}:** {before:,}원 — 레이 ground truth 와 일치')
        elif action == 'journaled':
            sign = '평가차익' if (delta or 0) > 0 else '평가차손'
            lines.append(
                f'- **{label}:** {before:,}원 → {after:,}원 '
                f'({sign} **{abs(delta):,}원** 자동 분개)'
            )
        elif action == 'noise':
            lines.append(f'- **{label}:** 차액 {delta:+,}원 (1만원 미만, 분개 skip)')
        elif action == 'skipped':
            lines.append(f'- **{label}:** {result.get("note", "skip")}')
        elif action == 'rejected':
            lines.append(f'- **{label}:** ⚠️ 차액 {delta:+,}원 — 분개 거부 (안전 가드)')
        elif action == 'journal_failed':
            lines.append(f'- **{label}:** ⚠️ 분개 실패 — {result.get("note", "")[:120]}')

    if summary['failed']:
        lines.append('')
        lines.append('### 이번 사이클 실패')
        for f in summary['failed']:
            lines.append(f'- `{f["file"]}` — {f["reason"]}')

    if has_alert:
        lines.append('')
        lines.append(
            f'### ⚠️ failed/ 적체 {backlog}건 — 검토·수동 삭제 필요'
        )
        lines.append(f'- 위치: `agents/budget/inbox/failed/`')

    return '\n'.join(lines).rstrip() + '\n'


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument('--dry-run', action='store_true', help='검증만 수행, 분개 webhook·파일 이동·state 저장 안 함')
    args = ap.parse_args()

    summary = process_inbox(dry_run=args.dry_run)
    print(json.dumps(summary, ensure_ascii=False, indent=2))
    return 0 if not summary['failed'] else 1


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