#!/usr/bin/env python3
from __future__ import annotations

"""whooing-sync: iMessage 결제/입출금 알림 -> 후잉 웹훅 자동 전송.

전략:
1. confirmed=true 발신번호 메시지만 대상.
2. parsers.py로 구조화 시도. 카테고리 매칭(merchant_map) 성공 시 structured POST.
3. 파싱/매칭 실패 시 raw 폴백 (후잉 자체 파서에 위임).
4. dedupe는 created_at 기준.
"""
import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo

import parsers as sms_parsers
import imsg_cli
import notify
import whooing_balance
import gahee_reminder

# 후잉 웹훅은 HTTP 200을 반환하면서 본문에 "fail"/"Error : ..." 를 줄 수 있어 본문 검증 필수.
SUCCESS_BODY = "done"


def _infer_raw_details(text: str, parsed: dict | None = None) -> tuple[str, str]:
    """raw 폴백 알림용 금액/상호명 요약.

    SMS 원문 전체는 텔레그램 anti-spam 오탐 위험이 있어 보내지 않고,
    분류 확인에 필요한 금액과 상호명만 best-effort 로 추출한다.
    """
    if parsed:
        amount = parsed.get("amount")
        merchant = (parsed.get("merchant") or "").strip()
        amount_s = f"{int(amount):,}원" if amount else "금액불명"
        return amount_s, merchant or "상호명불명"

    amount_s = "금액불명"
    m = re.search(r"(\d[\d,]*)\s*원", text or "")
    if m:
        amount_s = f"{int(m.group(1).replace(',', '')):,}원"

    # 흔한 카드 승인 문자는 금액 다음 줄이 가맹점인 경우가 많다.
    lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
    merchant = "상호명불명"
    for i, line in enumerate(lines):
        if re.search(r"\d[\d,]*\s*원", line):
            for cand in lines[i + 1:i + 4]:
                if any(skip in cand for skip in ("누적", "잔액", "일시불", "승인", "취소")):
                    continue
                merchant = cand
                break
            break
    return amount_s, merchant


def _format_raw_fallback(new_raw: list) -> str:
    """raw 폴백 처리된 건들 → 텔레그램 메시지(HTML).

    SMS 원문은 의도적으로 제외. 결제 SMS 형식이 텔레그램 anti-spam smishing 필터에 오탐돼 봇 동결을 유발할 수 있음. 원문 디버깅은 iMessage DB / state 로그에서.
    """
    header = f"⚠️ <b>후잉 raw 폴백 {len(new_raw)}건</b>"
    blocks = []
    for r in new_raw[:5]:
        label = notify.escape_html(r["label"])
        when = notify.format_kst(r["created_at"])
        amount_s, merchant = _infer_raw_details(r.get("text", ""), r.get("parsed"))
        merchant = notify.escape_html(merchant)
        blocks.append(f"\n• <b>{label}</b> · {when} · <b>{amount_s}</b> · {merchant}")
    more = f"\n<i>외 {len(new_raw)-5}건 더 있음.</i>" if len(new_raw) > 5 else ""
    footer = "\n\n<i>structured 분류 실패 — parsers.py / carrier_to_account / merchant_map 점검 필요.</i>"
    return header + "".join(blocks) + more + footer


def _format_balance_mismatch(info: dict, sms_b: int, whoo_b: int, diff: int, prev_diff,
                             recovery_hint=None) -> str:
    """SMS 잔액(자산) 또는 누적사용액(부채/카드)과 후잉 잔액 차이 → 텔레그램 메시지(HTML)."""
    label = notify.escape_html(info["label"])
    acct = notify.escape_html(info["account"])
    side = info.get("side", "asset")
    kind_word = "누적사용액" if side == "liability" else "잔액"
    actual_whoo_b = info.get("actual_whooing_balance", whoo_b)
    payment_offset = info.get("liability_payment_offset", 0) or 0
    has_offset = bool(payment_offset and actual_whoo_b != whoo_b)
    matched_word = "후잉(카드대금 보정 후)" if has_offset else "후잉"

    if diff == 0:
        recovered = abs(prev_diff) if prev_diff is not None else 0
        lines = [
            f"✅ <b>{kind_word} 정합 회복</b>",
            "",
            f"📌 <b>{label}</b> ({acct})",
            f"• SMS · {matched_word}: <b>{sms_b:,}원</b> (일치)",
        ]
        if recovered:
            lines.append(f"• 불일치 해소: <b>{recovered:,}원</b>")
        if recovery_hint:
            lines.append("─────────────")
            lines.append(f"• 마지막 반영 거래: <b>{notify.escape_html(recovery_hint)}</b>")
        return "\n".join(lines)

    sign = "+" if diff > 0 else ""
    if side == "liability":
        direction = "결제 누락 의심" if diff > 0 else "사용 거래 누락 의심"
    else:
        direction = "출금 누락 또는 입금 중복" if diff > 0 else "입금 누락 의심"
    lines = [
        f"⚠️ <b>{kind_word} 불일치</b>",
        "",
        f"📌 <b>{label}</b> ({acct})",
        f"• SMS {kind_word}: <b>{sms_b:,}원</b>",
    ]
    if has_offset:
        lines.append(
            f"• 후잉 {kind_word}: <b>{actual_whoo_b:,}원</b>"
            f" + 카드대금 보정 <b>{payment_offset:,}원</b>"
            f" = <b>{whoo_b:,}원</b>"
        )
    else:
        lines.append(f"• 후잉 {kind_word}: <b>{whoo_b:,}원</b>")
    lines.append(f"• 차이: <b>{sign}{diff:,}원</b> → {direction}")
    if prev_diff is not None and prev_diff != diff:
        lines.append(f"• 직전 차이: {prev_diff:+,}원 (변동)")
    lines.append("")
    lines.append("<i>ℹ️ SMS 누락·잘못 분개·기간 외 거래 등 점검이 필요해요.</i>")
    return "\n".join(lines)


def _format_sync_failure(f: dict) -> str:
    """SMS 동기화 실패 1건 → 텔레그램 메시지(HTML)."""
    label = notify.escape_html(f["label"])
    when = notify.format_kst(f["created_at"])
    reason = notify.escape_html(notify.clean_reason(f.get("response", ""), f.get("status", 0)))
    payload = f.get("payload") or {}
    mode = f.get("mode")

    header = f"❌ <b>후잉 동기화 실패</b>\n└ {label} · {when}"

    if mode == "structured":
        item = notify.escape_html(payload.get("item", ""))
        money = payload.get("money", "")
        try:
            money_fmt = f"{int(money):,}원"
        except Exception:
            money_fmt = f"{money}원"
        left = notify.escape_html(payload.get("left", ""))
        right = notify.escape_html(payload.get("right", ""))
        body_block = (
            f"\n\n💳 <b>{left}</b> ← <b>{right}</b>"
            f"\n   {money_fmt} · {item}"
        )
    else:
        body_block = "\n\n📩 <i>raw 폴백 — SMS 원문은 텔레그램 비표시 (anti-spam 회피)</i>"

    reason_block = f"\n\n⚠️ <b>사유</b>\n{reason}"
    footer = "\n\n<i>다음 cron 까지 대기 중. 동일 실패 반복되면 failures.json 확인.</i>"
    return header + body_block + reason_block + footer

KST = ZoneInfo("Asia/Seoul")
ROOT = Path("/Users/snowoyh/.openclaw")
WORKSPACE = ROOT / "agents" / "budget" / "workspace"
CREDENTIALS = ROOT / "credentials" / "whooing.json"
ACCOUNT_MAP_FILE = WORKSPACE / "state" / "whooing_account_map.json"
ACCOUNTS_FILE = WORKSPACE / "state" / "whooing_accounts.json"
MERCHANT_MAP_FILE = WORKSPACE / "state" / "whooing_merchant_map.json"
OVERRIDES_FILE = WORKSPACE / "state" / "whooing_overrides.json"
SYNC_STATE_FILE = WORKSPACE / "state" / "whooing_synced.json"
FAILURES_FILE = WORKSPACE / "state" / "whooing_failures.json"
BALANCE_ALERT_FILE = WORKSPACE / "state" / "whooing_balance_alerts.json"
CHAT_IDS_CACHE = WORKSPACE / "state" / "whooing_chat_ids.json"
# 한국 공휴일은 stock agent의 KRX 휴장일 파일을 그대로 재사용 (read-only).
# 파일 부재/오류 시 토·일만 비영업일로 보는 fallback 동작.
HOLIDAYS_FILE = ROOT / "agents" / "stock" / "workspace" / "state" / "market_holidays.json"
IMSG_TIMEOUT = 15
HISTORY_LIMIT = 100

# 본인 계좌 간 이체 pair 매칭 (출금 SMS + 입금 SMS 같은 금액, 시간창 내).
# 창 안에 짝이 오면 1건의 자산↔자산 이체로 합성 POST.
PAIR_WINDOW_SECONDS = 300
# 페어 미발견 입출금은 이 나이까지는 hold (아직 상대편 SMS 도착 전일 수 있음).
# 이 시간이 지났는데도 짝이 없으면 개별 SMS로 처리 (외부 송금/입금으로 간주).
HOLD_GRACE_SECONDS = 300

# 결제/입출금이 아닌 메시지(광고·공지·이벤트)를 후잉으로 보내지 않기 위한 패턴.
SKIP_PATTERNS = (
    "(광고)",
    "[광고]",
    "광고)",
    "이벤트",
    "안내드립니다",
    "수신거부",
    "인증번호",
    "간편인증서",
    "개설되었습니다",
    "계좌개설",
)

# 후잉 처리 대상은 금액이 있는 결제/입금/출금 알림으로 제한한다.
# 확인/인증/계좌개설 안내처럼 confirmed 발신번호에서 온 비거래 문자가 raw 로 들어가는 것을 막는다.
# 후잉 처리 대상 금액 패턴. 신한은행 SMS는 "출금 20"처럼 "원" 없이 오는 경우가 있다.
MONEY_AMOUNT_RE = re.compile(r"(?:\d[\d,]*\s*원|(?:입금|출금)\s+\d[\d,]*)")

# 주유소 카드 결제는 보통 150,000원 선승인 → 실제 주유금액 승인 → 150,000원 승인취소 순서로 온다.
# 선승인/취소는 후잉에 기록하지 않고, 실제 결제만 차량유지비/주유비로 남긴다.
# "현대가스"·"한경에너"는 상호에 주유/석유가 안 들어가는 충전소라 직접 등록 (2026-08-03 관리자님 확인).
# 새 주유소를 만나면 여기와 whooing_merchant_map.json contains **양쪽에** 상호를 추가한다 —
# 이 상수는 선승인/취소 스킵, merchant_map 은 실제 주유 결제 분류로 한 쌍이다.
# ⚠️ 한경에너는 선승인이 149,900원이라 FUEL_PREAUTH_AMOUNT 정확일치엔 안 걸린다(분류만 적용).
FUEL_KEYWORDS = ("석유", "주유소", "주유", "현대가스", "한경에너")
FUEL_PREAUTH_AMOUNT = 150_000


def _is_fuel_text(text: str) -> bool:
    return any(k in (text or "") for k in FUEL_KEYWORDS)


def _should_skip_fuel_preauth(parsed: dict | None, text: str) -> bool:
    """주유소 15만원 선승인/취소 스킵.

    parsed 가 있으면 구조화 필드 기준으로 판단한다. 파서가 못 읽은 형식은 폴백으로
    raw 텍스트에서 '취소' + '150,000원' + 주유 키워드 조합만 보수적으로 스킵한다.
    """
    if parsed:
        merchant = parsed.get("merchant") or ""
        if (
            parsed.get("kind") in ("card_approval", "card_cancel")
            and parsed.get("amount") == FUEL_PREAUTH_AMOUNT
            and _is_fuel_text(merchant)
        ):
            return True
    compact = (text or "").replace(" ", "")
    return _is_fuel_text(text) and "취소" in text and "150,000원" in compact


def _should_skip_hyundai_insurance_duplicate(c: dict, balance_alerts: dict) -> bool:
    """이미 월 1회 보정된 현대카드 라이나생명 31,100원 지연 SMS는 중복 raw 전송하지 않는다."""
    info = c.get("info") or {}
    if info.get("carrier") != "hyundai_card":
        return False
    text = c.get("text") or ""
    compact = text.replace(" ", "")
    if not ("라이나생명" in text and "31,100원" in compact):
        return False
    state = (balance_alerts.get("carriers") or {}).get("hyundai_card") or {}
    try:
        msg_month = _parse_iso_utc(c.get("created_at") or "").astimezone(KST).strftime("%Y-%m")
    except Exception:
        msg_month = datetime.now(KST).strftime("%Y-%m")
    return state.get("last_hyundai_insurance_month") == msg_month


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


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


def load_webhook_url():
    cred = load_json(CREDENTIALS, {})
    url = (cred.get("webhook_url") or "").strip()
    if not url:
        print("❌ credentials/whooing.json 의 webhook_url 이 비어 있습니다.", file=sys.stderr)
        sys.exit(2)
    return url


def resolve_chat_ids(carriers):
    cached = load_json(CHAT_IDS_CACHE, {})
    cached_keys = set(cached.keys())
    needed = set(carriers.keys())
    if needed.issubset(cached_keys):
        return {k: cached[k] for k in needed}
    chats, err = imsg_cli.chats(IMSG_TIMEOUT)
    if err:
        print(f"❌ imsg chats 실행 실패: {err}", file=sys.stderr)
        sys.exit(3)
    result = {}
    for sender in carriers.keys():
        ids = [c["id"] for c in chats if c.get("identifier") == sender and c.get("id") is not None]
        result[sender] = ids
    # 전 carrier 가 빈 리스트면 조회 자체가 실패한 것으로 본다. 이 상태를 캐시에 저장하면
    # 이후 모든 실행(launchd 포함)이 캐시에 적중해 조용히 0건 수집을 영구 반복한다.
    # 일부만 비는 건 정상 — 메시지 이력이 없는 carrier 가 있을 수 있다.
    if not any(result.values()):
        print("❌ imsg chats 결과에 매핑된 발신번호가 하나도 없습니다 — 캐시 저장 안 함", file=sys.stderr)
        sys.exit(3)
    save_json(CHAT_IDS_CACHE, result)
    return result


def fetch_messages(chat_id, since):
    args = ["history", "--chat-id", str(chat_id), "--limit", str(HISTORY_LIMIT), "--json"]
    if since:
        args += ["--start", since]
    rows, err = imsg_cli.run_json(args, IMSG_TIMEOUT)
    if err:
        print(f"⚠️  chat-id {chat_id} history 실패: {err}", file=sys.stderr)
        return []
    return rows


# --- 룰 엔진 (whooing_overrides.json) -----------------------------------------
# build_structured 보다 먼저 평가되는 우선 룰. 정기결제·시간대 기반 분개 등.
# 매처 종류는 _rule_matches() 참고. 새 매처 추가 시 거기 케이스만 늘리면 된다.

_HOLIDAYS_CACHE: "set[str] | None" = None


def _load_holidays() -> "set[str]":
    """KRX 휴장일 set (YYYY-MM-DD). 파일 부재/오류 시 빈 set."""
    global _HOLIDAYS_CACHE
    if _HOLIDAYS_CACHE is None:
        try:
            with open(HOLIDAYS_FILE, encoding="utf-8") as f:
                _HOLIDAYS_CACHE = set(json.load(f).get("holidays", {}).keys())
        except Exception:
            _HOLIDAYS_CACHE = set()
    return _HOLIDAYS_CACHE


def _is_non_business_day(d) -> bool:
    """토·일·공휴일 → True."""
    return d.weekday() >= 5 or d.isoformat() in _load_holidays()


def _matches_scheduled_day(dt_kst: datetime, day: int, policy: str) -> bool:
    """결제일 매처. policy:
      - "exact": 그 달의 day 와 거래일이 같아야 매칭.
      - "next_weekday": day 가 영업일이면 그 날만, 비영업일(토·일·공휴일)이면 [원래일, 다음 영업일] 윈도우 모두 매칭.
    """
    try:
        target = dt_kst.replace(day=day, hour=0, minute=0, second=0, microsecond=0)
    except ValueError:
        return False  # 그 달에 그 일이 없는 경우 (예: 2/30)
    if policy == "exact":
        return dt_kst.date() == target.date()
    if policy == "next_weekday":
        if not _is_non_business_day(target.date()):
            return dt_kst.date() == target.date()
        rolled = target
        while _is_non_business_day(rolled.date()):
            rolled = rolled + timedelta(days=1)
        return target.date() <= dt_kst.date() <= rolled.date()
    return False


def _in_time_window_kst(dt_kst: datetime, start: str, end: str) -> bool:
    """start/end: "HH:MM" KST. 양 끝 포함."""
    def to_min(s):
        h, m = s.split(":")
        return int(h) * 60 + int(m)
    cur = dt_kst.hour * 60 + dt_kst.minute
    return to_min(start) <= cur <= to_min(end)


def _rule_matches(match: dict, parsed: dict, carrier: str, merchant: str,
                  merchant_map: dict, dt_kst: datetime) -> bool:
    if "kind" in match and parsed.get("kind") != match["kind"]:
        return False
    if "merchant_contains" in match and match["merchant_contains"] not in merchant:
        return False
    if "merchant_regex" in match and not re.match(match["merchant_regex"], merchant):
        return False
    if match.get("merchant_unmapped"):
        rule_hit, cat_hit = lookup_category(merchant, merchant_map)
        if rule_hit or cat_hit:
            return False
    if "amount_eq" in match and int(parsed.get("amount") or 0) != int(match["amount_eq"]):
        return False
    if "carrier_in" in match and carrier not in match["carrier_in"]:
        return False
    if "weekday_in" in match and dt_kst.weekday() not in match["weekday_in"]:
        return False
    if "scheduled_day_of_month" in match:
        if not _matches_scheduled_day(
            dt_kst, int(match["scheduled_day_of_month"]),
            match.get("weekend_policy", "exact"),
        ):
            return False
    if "time_kst_between" in match:
        s, e = match["time_kst_between"]
        if not _in_time_window_kst(dt_kst, s, e):
            return False
    return True


def _render_post(post: dict, parsed: dict, ctx: dict):
    """post 의 left/right/item/memo 문자열에 ctx 변수 치환. left 비면 None 반환 (룰 무효).

    입금(deposit) 룰에서는 post.left 를 수익/상대 계정으로 해석해
    실제 후잉 분개는 carrier 자산 ← post.left 형태로 만든다.
    """
    def fmt(v):
        if not isinstance(v, str):
            return v
        for k, val in ctx.items():
            v = v.replace("{" + k + "}", str(val))
        return v

    left = fmt(post.get("left"))
    if not left:
        return None
    item = fmt(post.get("item")) or ctx["merchant"]
    memo = fmt(post.get("memo"))
    if memo is None:
        memo = parsed.get("raw", "")
    if parsed.get("kind") == "deposit":
        right = left
        left = fmt(post.get("right")) or ctx["carrier_account"]
    else:
        right = fmt(post.get("right")) or ctx["carrier_account"]
    return {
        "entry_date": parsed["entry_date"],
        "money": parsed["amount"],
        "item": item,
        "left": left,
        "right": right,
        "memo": (memo or "")[:200],
    }


def apply_overrides(rules: list, parsed: dict, sender_info: dict,
                    accounts: dict, merchant_map: dict, created_at: str):
    """whooing_overrides.json rules 를 위에서 아래로 평가, 첫 매칭 룰의 post 를 후잉 structured payload 로 변환.

    매칭 실패 / parsed 없음 / carrier_to_account 미등록 / left 미지정 → None (build_structured 폴백).
    """
    if not parsed or not rules:
        return None
    carrier = sender_info.get("carrier")
    carrier_acct = accounts.get("carrier_to_account", {}).get(carrier, "")
    if not carrier_acct:
        return None
    try:
        dt_kst = datetime.fromisoformat(created_at.replace("Z", "+00:00")).astimezone(KST)
    except Exception:
        return None

    merchant = (parsed.get("merchant") or "").strip()
    ctx = {
        "carrier_account": carrier_acct,
        "merchant": merchant,
        "raw": parsed.get("raw", ""),
        "amount": str(parsed.get("amount", 0)),
    }
    for rule in rules:
        if not rule.get("enabled", True):
            continue
        if not _rule_matches(rule.get("match", {}), parsed, carrier, merchant, merchant_map, dt_kst):
            continue
        return _render_post(rule.get("post", {}), parsed, ctx)
    return None


def lookup_category(merchant: str, merchant_map: dict):
    """가맹점/메모 → (rule|None, contains_category|None).
    rule은 exact 매칭(dict), contains_category는 키워드 매칭(str).
    contains 값이 dict 인 경우엔 exact rule 처럼 (dict, None) 으로 반환한다."""
    rule = merchant_map.get("exact", {}).get(merchant)
    if rule:
        return rule, None
    for keyword, value in (merchant_map.get("contains") or {}).items():
        if keyword in merchant:
            if isinstance(value, dict):
                return value, None
            return None, value
    return None, None


def build_structured(parsed, sender_info, accounts, merchant_map):
    """파싱 결과 + 매핑 → 후잉 structured payload. 매칭 실패 시 None (raw 폴백)."""
    carrier_key = sender_info.get("carrier")
    carrier_acct = accounts.get("carrier_to_account", {}).get(carrier_key, "")
    if not carrier_acct:
        return None

    merchant = parsed["merchant"]
    rule, cat = lookup_category(merchant, merchant_map)

    base = {
        "entry_date": parsed["entry_date"],
        "money": parsed["amount"],
        "item": merchant,
        "memo": parsed["raw"][:200],
    }

    kind = parsed["kind"]

    if kind == "card_approval":
        if rule:
            left = rule.get("left")
            right = rule.get("right") or carrier_acct
            if left:
                out = {**base, "left": left, "right": right}
                if rule.get("item"):
                    out["item"] = rule["item"]
                return out
            return None
        if cat:
            return {**base, "left": cat, "right": carrier_acct}
        return {**base, "left": "기타비용", "right": carrier_acct}

    # card_cancel 은 여기서 payload 를 만들지 않는다 — 처리 루프의 _handle_card_cancel 이
    # 원본 승인 거래를 찾아 PUT 으로 감액한다. 좌우를 뒤집은 역분개는 후잉이 거부한다
    # (비용 계정은 차변 전용 → `r_account 잘못된 값입니다`) — 2026-08-03 회귀의 원인.

    if kind == "withdrawal":
        if rule:
            left = rule.get("left")
            right = rule.get("right") or carrier_acct
            if left:
                out = {**base, "left": left, "right": right}
                if rule.get("item"):
                    out["item"] = rule["item"]
                return out
            return None
        if cat:
            return {**base, "left": cat, "right": carrier_acct}
        return {**base, "left": "기타비용", "right": carrier_acct}

    if kind == "deposit":
        if rule:
            # 입금 시 left=carrier자산, right=수익/송금처
            counterpart = rule.get("left")
            if counterpart:
                out = {**base, "left": carrier_acct, "right": counterpart}
                if rule.get("item"):
                    out["item"] = rule["item"]
                return out
            return None
        return None  # 자동 입금 분류는 안전하게 raw 폴백

    return None


def _handle_card_cancel(parsed, sender_info, accounts, dry_run=False):
    """카드 취소 → 원본 승인 거래를 찾아 취소액만큼 감액.

    반환 (ok, detail). ok=False 면 원본을 특정하지 못한 것이니 호출측이 raw 폴백으로 넘긴다.
    ⚠️ 폴백은 자동 처리가 아니다 — 후잉은 취소 문자를 받아도 원본을 지워주지 않으므로
    (공식 FAQ 도 환불은 수동 처리하라고 안내한다) 관리자님이 후잉 UI 에서 손봐야 한다.
    그래서 폴백 건은 raw 폴백 알림에 그대로 실어 보낸다.

    전액취소면 금액이 0 이 되어 줄은 남는다. 후잉 API 로는 거래를 지울 수 없기 때문이다
    (whooing_balance.api_request 주석 참고). 잔액·합계에는 영향이 없다.
    """
    carrier_acct = accounts.get("carrier_to_account", {}).get(sender_info.get("carrier"), "")
    if not carrier_acct:
        return False, "카드 계정 매핑 없음"

    money = parsed["amount"]
    cands = whooing_balance.find_card_entries(
        carrier_acct, money, parsed["merchant"], parsed["entry_date"]
    )
    if len(cands) != 1:
        return False, f"원본 승인 후보 {len(cands)}건 — 1건일 때만 감액"

    orig = cands[0]
    new_money = (orig.get("money") or 0) - money
    detail = (f"{orig.get('item')} {orig.get('money'):,}원 → {new_money:,}원 "
              f"(취소 {money:,}원, entry {orig['entry_id']})")
    if dry_run:
        return True, f"(dry-run) {detail}"
    whooing_balance.set_entry_money(orig, new_money)
    return True, detail


def _parse_iso_utc(iso: str) -> datetime:
    return datetime.fromisoformat(iso.replace("Z", "+00:00"))


def detect_pairs(candidates, accounts):
    """자기 계좌 간 이체 pair 를 찾아 structured 이체 payload 로 변환.

    기준:
      - 한쪽은 withdrawal, 다른 쪽은 deposit
      - 금액 동일
      - created_at 차이 < PAIR_WINDOW_SECONDS
      - 양쪽 모두 carrier_to_account 매핑이 있어야 함 (자산 계정명 확보)

    반환:
      pairs:    [(c_withdraw, c_deposit, payload), ...]
      used_idx: set — candidates 내 인덱스 중 pair 로 소비된 것
    """
    pairs = []
    used = set()
    ctoa = accounts.get("carrier_to_account", {})

    for i, ci in enumerate(candidates):
        if i in used or not ci.get("parsed"):
            continue
        pi = ci["parsed"]
        if pi["kind"] not in ("withdrawal", "deposit"):
            continue
        opposite = "deposit" if pi["kind"] == "withdrawal" else "withdrawal"
        dti = _parse_iso_utc(ci["created_at"])

        for j, cj in enumerate(candidates):
            if j == i or j in used or not cj.get("parsed"):
                continue
            pj = cj["parsed"]
            if pj["kind"] != opposite or pj["amount"] != pi["amount"]:
                continue
            dtj = _parse_iso_utc(cj["created_at"])
            if abs((dti - dtj).total_seconds()) > PAIR_WINDOW_SECONDS:
                continue

            # pair 확정. 송금/수신 구분.
            c_w, c_d = (ci, cj) if pi["kind"] == "withdrawal" else (cj, ci)
            acct_w = ctoa.get(c_w["info"]["carrier"])
            acct_d = ctoa.get(c_d["info"]["carrier"])
            if not acct_w or not acct_d:
                break  # 자산 계정명 중 하나라도 비면 이체 구성 불가 → 개별 처리로 폴백
            if acct_w == acct_d:
                break  # 같은 계좌끼리 pair 는 의미 없음

            balance_parts = []
            if c_w["parsed"].get("balance") is not None:
                balance_parts.append(f"{c_w['info']['label']} {c_w['parsed']['balance']:,}원")
            if c_d["parsed"].get("balance") is not None:
                balance_parts.append(f"{c_d['info']['label']} {c_d['parsed']['balance']:,}원")
            payload = {
                "entry_date": c_w["parsed"]["entry_date"],
                "money": c_w["parsed"]["amount"],
                "item": "이체",
                "left": acct_d,   # 수신 자산 증가 (차변)
                "right": acct_w,  # 송신 자산 감소 (대변)
                "memo": ("잔액 : " + " / ".join(balance_parts)) if balance_parts else "",
            }
            pairs.append((c_w, c_d, payload))
            used.add(i)
            used.add(j)
            break
    return pairs, used


def post_to_whooing(webhook_url, payload, dry_run=False):
    """payload는 dict. 반환: (ok: bool, status: int, body: str).
    HTTP 2xx + body가 'done' 으로 시작해야 성공. 그 외(HTTP 200 + 'fail' 포함) 모두 실패."""
    if dry_run:
        return True, 200, "dry-run"
    # structured 모드는 memo 비우면 후잉이 거절. 비면 공백 1개로 정규화.
    if "memo" in payload and not str(payload.get("memo", "")).strip():
        payload = {**payload, "memo": " "}
    # 후잉은 '+' 를 공백으로 디코드하지 않음. quote_via=quote 로 공백을 %20 으로 보내야 함.
    encoded = urllib.parse.urlencode(payload, quote_via=urllib.parse.quote).encode()
    req = urllib.request.Request(webhook_url, data=encoded, method="POST")
    # 후잉 파서는 '; charset=utf-8' 가 붙으면 거절. 순수 미디어타입만 보냄.
    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(SUCCESS_BODY)
            return ok, r.status, body
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else str(e)
        return False, e.code, body
    except Exception as e:
        return False, 0, str(e)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dry-run", action="store_true", help="후잉 POST 안 함, 계획만 출력")
    args = ap.parse_args()

    webhook_url = load_webhook_url() if not args.dry_run else "(dry-run)"
    account_map = load_json(ACCOUNT_MAP_FILE, {}).get("carriers", {})
    accounts = load_json(ACCOUNTS_FILE, {})
    merchant_map = load_json(MERCHANT_MAP_FILE, {})
    overrides = load_json(OVERRIDES_FILE, {"rules": []}).get("rules", [])
    sync_state = load_json(SYNC_STATE_FILE, {"last_message_at": None})
    balance_alerts = load_json(BALANCE_ALERT_FILE, {"carriers": {}})
    failures = load_json(FAILURES_FILE, {"failures": []})
    failures.setdefault("failures", [])

    def run_gahee_reminder():
        # 가희 잔액 리마인더는 결제 SMS 유무와 독립적으로 매 sync 사이클마다 확인한다.
        gahee_reminder.run(webhook_url, post_to_whooing, dry_run=args.dry_run)

    confirmed = {k: v for k, v in account_map.items() if v.get("confirmed")}
    if not confirmed:
        print("🟡 confirmed=true 인 발신번호가 없습니다.")
        run_gahee_reminder()
        return

    chat_ids_by_sender = resolve_chat_ids(confirmed)
    last_message_at = sync_state.get("last_message_at") or ""
    latest_skip = ""

    candidates = []
    for sender, info in confirmed.items():
        ids = chat_ids_by_sender.get(sender) or []
        if not ids:
            print(f"🟡 {info['label']}({sender}) 대화방 없음")
            continue
        for chat_id in ids:
            for msg in fetch_messages(chat_id, last_message_at):
                text = (msg.get("text") or "").strip()
                created = msg.get("created_at") or ""
                if not text or not created:
                    continue
                if last_message_at and created <= last_message_at:
                    continue
                if any(p in text for p in SKIP_PATTERNS) or not MONEY_AMOUNT_RE.search(text):
                    print(f"  ⊘ [skip] {info['label']} {created} | {text[:50].replace(chr(10),' ')}")
                    if created > (last_message_at or ""):
                        latest_skip = created
                    continue
                candidates.append({
                    "sender": sender, "info": info,
                    "created_at": created, "text": text,
                })

    candidates.sort(key=lambda x: x["created_at"])

    if not candidates:
        # 광고만 있고 결제는 없을 수도 있으니, latest_skip을 last_message_at으로 반영
        if latest_skip and latest_skip > last_message_at and not args.dry_run:
            sync_state["last_message_at"] = latest_skip
            sync_state["last_synced_at"] = datetime.now(KST).isoformat()
            save_json(SYNC_STATE_FILE, sync_state)
        print(f"🟢 새 결제 메시지 없음 (last={latest_skip or last_message_at or '없음'})")
        run_gahee_reminder()
        return

    # 모든 후보를 먼저 파싱해서 pair 탐지에 쓴다. (기존엔 루프 안에서 lazy parse)
    for c in candidates:
        c["parsed"] = sms_parsers.parse(c["sender"], c["text"], c["created_at"])

    # 주유소 150,000원 선승인/취소와 이미 월 보정된 현대카드 보험료 지연 SMS는 후잉 기록 대상이 아니다.
    # pair 탐지/개별 처리 전에 후보에서 제거하고 커서는 진행한다.
    filtered_candidates = []
    for c in candidates:
        if _should_skip_fuel_preauth(c.get("parsed"), c.get("text", "")):
            print(f"  ⊘ [skip-fuel-preauth] {c['info']['label']} {c['created_at']} | {c['text'][:50].replace(chr(10),' ')}")
            if c["created_at"] > (latest_skip or ""):
                latest_skip = c["created_at"]
            continue
        if _should_skip_hyundai_insurance_duplicate(c, balance_alerts):
            print(f"  ⊘ [skip-hyundai-insurance-duplicate] {c['info']['label']} {c['created_at']} | 라이나생명 31,100원 이미 월 보정됨")
            if c["created_at"] > (latest_skip or ""):
                latest_skip = c["created_at"]
            continue
        filtered_candidates.append(c)
    candidates = filtered_candidates

    if not candidates:
        if latest_skip and latest_skip > last_message_at and not args.dry_run:
            sync_state["last_message_at"] = latest_skip
            sync_state["last_synced_at"] = datetime.now(KST).isoformat()
            save_json(SYNC_STATE_FILE, sync_state)
        print(f"🟢 새 결제 메시지 없음 (last={latest_skip or last_message_at or '없음'})")
        run_gahee_reminder()
        return

    pairs, used_idx = detect_pairs(candidates, accounts)
    now_utc = datetime.now(timezone.utc)

    sent_transfer = 0
    sent_structured = 0
    sent_raw = 0
    sent_cancel = 0
    failed = 0
    new_failures = []
    new_raw = []  # raw 폴백으로 성공 처리된 건 (구조화 실패 신호) — 텔레그램 알림용
    recovery_hints: dict[str, str] = {}
    liability_accounts = set(accounts.get("categories", {}).get("부채", []))
    liability_payment_offsets_to_add: dict[str, int] = {}
    latest = last_message_at
    blocked_at = None  # 첫 실패/hold 의 created_at — 이후 메시지는 처리/커서 진행 모두 중단

    # 1) 페어 이체부터 처리 (한 건의 자산↔자산 이체 엔트리로 POST)
    for c_w, c_d, payload in pairs:
        if blocked_at:
            break
        payload_str = {k: str(v) for k, v in payload.items()}
        display = f"{payload['left']} ← {payload['right']} {payload['money']:,}원 ({payload['item']})"
        ok, status, body = post_to_whooing(webhook_url, payload_str, dry_run=args.dry_run)
        pair_latest = max(c_w["created_at"], c_d["created_at"])
        if ok:
            sent_transfer += 1
            for carrier_key in (c_w["info"].get("carrier"), c_d["info"].get("carrier")):
                if carrier_key:
                    recovery_hints[carrier_key] = display
            if pair_latest > (latest or ""):
                latest = pair_latest
            print(f"  🔄 [transfer] {c_w['info']['label']}→{c_d['info']['label']} {pair_latest} {status} | {display}")
        else:
            failed += 1
            failure_record = {
                "sender": f"{c_w['sender']}+{c_d['sender']}",
                "label": f"{c_w['info']['label']}→{c_d['info']['label']}",
                "created_at": pair_latest,
                "text": payload.get("memo", ""),
                "mode": "structured", "payload": payload_str,
                "status": status, "response": body[:500],
                "failed_at": datetime.now(KST).isoformat(),
            }
            new_failures.append(failure_record)
            print(f"  ❌ [transfer] {c_w['info']['label']}→{c_d['info']['label']} {pair_latest} {status} body={body[:120]!r}")
            # 쌍 중 이른 시각 이후로 커서 잠금 (재시도 시 둘 다 다시 걸리게)
            blocked_at = min(c_w["created_at"], c_d["created_at"])

    # 2) 페어에 소비되지 않은 개별 SMS 처리 (단, 최근 HOLD_GRACE 이내 입출금은 hold)
    for idx, c in enumerate(candidates):
        if idx in used_idx:
            continue
        if blocked_at:
            break
        parsed = c.get("parsed")

        # hold: 아직 상대편 SMS 가 도착할 시간이 남아 있으면 이번 사이클 보류
        if parsed and parsed.get("kind") in ("withdrawal", "deposit"):
            age = (now_utc - _parse_iso_utc(c["created_at"])).total_seconds()
            if age < HOLD_GRACE_SECONDS:
                print(f"  ⏸ [hold] {c['info']['label']} {c['created_at']} — 상대편 pair 대기 (age {int(age)}s)")
                blocked_at = c["created_at"]
                break

        # 카드 취소는 상쇄 줄을 더하지 않고 원본 승인 거래를 감액한다.
        # 원본을 못 찾으면 아래 raw 폴백으로 흘러간다(build_structured 가 None 을 준다).
        if parsed and parsed.get("kind") == "card_cancel":
            try:
                cancel_ok, cancel_detail = _handle_card_cancel(
                    parsed, c["info"], accounts, args.dry_run
                )
            except Exception as e:
                failed += 1
                detail = f"{type(e).__name__}: {e}"
                new_failures.append({
                    "sender": c["sender"], "label": c["info"]["label"],
                    "created_at": c["created_at"], "text": c["text"],
                    "mode": "cancel_reduce", "payload": {},
                    "status": 0, "response": detail[:500],
                    "failed_at": datetime.now(KST).isoformat(),
                })
                print(f"  ❌ [cancel] {c['info']['label']} {c['created_at']} | {detail[:120]}")
                blocked_at = c["created_at"]
                break
            if cancel_ok:
                sent_cancel += 1
                carrier_key = c["info"].get("carrier")
                if carrier_key:
                    recovery_hints[carrier_key] = cancel_detail
                print(f"  ➖ [cancel] {c['info']['label']} {c['created_at']} | {cancel_detail}")
                if c["created_at"] > (latest or ""):
                    latest = c["created_at"]
                continue
            print(f"  ↩︎ [cancel→raw] {c['info']['label']} {c['created_at']} | {cancel_detail}")

        # card_cancel 이 여기까지 오면 감액 실패 → raw 폴백만 남았다.
        # apply_overrides 는 match 에 kind 가 없는 룰을 모든 kind 에 적용하므로, 그런 룰이
        # 추가되면 취소가 좌우 뒤집힌 payload 로 새어나갈 수 있어 여기서 끊는다.
        structured = None
        if parsed and parsed.get("kind") != "card_cancel":
            structured = apply_overrides(overrides, parsed, c["info"], accounts, merchant_map, c["created_at"])
            if structured is None:
                structured = build_structured(parsed, c["info"], accounts, merchant_map)

        if structured:
            payload = {k: str(v) for k, v in structured.items()}
            mode = "structured"
            display = f"{structured['left']} ← {structured['right']} {structured['money']:,}원 ({structured['item']})"
        else:
            payload = {"message": c["text"]}
            mode = "raw"
            display = c["text"][:60].replace("\n", " ")

        ok, status, body = post_to_whooing(webhook_url, payload, dry_run=args.dry_run)
        if ok:
            if mode == "structured":
                sent_structured += 1
                carrier_key = c["info"].get("carrier")
                if carrier_key:
                    recovery_hints[carrier_key] = display
                # 은행계좌에서 카드대금이 빠지는 건 `카드부채 ← 은행계좌`로 분개한다.
                # 카드사 SMS 누적사용액은 이 상환 감소분을 별도 SMS로 알려주지 않는 경우가 있어,
                # 잔액 정합성 체크에서는 이 금액을 기대 보정치로 누적해 오탐을 막는다.
                if (
                    (parsed or {}).get("kind") == "withdrawal"
                    and structured.get("left") in liability_accounts
                ):
                    liability_acct = structured["left"]
                    liability_payment_offsets_to_add[liability_acct] = (
                        liability_payment_offsets_to_add.get(liability_acct, 0)
                        + int(structured.get("money", 0) or 0)
                    )
            else:
                sent_raw += 1
                new_raw.append({
                    "label": c["info"]["label"],
                    "created_at": c["created_at"],
                    "text": c["text"],
                    "parsed": parsed,
                })
            print(f"  ✅ [{mode}] {c['info']['label']} {c['created_at']} {status} | {display}")
            if c["created_at"] > (latest or ""):
                latest = c["created_at"]
        else:
            failed += 1
            failure_record = {
                "sender": c["sender"], "label": c["info"]["label"],
                "created_at": c["created_at"], "text": c["text"],
                "mode": mode, "payload": payload,
                "status": status, "response": body[:500],
                "failed_at": datetime.now(KST).isoformat(),
            }
            new_failures.append(failure_record)
            print(f"  ❌ [{mode}] {c['info']['label']} {c['created_at']} {status} body={body[:120]!r}")
            blocked_at = c["created_at"]  # 커서가 이 메시지를 넘지 않게 잠금

    if new_failures and not args.dry_run:
        failures["failures"].extend(new_failures)
        save_json(FAILURES_FILE, failures)
        # 텔레그램 알림 (실패 1건당 1메시지, 최대 3건만)
        for f in new_failures[:3]:
            notify.send(_format_sync_failure(f))
        if len(new_failures) > 3:
            notify.send(
                f"⚠️ <b>후잉 동기화 실패 {len(new_failures)-3}건 추가</b>\n"
                f"<i>failures.json 에서 확인 부탁드립니다.</i>"
            )

    if new_raw and not args.dry_run:
        notify.send(_format_raw_fallback(new_raw))

    # 잔액 정합성 체크: 부분 처리(blocked_at) 사이클은 skip — 후잉이 모든 거래를 받지 못한 상태에서
    # 비교하면 잘못된 알림이 발생할 수 있음.
    if not blocked_at and not args.dry_run:
        last_balance_by_carrier: dict = {}
        for c in candidates:
            parsed = c.get("parsed") or {}
            # 자산은 parsed.balance, 부채(카드 누적)는 parsed.cumulative
            if parsed.get("balance") is not None:
                sms_b = parsed["balance"]
                side = "asset"
            elif parsed.get("cumulative") is not None:
                sms_b = parsed["cumulative"]
                side = "liability"
            else:
                continue
            carrier_key = c["info"].get("carrier")
            acct_name = accounts.get("carrier_to_account", {}).get(carrier_key)
            if not acct_name:
                continue
            cur = last_balance_by_carrier.get(carrier_key)
            if cur is None or c["created_at"] > cur["created_at"]:
                last_balance_by_carrier[carrier_key] = {
                    "sms_balance": sms_b,
                    "side": side,
                    "account": acct_name,
                    "label": c["info"]["label"],
                    "created_at": c["created_at"],
                }
        if last_balance_by_carrier:
            try:
                whoo_balances = whooing_balance.fetch_balances(
                    [v["account"] for v in last_balance_by_carrier.values()],
                    sides=("assets", "liabilities"),
                )
            except Exception as e:
                print(f"⚠️ 잔액 조회 실패 — 정합성 체크 skip: {e}")
                whoo_balances = None
            if whoo_balances is not None:
                state = load_json(BALANCE_ALERT_FILE, {"carriers": {}})
                carriers_state = state.setdefault("carriers", {})
                liability_payment_offsets = state.setdefault("liability_payment_offsets", {})
                for acct, amount in liability_payment_offsets_to_add.items():
                    if amount:
                        liability_payment_offsets[acct] = int(liability_payment_offsets.get(acct, 0) or 0) + amount
                changed = bool(liability_payment_offsets_to_add)
                for carrier, info in last_balance_by_carrier.items():
                    sms_b = info["sms_balance"]
                    actual_whoo_b = whoo_balances.get(info["account"])
                    if actual_whoo_b is None:
                        continue
                    liability_payment_offset = 0
                    if info.get("side") == "liability":
                        liability_payment_offset = int(liability_payment_offsets.get(info["account"], 0) or 0)
                    whoo_b = actual_whoo_b + liability_payment_offset
                    # 카드사 누적사용액이 카드대금 상환을 반영하는지는 문자 형식/시점에 따라 다르다.
                    # 실제 후잉 잔액이 보정 후 값보다 SMS에 더 가깝다면 보정치를 소진된 것으로 본다.
                    if (
                        liability_payment_offset
                        and abs(actual_whoo_b - sms_b) < abs(whoo_b - sms_b)
                    ):
                        liability_payment_offsets[info["account"]] = 0
                        liability_payment_offset = 0
                        whoo_b = actual_whoo_b
                        changed = True
                    diff = whoo_b - sms_b
                    prev = carriers_state.get(carrier, {})
                    prev_diff = prev.get("last_diff")

                    # 은행 자동 이자/캐시백 보정: 자산 + 후잉이 SMS보다 적고(deposit 누락), 차이 ≤ 5,000원.
                    # 횟수·날짜 제한 없이 매번 자동 분개. 매 발화마다 텔레그램 알림으로 누적 가시성 확보.
                    # 정상 SMS가 와서 deposit이 후잉에 들어가면 diff>=0이 되어 안전하게 미발화.
                    cur_month = datetime.now(KST).strftime("%Y-%m")
                    if (
                        info.get("side") == "asset"
                        and carrier in {"kakaobank", "shinhan_bank", "hana_bank"}
                        and diff < 0
                        and abs(diff) <= 5000
                    ):
                        interest_amount = -diff
                        interest_payload = {
                            "entry_date": datetime.now(KST).strftime("%Y%m%d"),
                            "money": str(interest_amount),
                            "item": "이자",
                            "left": info["account"],
                            "right": "기타수익",
                            "memo": "은행이자",
                        }
                        ok, status, body = post_to_whooing(webhook_url, interest_payload, dry_run=False)
                        if ok:
                            notify.send(
                                f"💰 <b>{notify.escape_html(info['label'])} 은행이자 자동 분개</b>\n"
                                f"+{interest_amount:,}원 ({notify.escape_html(info['account'])} ← 기타수익)"
                            )
                            carriers_state[carrier] = {
                                "account": info["account"],
                                "label": info["label"],
                                "sms_balance": sms_b,
                                "whooing_balance": whoo_b + interest_amount,
                                "last_diff": 0,
                                "last_interest_month": cur_month,
                                "last_sms_fee_month": prev.get("last_sms_fee_month"),
                                "last_hyundai_insurance_month": prev.get("last_hyundai_insurance_month"),
                                "alerted_at": datetime.now(KST).isoformat(),
                            }
                            changed = True
                            print(f"  💰 [interest] {info['label']} +{interest_amount:,}원 자동 분개")
                            continue
                        else:
                            print(f"  ❌ [interest] 분개 실패 status={status} body={body[:120]!r}")

                    # 현대카드 문자메시지 이용료 자동 보정: 별도 승인 알림 없이 누적액에만 300원이 붙는 경우.
                    # 현대카드 부채 + 후잉(카드대금 보정 후)이 SMS보다 정확히 300원 적고, 월 1회만 처리한다.
                    if (
                        info.get("side") == "liability"
                        and carrier == "hyundai_card"
                        and diff == -300
                        and prev.get("last_sms_fee_month") != cur_month
                    ):
                        sms_fee_payload = {
                            "entry_date": datetime.now(KST).strftime("%Y%m%d"),
                            "money": "300",
                            "item": "문자메시지 이용료",
                            "left": "주거,통신",
                            "right": info["account"],
                            "memo": "현대카드 문자메시지 이용료 자동보정: 승인 알림 없이 누적액에 반영된 월 1회 300원",
                        }
                        ok, status, body = post_to_whooing(webhook_url, sms_fee_payload, dry_run=False)
                        if ok:
                            notify.send(
                                f"📨 <b>현대카드 문자메시지 이용료 자동 분개</b>\n"
                                f"300원 (주거,통신 ← {notify.escape_html(info['account'])})"
                            )
                            carriers_state[carrier] = {
                                "account": info["account"],
                                "label": info["label"],
                                "sms_balance": sms_b,
                                "whooing_balance": actual_whoo_b + 300,
                                "whooing_balance_adjusted": whoo_b + 300,
                                "liability_payment_offset": liability_payment_offset,
                                "last_diff": 0,
                                "last_interest_month": prev.get("last_interest_month"),
                                "last_sms_fee_month": cur_month,
                                "last_hyundai_insurance_month": prev.get("last_hyundai_insurance_month"),
                                "alerted_at": datetime.now(KST).isoformat(),
                            }
                            changed = True
                            print(f"  📨 [sms-fee] {info['label']} 300원 자동 분개")
                            continue
                        else:
                            print(f"  ❌ [sms-fee] 분개 실패 status={status} body={body[:120]!r}")

                    # 현대카드 보험료 자동 보정: 라이나생명 보험료 SMS가 익일 지연 도착하지만
                    # 카드 누적사용액에는 당일 반영되는 경우. 현대카드 부채 + 후잉(카드대금 보정 후)이
                    # SMS보다 정확히 31,100원 적고, 월 1회만 처리한다.
                    if (
                        info.get("side") == "liability"
                        and carrier == "hyundai_card"
                        and diff == -31100
                        and prev.get("last_hyundai_insurance_month") != cur_month
                    ):
                        insurance_payload = {
                            "entry_date": datetime.now(KST).strftime("%Y%m%d"),
                            "money": "31100",
                            "item": "보험료",
                            "left": "의료,건강,보험",
                            "right": info["account"],
                            "memo": "현대카드 보험료 자동보정: 라이나생명 SMS 익일 지연, 누적사용액 차이 월 1회 31,100원",
                        }
                        ok, status, body = post_to_whooing(webhook_url, insurance_payload, dry_run=False)
                        if ok:
                            notify.send(
                                f"🛡️ <b>현대카드 보험료 자동 분개</b>\n"
                                f"31,100원 (의료,건강,보험 ← {notify.escape_html(info['account'])})"
                            )
                            carriers_state[carrier] = {
                                "account": info["account"],
                                "label": info["label"],
                                "sms_balance": sms_b,
                                "whooing_balance": actual_whoo_b + 31100,
                                "whooing_balance_adjusted": whoo_b + 31100,
                                "liability_payment_offset": liability_payment_offset,
                                "last_diff": 0,
                                "last_interest_month": prev.get("last_interest_month"),
                                "last_sms_fee_month": prev.get("last_sms_fee_month"),
                                "last_hyundai_insurance_month": cur_month,
                                "alerted_at": datetime.now(KST).isoformat(),
                            }
                            changed = True
                            print(f"  🛡️ [hyundai-insurance] {info['label']} 31,100원 자동 분개")
                            continue
                        else:
                            print(f"  ❌ [hyundai-insurance] 분개 실패 status={status} body={body[:120]!r}")

                    if prev_diff == diff:
                        continue  # 동일 차이 → 알림 skip
                    if prev_diff is None and diff == 0:
                        # 첫 관측부터 이미 일치하는 계정은 "정합 회복" 알림을 보내지 않는다.
                        carriers_state[carrier] = {
                            "account": info["account"],
                            "label": info["label"],
                            "sms_balance": sms_b,
                            "whooing_balance": actual_whoo_b,
                            "whooing_balance_adjusted": whoo_b,
                            "liability_payment_offset": liability_payment_offset,
                            "last_diff": diff,
                            "last_interest_month": prev.get("last_interest_month"),
                            "last_sms_fee_month": prev.get("last_sms_fee_month"),
                            "last_hyundai_insurance_month": prev.get("last_hyundai_insurance_month"),
                            "alerted_at": datetime.now(KST).isoformat(),
                        }
                        changed = True
                        print(f"  📊 [balance-init] {info['label']} sms={sms_b:,} whoo={whoo_b:,} diff={diff:+,} (notify skip)")
                        continue
                    notify.send(_format_balance_mismatch(
                        {
                            **info,
                            "actual_whooing_balance": actual_whoo_b,
                            "liability_payment_offset": liability_payment_offset,
                        },
                        sms_b, whoo_b, diff, prev_diff,
                        recovery_hints.get(carrier),
                    ))
                    carriers_state[carrier] = {
                        "account": info["account"],
                        "label": info["label"],
                        "sms_balance": sms_b,
                        "whooing_balance": actual_whoo_b,
                        "whooing_balance_adjusted": whoo_b,
                        "liability_payment_offset": liability_payment_offset,
                        "last_diff": diff,
                        "last_interest_month": prev.get("last_interest_month"),
                        "last_sms_fee_month": prev.get("last_sms_fee_month"),
                        "last_hyundai_insurance_month": prev.get("last_hyundai_insurance_month"),
                        "alerted_at": datetime.now(KST).isoformat(),
                    }
                    changed = True
                    print(f"  📊 [balance] {info['label']} sms={sms_b:,} whoo={whoo_b:,} diff={diff:+,}")
                if changed:
                    save_json(BALANCE_ALERT_FILE, state)

    # 커서 진행 규칙:
    #   - 실패가 없으면: latest(성공 마지막) 또는 latest_skip(광고 마지막) 중 큰 값
    #   - 실패가 있으면: 그 실패 메시지 직전까지만. latest(성공 마지막)으로 고정. latest_skip는 무시
    #     (실패 이후 시각의 광고로 커서가 점프해 실패 메시지를 건너뛰는 사고 방지)
    if blocked_at:
        final_latest = latest
    else:
        final_latest = max(latest or "", latest_skip or "")
    if final_latest and final_latest != last_message_at and not args.dry_run:
        sync_state["last_message_at"] = final_latest
        sync_state["last_synced_at"] = datetime.now(KST).isoformat()
        save_json(SYNC_STATE_FILE, sync_state)

    if blocked_at:
        print(f"⚠️ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 취소감액 {sent_cancel}건, 실패 {failed}건 — {blocked_at} 에서 중단(다음 cron 재시도)")
    else:
        print(f"✅ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 취소감액 {sent_cancel}건, 실패 {failed}건 (last={final_latest or latest})")

    # 가희 잔액 리마인더 & 답신 자동 분개 (격리 — 실패해도 결제 sync 결과는 위에서 이미 출력됨)
    run_gahee_reminder()


if __name__ == "__main__":
    main()
