"""
매매 흐름 핸들러.

자연어/단축어 → 카드+PIN 발행, PIN echo → 실주문, 만료/취소 알림.

LLM 결정 금지 원칙: 페이로드 추출은 LLM 가능, 실행은 사람의 PIN echo 가 게이트.
이 모듈은 채널 독립적이다 — 텔레그램 어댑터(launchd polling 또는 OpenClaw 도구)가 호출.

CLI:
    python3 -m orders.handler propose <account> <side> <symbol> <name> <qty> <type> [price] [routing]
    python3 -m orders.handler pin <input> [--live]
    python3 -m orders.handler amend [--qty N] [--price W] [--order-type T] [--budget W] [--routing R] [--send]
    python3 -m orders.handler cancel [--send]
    python3 -m orders.handler open-orders [--account A]
    python3 -m orders.handler cancel-order [--ord-no N] [--account A] [--live] [--send]
    python3 -m orders.handler modify-order [--ord-no N] [--account A] [--qty Q] [--price P] [--live] [--send]
    python3 -m orders.handler status
    python3 -m orders.handler cmd </command>
"""

from __future__ import annotations

import json
import sys
import threading
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Optional

_PARENT = Path(__file__).resolve().parent.parent
if str(_PARENT) not in sys.path:
    sys.path.insert(0, str(_PARENT))

import kiwoom_client as kc  # noqa: E402

from . import card, datasource, fill_watcher, guards, kiwoom_order, ledger, pin, sidecar, trailing  # noqa: E402

LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
OPENCLAW_CONFIG = Path.home() / '.openclaw' / 'openclaw.json'
TELEGRAM_ACCOUNT = 'stock'

_pin_store = pin.PinStore()
_expiry_thread: Optional[threading.Thread] = None
_expiry_send = None


def _limits() -> dict:
    return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))


# ---- 텔레그램 발송 ----

def send_telegram(text: str, parse_mode: str = 'Markdown') -> bool:
    try:
        cfg = json.loads(OPENCLAW_CONFIG.read_text(encoding='utf-8'))
        acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
        token = acct['botToken']
        chat_ids = acct.get('allowFrom') or []
    except Exception as e:
        print(f'[send_telegram] config error: {e}', file=sys.stderr)
        return False
    if not chat_ids:
        print('[send_telegram] no chat_ids configured', 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],
            'parse_mode': parse_mode,
            '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 as e:
            print(f'[send_telegram] error: {e}', file=sys.stderr)
            ok = False
    return ok


ADMIN_IMSG_CRED = Path.home() / '.openclaw' / 'credentials' / 'admin_imessage.json'
IMSG_TIMEOUT_SEC = 60


def _load_admin_imsg_handle() -> Optional[str]:
    """본인 iCloud handle (전화번호 또는 이메일) — admin_imessage.json. 없으면 None."""
    if not ADMIN_IMSG_CRED.exists():
        return None
    try:
        return (json.loads(ADMIN_IMSG_CRED.read_text(encoding='utf-8')) or {}).get('handle')
    except Exception:
        return None


def send_imessage_pin(pin: str, card_id: str, side_label: str, symbol_name: str,
                       domain: str = 'stock.hyowons.net') -> bool:
    """PIN을 본인 iMessage로 발송 — iOS Safari OTP 자동입력 대상.

    Apple 도메인 바인딩 형식: 마지막 줄 `@<domain> #<code>` → iOS가 해당 도메인 페이지에서만 자동입력 제안.
    credential `admin_imessage.json` 미설정 시 skip, 텔레그램 발송에는 영향 X.

    fire-and-forget: AppleScript ↔ Messages.app AppleEvent 응답이 timeout(-1712) 떨어져도 실제 메시지는
    큐에 들어가 발송됨. 응답 기다리지 않고 Popen background로 던지고 즉시 반환 — propose 응답 지연 방지.
    """
    import subprocess
    handle = _load_admin_imsg_handle()
    if not handle:
        print('[send_imessage_pin] admin_imessage.json 없음 — iMessage 발송 skip', file=sys.stderr)
        return False
    text = (
        f"[{domain}] {side_label} 인증\n"
        f"종목: {symbol_name}\n"
        f"카드: {card_id}\n"
        f"코드: {pin}\n\n"
        f"@{domain} #{pin}"
    )
    try:
        subprocess.Popen(
            ['imsg', 'send', '--to', handle, '--text', text, '--service', 'imessage'],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )
        return True
    except Exception as e:
        print(f'[send_imessage_pin] error: {e}', file=sys.stderr)
        return False


# ---- 매매 흐름 ----

def _sweep_expired_and_notify() -> None:
    """진입점에서 호출 — 만료된 카드 정리 + 텔레그램 알림."""
    try:
        expired = _pin_store.sweep_expired()
    except Exception as e:
        print(f'[sweep_expired] {e}', file=sys.stderr)
        return
    if expired:
        try:
            ledger.append('expired', {'card_id': expired.card_id, **expired.payload})
        except Exception:
            pass
        try:
            send_telegram(card.format_expired(expired.card_id, expired.payload.get('side', 'BUY')),
                          parse_mode=None)
        except Exception:
            pass


def propose_trade(
    account: str,
    side: str,
    symbol: str,
    symbol_name: str,
    qty: Optional[int],
    order_type: str,
    price: Optional[int] = None,
    routing_force: Optional[str] = None,
    budget: Optional[int] = None,
    stop_price: Optional[int] = None,
    trail_steps: Optional[list] = None,
    min_sell_price: Optional[int] = None,
) -> dict:
    sidecar.guard_or_raise()
    _sweep_expired_and_notify()

    if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT', 'TRAILING_STOP'):
        return {'ok': False,
                'message': card.format_rejected('INVALID_ORDER_TYPE', f'잘못된 주문방식: {order_type}')}
    if side not in ('BUY', 'SELL'):
        return {'ok': False,
                'message': card.format_rejected('INVALID_SIDE', f'잘못된 방향: {side}')}
    if order_type == 'STOP_LIMIT':
        if side != 'SELL':
            return {'ok': False,
                    'message': card.format_rejected('STOP_SELL_ONLY', '스톱지정가는 현재 매도만 지원')}
        if not price or price <= 0 or not stop_price or stop_price <= 0:
            return {'ok': False,
                    'message': card.format_rejected('STOP_INPUT',
                                                    '스톱지정가는 지정가와 조건단가(트리거)가 모두 필요')}
    norm_steps = None
    if order_type == 'TRAILING_STOP':
        # 트레일링은 계단 정의(하락률·누적 비중)만 입력받고, 계단별 조건단가·지정가·주수는
        # 현재가와 보유수량 기준으로 자동 계산한다 (md 수집 후).
        if side != 'SELL':
            return {'ok': False,
                    'message': card.format_rejected('STOP_SELL_ONLY', '트레일링 스톱은 매도 전용')}
        try:
            norm_steps = trailing.normalize_steps(trail_steps or [])
        except (ValueError, KeyError, TypeError) as e:
            return {'ok': False,
                    'message': card.format_rejected('TRAIL_STEPS', f'계단 정의 오류: {e}')}
        if budget is not None:
            # 계단별 주수는 보유수량을 나눠 만든다 — 예산 환산이 끼면 기준이 두 개가 된다.
            return {'ok': False,
                    'message': card.format_rejected('TRAIL_BUDGET',
                                                    '트레일링은 수량으로만 지정 (예산 환산 불가)')}
        if min_sell_price is not None and min_sell_price <= 0:
            return {'ok': False,
                    'message': card.format_rejected('TRAIL_MIN_PRICE',
                                                    '최저 매도가는 0보다 커야 함 (제한 없으면 비우세요)')}
        dup = trailing.find_by_symbol(account, symbol)
        if dup:
            dup_steps = dup.get('steps') or []
            top_uv = max((s['cond_uv'] for s in dup_steps), default=0)
            return {'ok': False,
                    'message': card.format_rejected(
                        'TRAIL_DUPLICATE',
                        f'{symbol_name or symbol} 트레일링 예약이 이미 있음 '
                        f'({dup["id"]} · {len(dup_steps)}단 · 1단계 손절 {top_uv:,}원) '
                        f'— 먼저 취소하세요')}
    if budget is not None and qty is not None:
        return {'ok': False,
                'message': card.format_rejected('AMBIGUOUS_INPUT',
                                                'qty와 budget을 동시에 지정할 수 없음 — 하나만 입력')}
    if budget is None and qty is None:
        return {'ok': False,
                'message': card.format_rejected('MISSING_QTY', '수량(qty) 또는 예산(budget)을 입력해야 함')}

    # budget 환산은 호가창 필요 — qty 결정 전 호가 한번 받기 위해 임시 qty=0 으로 datasource 호출.
    # collect_market_data 의 잔고/보유 조회는 qty 와 무관.
    md = datasource.collect_market_data(account, symbol, side, qty or 0)

    trail_final = None
    trail_peak = None
    if order_type == 'TRAILING_STOP':
        # 현재가를 못 받으면 손절선을 정할 수 없어 진행 불가 (호가 fallback 금지 — 기준이 흔들림).
        cur = md.get('current_price') or 0
        if cur <= 0:
            return {'ok': False,
                    'message': card.format_rejected('TRAIL_NO_PRICE',
                                                    '현재가를 못 받아 트레일링 손절선을 정할 수 없음')}
        # 초기 고점은 등록 시점 현재가로 고정. 과거 고점(52주·매수후)을 쓰면 손절선이 현재가
        # 위로 올라가 등록 즉시 발동한다 — 손실 종목은 아예 등록 불가, 이익 종목은 현재가와
        # 결과가 같아 옵션 자체를 제거했다(2026-07-30 실측 근거로 관리자님 결정).
        trail_peak = cur
        try:
            leveled = trailing.compute_step_levels(trail_peak, norm_steps, min_sell_price)
        except ValueError as e:
            return {'ok': False, 'message': card.format_rejected('TRAIL_LEVEL', str(e))}
        # 계단별 주수 배분. 수량이 적어 0주가 된 계단은 발주할 수 없으니 떨어낸다 —
        # 단계 번호(n)는 그대로 두어 "몇 단계가 빠졌는지" 가 카드에서 보이게 한다.
        qtys = trailing.allocate_step_qty(qty, [s['weight'] for s in norm_steps])
        trail_final = [dict(s, qty=q) for s, q in zip(leveled, qtys) if q > 0]
        if not trail_final:
            return {'ok': False,
                    'message': card.format_rejected(
                        'TRAIL_QTY', f'{qty:,}주로는 계단을 나눌 수 없음 — 수량을 늘리거나 계단을 줄이세요')}
        # 가장 얕은 계단이 제일 먼저 발동한다. 이게 현재가 이상이면 등록하는 순간 터진다.
        # 고점=현재가로 고정된 뒤로는 최저 매도가가 현재가보다 높은 경우만 남는다
        # (트레일 폭은 항상 현재가 아래로 내려감).
        top = trail_final[0]
        if top['cond_uv'] >= cur:
            if top['floor_applied']:
                msg = (f'최저 매도가 {min_sell_price:,}원이 현재가 {cur:,}원보다 높아 '
                       f'등록 즉시 발동됩니다 — 현재가보다 낮게 설정하세요')
            else:
                msg = (f'1단계 손절선 {top["cond_uv"]:,}원이 현재가 {cur:,}원 이상이라 '
                       f'등록 즉시 발동됩니다')
            return {'ok': False, 'message': card.format_rejected('TRAIL_IMMEDIATE', msg)}
        price = top['ord_uv']
        stop_price = top['cond_uv']

    budget_conversion = None
    if budget is not None:
        conv = guards.convert_budget_to_qty(side, budget, md.get('orderbook'),
                                             fallback_price=md.get('current_price'))
        if not conv['ok']:
            ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
                                        'budget': budget, 'reason': conv['code'],
                                        'message': conv['message']})
            return {'ok': False, 'message': card.format_rejected(conv['code'], conv['message'])}
        qty = conv['qty']
        budget_conversion = {
            'budget': budget,
            'qty': conv['qty'],
            'ref_price': conv['ref_price'],
            'remainder': conv['remainder'],
            'source': conv.get('source', 'orderbook'),
            'bumped': conv.get('bumped', False),
        }

    request = {
        'account': account, 'side': side, 'symbol': symbol, 'symbol_name': symbol_name,
        'qty': qty, 'order_type': order_type,
    }
    if order_type == 'LIMIT':
        request['price'] = price
    elif order_type in ('STOP_LIMIT', 'TRAILING_STOP'):
        request['price'] = price
        request['stop_price'] = stop_price
        if order_type == 'TRAILING_STOP':
            request['trail_steps'] = trail_final
            request['min_sell_price'] = min_sell_price
            request['trail_peak'] = trail_peak

    r = guards.validate_request(request, md)
    if not r.ok:
        ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
                                    'qty': qty, 'price': price, 'reason': r.code, 'message': r.message})
        return {'ok': False, 'message': card.format_rejected(r.code, r.message)}

    # 종목 상태 평가 (ka10099 캐시 기반) — 정리매매·투자위험·거래정지 사전 거부, 그 외 위험은 경고
    state_eval = guards.evaluate_stock_state(md.get('stock_meta'))
    if not state_eval['result'].ok:
        sr = state_eval['result']
        ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
                                    'qty': qty, 'price': price, 'reason': sr.code,
                                    'message': sr.message,
                                    'stock_state': state_eval['state'],
                                    'order_warning': state_eval['order_warning']})
        return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
    state_warning = state_eval['warning']  # 카드에 표시할 경고 (None이면 정상)

    suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force)

    estimate = None
    final_price = price
    if order_type == 'MARKET':
        if md.get('orderbook'):
            estimate = guards.estimate_market_fill(side, qty, md['orderbook'])
    elif order_type == 'AGGRESSIVE_LIMIT':
        agg_res = guards.aggressive_limit_price(side, md.get('orderbook'),
                                                  fallback_price=md.get('current_price'))
        if not agg_res['ok']:
            ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
                                        'qty': qty, 'price': price, 'reason': agg_res['code'],
                                        'message': agg_res['message']})
            return {'ok': False, 'message': card.format_rejected(agg_res['code'], agg_res['message'])}
        estimate = {'aggressive_price': agg_res['price'],
                    'source': agg_res['source'],
                    'ref_price': agg_res['ref_price']}
        final_price = agg_res['price']

    payload = {
        'account': account, 'side': side, 'symbol': symbol, 'symbol_name': symbol_name,
        'qty': qty, 'price': final_price, 'order_type': order_type, 'routing_suffix': suffix,
    }
    if order_type == 'STOP_LIMIT':
        payload['stop_price'] = stop_price
    elif order_type == 'TRAILING_STOP':
        # 승인 시점에 확정되는 것: 계좌·종목·계단별 수량·하락률·최저 매도가.
        # 이후 감시 루프는 계단별 조건단가만 올린다 (수량·계단 구성은 불변).
        payload['stop_price'] = stop_price
        payload['trail_steps'] = trail_final
        payload['trail_peak'] = trail_peak
        payload['min_sell_price'] = min_sell_price

    try:
        pending = _pin_store.issue(account, payload)
    except RuntimeError:
        active = _pin_store.peek()
        info = _active_card_info(active) if active else None
        return {'ok': False, 'message': card.format_card_locked(info)}

    card_msg = card.format_card(request, md, pending.card_id, estimate, budget_conversion,
                                 state_warning)
    pin_msg = card.format_pin_message(pending.pin)

    ledger.append('card_issued', {'card_id': pending.card_id, **payload})
    ledger.append('pin_issued', {'card_id': pending.card_id, 'account': account,
                                  'pin_length': len(pending.pin)})

    return {
        'ok': True,
        'card_id': pending.card_id,
        'card_message': card_msg,
        'pin_message': pin_msg,
        'expiry_seconds': pending.expiry_seconds,
        'is_spouse': pending.account_label in _limits()['spouse_accounts'],
    }


def propose_trade_multi(
    legs: list,
    side: str,
    symbol: str,
    symbol_name: str,
    order_type: str,
    price: Optional[int] = None,
) -> dict:
    """복수 계좌 동시 주문 — 같은 종목·방향·가격을 여러 계좌에서 카드 1장 + PIN 1개로.

    legs: [{'account': str, 'qty': int}, ...] (2개 이상)
    웹 거래 모달 '두 계좌 모두 매도' 진입점. SELL + LIMIT/MARKET 만 지원.
    레그별 검증은 propose_trade 와 동일 경로(collect_market_data + validate_request),
    하나라도 거부면 전체 거부. PIN 형식 일관성을 위해 같은 소유자 그룹 계좌만 허용.
    """
    sidecar.guard_or_raise()
    _sweep_expired_and_notify()

    if side != 'SELL':
        return {'ok': False,
                'message': card.format_rejected('INVALID_SIDE', '복수 계좌 동시 주문은 매도만 지원')}
    if order_type not in ('LIMIT', 'MARKET'):
        return {'ok': False,
                'message': card.format_rejected('INVALID_ORDER_TYPE', f'잘못된 주문방식: {order_type}')}
    if not legs or len(legs) < 2:
        return {'ok': False,
                'message': card.format_rejected('INVALID_LEGS', '계좌 2개 이상 필요')}
    accounts = [str(l.get('account') or '') for l in legs]
    if len(set(accounts)) != len(accounts):
        return {'ok': False,
                'message': card.format_rejected('INVALID_LEGS', '계좌 중복')}
    limits = _limits()
    owners, spouses = set(limits['owner_accounts']), set(limits['spouse_accounts'])
    if not (all(a in owners for a in accounts) or all(a in spouses for a in accounts)):
        return {'ok': False,
                'message': card.format_rejected('INVALID_LEGS', '같은 소유자 그룹 계좌만 동시 주문 가능')}

    validated = []
    state_warning = None
    suffix = None
    for leg in legs:
        account = leg['account']
        try:
            qty = int(leg.get('qty') or 0)
        except (TypeError, ValueError):
            qty = 0
        if qty <= 0:
            return {'ok': False,
                    'message': card.format_rejected('MISSING_QTY', f'[{account}] 수량 필요')}
        md = datasource.collect_market_data(account, symbol, side, qty)
        request = {'account': account, 'side': side, 'symbol': symbol,
                   'symbol_name': symbol_name, 'qty': qty, 'order_type': order_type}
        if order_type == 'LIMIT':
            request['price'] = price
        r = guards.validate_request(request, md)
        if not r.ok:
            ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
                                        'qty': qty, 'price': price, 'reason': r.code,
                                        'message': r.message, 'multi': True})
            return {'ok': False, 'message': card.format_rejected(r.code, f'[{account}] {r.message}')}
        state_eval = guards.evaluate_stock_state(md.get('stock_meta'))
        if not state_eval['result'].ok:
            sr = state_eval['result']
            ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
                                        'qty': qty, 'price': price, 'reason': sr.code,
                                        'message': sr.message, 'multi': True,
                                        'stock_state': state_eval['state'],
                                        'order_warning': state_eval['order_warning']})
            return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
        if state_eval['warning']:
            state_warning = state_eval['warning']
        if suffix is None:
            suffix = guards.determine_routing(md['now'], md['nxt_eligible'], None)
        validated.append({'account': account, 'qty': qty,
                          'position_qty': md.get('position_qty', 0)})

    payload = {
        'multi': True,
        'side': side, 'symbol': symbol, 'symbol_name': symbol_name,
        'order_type': order_type, 'price': price, 'routing_suffix': suffix,
        'legs': [{'account': v['account'], 'qty': v['qty']} for v in validated],
        # 단일 payload 필드(account/qty)를 읽는 경로(format_card_locked 등) 호환용 요약
        'account': '+'.join(accounts),
        'qty': sum(v['qty'] for v in validated),
    }

    try:
        pending = _pin_store.issue(accounts[0], payload)
    except RuntimeError:
        active = _pin_store.peek()
        info = _active_card_info(active) if active else None
        return {'ok': False, 'message': card.format_card_locked(info)}

    card_msg = card.format_card_multi(validated, side, symbol, symbol_name, order_type,
                                       price, pending.card_id, state_warning)
    pin_msg = card.format_pin_message(pending.pin)

    ledger.append('card_issued', {'card_id': pending.card_id, **payload})
    ledger.append('pin_issued', {'card_id': pending.card_id, 'account': accounts[0],
                                  'pin_length': len(pending.pin)})

    return {
        'ok': True,
        'card_id': pending.card_id,
        'card_message': card_msg,
        'pin_message': pin_msg,
        'expiry_seconds': pending.expiry_seconds,
        'is_spouse': pending.account_label in spouses,
    }


def propose_and_send(account, side, symbol, symbol_name, qty, order_type,
                     price=None, routing_force=None, budget=None) -> dict:
    """propose_trade 후 카드+PIN 을 텔레그램에 분리 발송. skill / launchd 진입점용."""
    res = propose_trade(account, side, symbol, symbol_name, qty, order_type, price, routing_force,
                        budget=budget)
    if not res['ok']:
        send_telegram(res['message'], parse_mode=None)
        return res
    send_telegram(res['card_message'], parse_mode='Markdown')
    send_telegram(res['pin_message'], parse_mode=None)
    return res


def submit_with_pin_and_send(pin_input: str, dry_run: bool = False) -> dict:
    """submit_with_pin 후 결과 메시지를 텔레그램에 발송."""
    res = submit_with_pin(pin_input, dry_run=dry_run)
    msg = res.get('message')
    if msg:
        send_telegram(msg, parse_mode='Markdown' if res['ok'] and not dry_run else None)
    return res


def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
    sidecar.guard_or_raise()
    _sweep_expired_and_notify()
    # PIN 자리에 "0" 단독 → 취소로 해석 (amend 의 0=취소와 일관성).
    # 본인 PIN 4자리 / 가희 8자리라 "0" 한 자리는 어차피 PIN 길이 미달.
    if (pin_input or '').strip() == '0':
        return cancel_active_card()
    ok, reason, card_obj = _pin_store.verify(pin_input)
    if not ok:
        if card_obj is None:
            return {'ok': False, 'message': '활성 카드 없음'}
        if reason == '만료':
            ledger.append('expired', {'card_id': card_obj.card_id, **card_obj.payload})
            return {'ok': False,
                    'message': card.format_expired(card_obj.card_id, card_obj.payload['side'])}
        if 'PIN 불일치' in reason:
            ledger.append('rejected', {'card_id': card_obj.card_id, 'reason': 'PIN_MISMATCH'})
            return {'ok': False, 'message': card.format_pin_mismatch(card_obj.card_id)}
        return {'ok': False, 'message': reason}

    p = card_obj.payload
    ledger.append('approved', {'card_id': card_obj.card_id, **p})

    if p.get('multi'):
        return _submit_multi_legs(card_obj, dry_run)
    if p['order_type'] == 'TRAILING_STOP':
        return _submit_trailing_steps(card_obj, dry_run)

    submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
    res = kiwoom_order.submit(
        account_label=p['account'],
        side=p['side'],
        symbol=p['symbol'],
        qty=p['qty'],
        price=p.get('price'),
        order_type=submit_order_type,
        routing_suffix=p['routing_suffix'],
        dry_run=dry_run,
        card_id=card_obj.card_id,
        stop_price=p.get('stop_price'),
    )

    if res['ok']:
        if dry_run:
            msg = card.format_dryrun(res.get('payload', {}))
        else:
            ord_no = res.get('ord_no', '')
            msg = card.format_submitted(card_obj.card_id, p['side'],
                                        p.get('symbol_name', p['symbol']),
                                        p['qty'], p.get('price'), ord_no)
            if ord_no:
                # 키움에서 ord_no 받으면 백그라운드 체결 추적 시작 (kt00007 폴링).
                # 부분체결·완전체결·사후거절·30분 미체결 시 텔레그램 자동 알림.
                fill_watcher.watch(
                    ord_no=ord_no, account=p['account'], side=p['side'],
                    symbol=p['symbol'], symbol_name=p.get('symbol_name', p['symbol']),
                    order_qty=p['qty'], price=p.get('price'),
                    order_type=p['order_type'], card_id=card_obj.card_id,
                )
        return {'ok': True, 'message': msg, 'detail': res}
    return {'ok': False,
            'message': card.format_rejected(res.get('reason', 'UNKNOWN'), str(res.get('error', ''))),
            'detail': res}


def _submit_trailing_steps(card_obj, dry_run: bool) -> dict:
    """트레일링 계단 카드 — 계단마다 스톱주문을 내고 예약 1건으로 묶어 등록한다.

    키움엔 스톱지정가(trde_tp=28)로 나간다 — 트레일링은 우리 쪽 개념이다.
    얕은 계단(먼저 발동할 것)부터 접수해, 중간에 막히더라도 가장 가까운 방어선이 먼저 걸리게 한다.

    한 레그가 실패해도 나머지는 계속 시도하고, 접수된 레그만 예약에 등록한다. 이미 나간 주문을
    되돌리지 않는 이유는 취소 자체도 실패할 수 있어 상태가 더 불분명해지기 때문이다 —
    대신 몇 단이 걸리고 몇 단이 실패했는지를 메시지에 그대로 드러낸다.

    fill_watcher 는 걸지 않는다. 스톱 예약은 조건 도달까지 미체결로 남는 게 정상이라
    30분 미체결 알림이 오탐이 된다 — 생존·체결 감시는 trailing_monitor 가 담당한다.
    """
    p = card_obj.payload
    steps = p.get('trail_steps') or []
    name = p.get('symbol_name', p['symbol'])
    placed, failed, details = [], [], []

    for s in steps:
        res = kiwoom_order.submit(
            account_label=p['account'],
            side='SELL',
            symbol=p['symbol'],
            qty=s['qty'],
            price=s['ord_uv'],
            order_type='STOP_LIMIT',
            routing_suffix=p['routing_suffix'],
            dry_run=dry_run,
            card_id=card_obj.card_id,
            stop_price=s['cond_uv'],
        )
        details.append(res)
        if res['ok']:
            # dry_run 은 주문번호가 없다 — 예약 등록 없이 접수될 내용만 확인한다.
            placed.append(dict(s, ord_no=res.get('ord_no', '')))
        else:
            failed.append(dict(s, reason=res.get('reason', 'UNKNOWN'),
                               error=str(res.get('error', ''))))

    if not placed:
        return {'ok': False,
                'message': card.format_rejected(
                    (failed[0]['reason'] if failed else 'UNKNOWN'),
                    f'{name} 계단 {len(steps)}단 전부 접수 실패 — '
                    + (failed[0]['error'] if failed else '')),
                'detail': details}

    info = {'card_id': card_obj.card_id, 'symbol_name': name, 'account': p['account'],
            'total_qty': p['qty'], 'peak': p['trail_peak'],
            'min_sell_price': p.get('min_sell_price'),
            'placed': placed, 'failed': failed, 'dry_run': dry_run,
            'res_id': None, 'register_error': None}

    if not dry_run:
        registerable = [s for s in placed if s['ord_no']]
        if not registerable:
            # 접수는 됐는데 주문번호를 못 받았다 — 감시가 붙을 수 없으니 반드시 알린다.
            ledger.append('trailing_register_failed',
                          {'card_id': card_obj.card_id, 'error': 'no ord_no', **p})
            info['register_error'] = '주문번호를 못 받아 감시를 걸지 못했습니다'
        else:
            try:
                res_trl = trailing.register_steps(
                    account=p['account'], symbol=p['symbol'], symbol_name=name,
                    total_qty=p['qty'], peak=p['trail_peak'], steps=registerable,
                    routing_suffix=p['routing_suffix'], card_id=card_obj.card_id,
                    min_sell_price=p.get('min_sell_price'),
                )
                info['res_id'] = res_trl['id']
            except Exception as e:
                # 주문은 이미 접수됨 — 등록 실패를 숨기면 트레일링이 안 도는 걸 모른다.
                ledger.append('trailing_register_failed',
                              {'card_id': card_obj.card_id, 'error': repr(e), **p})
                info['register_error'] = str(e)

    return {'ok': True, 'message': card.format_trailing_submitted(info), 'detail': details}


def _submit_multi_legs(card_obj, dry_run: bool) -> dict:
    """multi 카드(propose_trade_multi)의 레그별 키움 제출 — 레그 독립 실행·독립 보고.

    한 레그가 실패해도 나머지 레그는 계속 시도. ok = 한 레그라도 접수 성공.
    접수된 레그는 각각 fill_watcher 추적 (큐 기반이라 동시 다건 OK).
    """
    p = card_obj.payload
    submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
    msgs = []
    details = []
    any_ok = False
    for leg in p.get('legs') or []:
        res = kiwoom_order.submit(
            account_label=leg['account'],
            side=p['side'],
            symbol=p['symbol'],
            qty=leg['qty'],
            price=p.get('price'),
            order_type=submit_order_type,
            routing_suffix=p['routing_suffix'],
            dry_run=dry_run,
            card_id=card_obj.card_id,
        )
        details.append(res)
        acc_disp = card.ACCOUNT_DISPLAY.get(leg['account'], leg['account'])
        if res['ok']:
            any_ok = True
            if dry_run:
                msgs.append(f'[{acc_disp}] ' + card.format_dryrun(res.get('payload', {})))
                continue
            ord_no = res.get('ord_no', '')
            msgs.append(f'[{acc_disp}] ' + card.format_submitted(
                card_obj.card_id, p['side'], p.get('symbol_name', p['symbol']),
                leg['qty'], p.get('price'), ord_no))
            if ord_no:
                fill_watcher.watch(
                    ord_no=ord_no, account=leg['account'], side=p['side'],
                    symbol=p['symbol'], symbol_name=p.get('symbol_name', p['symbol']),
                    order_qty=leg['qty'], price=p.get('price'),
                    order_type=p['order_type'], card_id=card_obj.card_id,
                )
        else:
            msgs.append(f'[{acc_disp}] ' + card.format_rejected(
                res.get('reason', 'UNKNOWN'), str(res.get('error', ''))))
    return {'ok': any_ok, 'message': '\n'.join(msgs), 'detail': details}


def cancel_active_card() -> dict:
    sidecar.guard_or_raise()
    card_obj = _pin_store.cancel()
    if not card_obj:
        return {'ok': False, 'message': '활성 카드 없음'}
    ledger.append('canceled', {'card_id': card_obj.card_id, **card_obj.payload, 'reason': 'user_cancel'})
    return {'ok': True, 'message': card.format_canceled(card_obj.card_id, card_obj.payload['side'])}


def cancel_active_card_and_send() -> dict:
    res = cancel_active_card()
    if res.get('message'):
        send_telegram(res['message'], parse_mode=None)
    return res


# ---- 키움 접수 후 주문 정정·취소 (kt10002/kt10003) ----

def _list_open_orders(account: Optional[str] = None,
                       ord_no: Optional[str] = None) -> list[dict]:
    """ka10075 미체결 조회 — account 명시 시 그 계좌만, 아니면 전체.
    ord_no 명시 시 해당 ord_no 만 필터링.
    """
    if account:
        rows = kc.get_open_orders(account)
    else:
        rows = kc.get_open_orders_all()
    if ord_no:
        rows = [r for r in rows if r.get('ord_no') == ord_no]
    return rows


def _format_open_order_list(rows: list[dict]) -> str:
    lines = []
    for r in rows:
        side_word = '매수' if r['side'] == 'BUY' else ('매도' if r['side'] == 'SELL' else r['side'])
        price = f"{r['order_price']:,}원" if r['order_price'] else r['order_type']
        lines.append(
            f"ord_no={r['ord_no']} · [{r['account']}] {r['name']} ({r['code']}) "
            f"{side_word} {r['order_qty']}주 @ {price} · 미체결 {r['unfilled_qty']}주 · "
            f"{r['exchange']} · {r['status']} · {r['order_time']}"
        )
    return '\n'.join(lines) if lines else '(미체결 없음)'


def _resolve_open_order(ord_no: Optional[str], account: Optional[str]) -> dict:
    """미체결 주문 1건을 확정. 명시·자동 선택·모호 케이스를 단일 흐름으로.

    반환:
      {ok: True, row: {...}}                             — 1건 확정
      {ok: False, code: NO_OPEN_ORDERS, message: ...}    — 미체결 없음
      {ok: False, code: NOT_FOUND, message: ...}         — ord_no 매칭 실패
      {ok: False, code: AMBIGUOUS, message: ...}         — ord_no 미명시 + 다수 후보
    """
    rows = _list_open_orders(account=account, ord_no=ord_no)
    if not rows:
        if ord_no:
            return {'ok': False, 'code': 'NOT_FOUND',
                    'message': f'⛔ 미체결 주문 ord_no={ord_no} 을(를) 찾을 수 없습니다'
                               + (f' (계좌: {account})' if account else ' (전 계좌)')}
        return {'ok': False, 'code': 'NO_OPEN_ORDERS',
                'message': '⛔ 미체결 주문이 없습니다'}
    if len(rows) > 1:
        listing = _format_open_order_list(rows)
        hint = ('대상 ord_no 를 명시해 주세요:\n' + listing) if not ord_no \
               else f'⛔ 동일 ord_no 가 여러 건입니다 (예외 상황):\n{listing}'
        return {'ok': False, 'code': 'AMBIGUOUS', 'message': hint}
    return {'ok': True, 'row': rows[0]}


def cancel_open_order(ord_no: Optional[str] = None,
                       account: Optional[str] = None,
                       dry_run: bool = False) -> dict:
    """키움에 접수된 미체결 주문 취소 (kt10003). PIN 게이트 없음 (취소는 안전 방향).

    ord_no 명시 시 즉시 취소. 미명시 + 미체결 1건이면 자동, 2건 이상이면 모호 응답.
    cancel_qty=0 — 잔량 전부 취소.
    """
    sidecar.guard_or_raise()
    resolved = _resolve_open_order(ord_no, account)
    if not resolved['ok']:
        return {'ok': False, 'message': resolved['message'], 'code': resolved['code']}

    row = resolved['row']
    res = kiwoom_order.cancel_order(
        account_label=row['account'],
        orig_ord_no=row['ord_no'],
        symbol=row['code'],
        cancel_qty=0,
        routing_suffix=row.get('routing_suffix', ''),
        dry_run=dry_run,
    )
    side_word = '매수' if row['side'] == 'BUY' else ('매도' if row['side'] == 'SELL' else row['side'])

    if not res['ok']:
        reason = res.get('reason', 'UNKNOWN')
        detail = ''
        if reason == 'BROKER_REJECT':
            detail = str(res.get('response', {}).get('return_msg') or '')
        elif reason == 'NETWORK':
            detail = str(res.get('error', ''))
        msg = (f"⛔ 취소 실패 [{reason}] · ord_no={row['ord_no']} · "
               f"[{row['account']}] {row['name']} {side_word} {row['unfilled_qty']}주"
               + (f"\n사유: {detail}" if detail else ''))
        return {'ok': False, 'message': msg, 'detail': res}

    if res.get('dry_run'):
        msg = (f"🧪 [DRY-RUN] 취소 시뮬레이션 · ord_no={row['ord_no']} · "
               f"[{row['account']}] {row['name']} {side_word} {row['unfilled_qty']}주 "
               f"({row['exchange']})")
    else:
        new_ord = res.get('new_ord_no', '')
        msg = (f"✅ 취소 접수 · 원주문 ord_no={row['ord_no']} → 취소 ord_no={new_ord} · "
               f"[{row['account']}] {row['name']} {side_word} {row['unfilled_qty']}주 "
               f"({row['exchange']})")
        # 키움이 취소 ord_no 발급하면 ka10075 폴링으로 broker 확정 추적 — 확정/30분 타임아웃 시 텔레그램 알림
        if new_ord:
            fill_watcher.watch_cancel(
                new_ord_no=new_ord,
                orig_ord_no=row['ord_no'],
                account=row['account'],
                side=row['side'],
                symbol=row['code'],
                symbol_name=row['name'],
                cancel_qty=row['unfilled_qty'],
            )
    return {'ok': True, 'message': msg, 'detail': res}


def cancel_open_order_and_send(ord_no: Optional[str] = None,
                                account: Optional[str] = None,
                                dry_run: bool = False) -> dict:
    res = cancel_open_order(ord_no=ord_no, account=account, dry_run=dry_run)
    if res.get('message'):
        send_telegram(res['message'], parse_mode=None)
    return res


def modify_open_order(ord_no: Optional[str] = None,
                       account: Optional[str] = None,
                       qty: Optional[int] = None,
                       price: Optional[int] = None,
                       dry_run: bool = False) -> dict:
    """키움에 접수된 미체결 주문 정정 (kt10002). PIN 게이트 없음.

    qty/price 둘 다 키움 명세상 Required. 일부만 주면 기존 값 그대로 유지.
    시장가 미체결은 정정 불가 (가격 0 차단) — 취소 후 신규 발주 안내.
    """
    sidecar.guard_or_raise()
    resolved = _resolve_open_order(ord_no, account)
    if not resolved['ok']:
        return {'ok': False, 'message': resolved['message'], 'code': resolved['code']}

    row = resolved['row']
    if qty is None and price is None:
        return {'ok': False,
                'message': '⛔ 정정 항목 누락 — qty/price 중 최소 하나는 필요합니다'}

    new_qty = qty if qty is not None else row['unfilled_qty']
    new_price = price if price is not None else row['order_price']

    if new_qty <= 0:
        return {'ok': False,
                'message': f'⛔ 정정수량 {new_qty} — 0 이하 정정은 취소를 사용하세요'}
    if new_price <= 0:
        return {'ok': False,
                'message': '⛔ 시장가 미체결은 정정 불가 (가격 0). 취소 후 신규 발주하세요.'}

    side_word = '매수' if row['side'] == 'BUY' else ('매도' if row['side'] == 'SELL' else row['side'])
    res = kiwoom_order.modify_order(
        account_label=row['account'],
        orig_ord_no=row['ord_no'],
        symbol=row['code'],
        modify_qty=new_qty,
        modify_price=new_price,
        routing_suffix=row.get('routing_suffix', ''),
        dry_run=dry_run,
    )

    if not res['ok']:
        reason = res.get('reason', 'UNKNOWN')
        detail = ''
        if reason == 'BROKER_REJECT':
            detail = str(res.get('response', {}).get('return_msg') or '')
        elif reason == 'NETWORK':
            detail = str(res.get('error', ''))
        msg = (f"⛔ 정정 실패 [{reason}] · ord_no={row['ord_no']} · "
               f"[{row['account']}] {row['name']} {side_word} {new_qty}주 @ {new_price:,}원"
               + (f"\n사유: {detail}" if detail else ''))
        return {'ok': False, 'message': msg, 'detail': res}

    if res.get('dry_run'):
        msg = (f"🧪 [DRY-RUN] 정정 시뮬레이션 · ord_no={row['ord_no']} · "
               f"[{row['account']}] {row['name']} {side_word} "
               f"{row['order_qty']}주 @ {row['order_price']:,}원 "
               f"→ {new_qty}주 @ {new_price:,}원 ({row['exchange']})")
    else:
        new_ord = res.get('new_ord_no', '')
        msg = (f"✏️ 정정 접수 · 원주문 ord_no={row['ord_no']} → 정정 ord_no={new_ord} · "
               f"[{row['account']}] {row['name']} {side_word} "
               f"{row['order_qty']}주 @ {row['order_price']:,}원 "
               f"→ {new_qty}주 @ {new_price:,}원 ({row['exchange']})")
    return {'ok': True, 'message': msg, 'detail': res}


def modify_open_order_and_send(ord_no: Optional[str] = None,
                                 account: Optional[str] = None,
                                 qty: Optional[int] = None,
                                 price: Optional[int] = None,
                                 dry_run: bool = False) -> dict:
    res = modify_open_order(ord_no=ord_no, account=account, qty=qty, price=price,
                             dry_run=dry_run)
    if res.get('message'):
        send_telegram(res['message'], parse_mode=None)
    return res


def list_open_orders_text(account: Optional[str] = None) -> dict:
    """미체결 주문 리스트 — 단순 조회. 텔레그램 응답용."""
    sidecar.guard_or_raise()
    rows = _list_open_orders(account=account)
    if not rows:
        return {'ok': True, 'message': '미체결 주문 없음', 'rows': rows}
    return {'ok': True, 'message': _format_open_order_list(rows), 'rows': rows}


def amend_trade(qty: Optional[int] = None, price: Optional[int] = None,
                order_type: Optional[str] = None, budget: Optional[int] = None,
                routing_force: Optional[str] = None,
                symbol: Optional[str] = None, symbol_name: Optional[str] = None,
                account: Optional[str] = None) -> dict:
    """활성 카드의 수량/가격/주문방식/예산/라우팅/종목/계좌를 수정.

    수정 가능: qty, price, order_type, budget, routing_force, symbol, symbol_name, account.
    수정 불가 (새 카드로): side (매수↔매도).
    qty/price/budget 중 하나라도 0 이면 → 카드 취소 위임 (다른 비0 값과 동시 입력 시 거부).
    symbol 변경 시 symbol_name 도 같이 입력 필요. account 변경 시 본인↔가희 PIN 길이도 재조정.
    가드 12개 전부 재실행. 통과 시 PIN 재발급 + 만료 리셋, card_id 유지.
    """
    sidecar.guard_or_raise()
    _sweep_expired_and_notify()

    active = _pin_store.peek()
    if active is None or active.is_expired() or active.consumed:
        return {'ok': False,
                'message': card.format_rejected('NO_ACTIVE_CARD', '수정할 활성 카드가 없습니다')}

    # 0 입력 → 취소. 0 + non-zero 동시 입력은 거부 (의도 모호)
    numeric_inputs = {'qty': qty, 'price': price, 'budget': budget}
    has_zero = any(v == 0 for v in numeric_inputs.values() if v is not None)
    has_nonzero = any(v is not None and v != 0 for v in numeric_inputs.values())
    other_amend = (order_type is not None or routing_force is not None
                   or symbol is not None or symbol_name is not None or account is not None)
    if has_zero and (has_nonzero or other_amend):
        return {'ok': False,
                'message': card.format_rejected('AMBIGUOUS_AMEND',
                                                 '0(취소)과 다른 수정값을 동시에 줄 수 없습니다')}
    if has_zero:
        return cancel_active_card()

    if budget is not None and qty is not None:
        return {'ok': False,
                'message': card.format_rejected('AMBIGUOUS_INPUT',
                                                 'qty와 budget을 동시에 지정할 수 없음 — 하나만 입력')}

    p = active.payload
    new_account = account if account is not None else p['account']
    new_symbol = symbol if symbol is not None else p['symbol']
    new_symbol_name = (symbol_name if symbol_name is not None
                       else p.get('symbol_name', new_symbol))

    # account whitelist 검증
    whitelist = _limits()['accounts_whitelist']
    if new_account not in whitelist:
        return {'ok': False,
                'message': card.format_rejected('INVALID_ACCOUNT',
                                                 f'허용되지 않은 계좌: {new_account}. 가능: {whitelist}')}

    # symbol 6자리 숫자 형식 검증
    if not (isinstance(new_symbol, str) and len(new_symbol) == 6 and new_symbol.isdigit()):
        return {'ok': False,
                'message': card.format_rejected('INVALID_SYMBOL',
                                                 f'종목코드는 6자리 숫자여야 합니다 (입력: {new_symbol!r})')}

    # symbol 변경 시 symbol_name 동반 필수
    if symbol is not None and symbol != p['symbol'] and symbol_name is None:
        return {'ok': False,
                'message': card.format_rejected('MISSING_SYMBOL_NAME',
                                                 '종목 변경 시 symbol_name 도 같이 입력해야 합니다')}
    # 반대로 symbol_name 단독 변경 금지 — 카드 표시(이름)와 발주(코드) 불일치 차단
    if symbol_name is not None and symbol is None and symbol_name != p.get('symbol_name'):
        return {'ok': False,
                'message': card.format_rejected('ORPHAN_SYMBOL_NAME',
                                                 '종목명만 단독으로 바꿀 수 없습니다 — symbol(6자리 코드) 도 같이 입력')}

    new_qty = qty if qty is not None else p.get('qty')
    new_price = price if price is not None else p.get('price')
    new_order_type = order_type if order_type is not None else p.get('order_type')

    md = datasource.collect_market_data(new_account, new_symbol, p['side'], new_qty or 0)

    budget_conversion = None
    if budget is not None:
        conv = guards.convert_budget_to_qty(p['side'], budget, md.get('orderbook'),
                                              fallback_price=md.get('current_price'))
        if not conv['ok']:
            ledger.append('rejected', {'card_id': active.card_id, 'amend': True,
                                        'budget': budget, 'reason': conv['code'],
                                        'message': conv['message']})
            return {'ok': False, 'message': card.format_rejected(conv['code'], conv['message'])}
        new_qty = conv['qty']
        new_order_type = 'MARKET'
        new_price = None
        budget_conversion = {
            'budget': budget, 'qty': conv['qty'], 'ref_price': conv['ref_price'],
            'remainder': conv['remainder'], 'source': conv.get('source', 'orderbook'),
            'bumped': conv.get('bumped', False),
        }

    if new_order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
        return {'ok': False,
                'message': card.format_rejected('INVALID_ORDER_TYPE',
                                                 f'잘못된 주문방식: {new_order_type}')}

    # LIMIT 으로 전환할 땐 price 필수 (MARKET→LIMIT 또는 AGG→LIMIT 시 누락 차단)
    if new_order_type == 'LIMIT' and not new_price:
        return {'ok': False,
                'message': card.format_rejected('MISSING_LIMIT_PRICE',
                                                 'LIMIT 으로 변경하려면 price 도 같이 입력해야 합니다')}

    request = {
        'account': new_account, 'side': p['side'], 'symbol': new_symbol,
        'symbol_name': new_symbol_name,
        'qty': new_qty, 'order_type': new_order_type,
    }
    if new_order_type == 'LIMIT':
        request['price'] = new_price

    r = guards.validate_request(request, md)
    if not r.ok:
        ledger.append('rejected', {'card_id': active.card_id, 'amend': True,
                                    'account': new_account, 'symbol': new_symbol,
                                    'qty': new_qty, 'price': new_price,
                                    'reason': r.code, 'message': r.message})
        return {'ok': False, 'message': card.format_rejected(r.code, r.message)}

    state_eval = guards.evaluate_stock_state(md.get('stock_meta'))
    if not state_eval['result'].ok:
        sr = state_eval['result']
        ledger.append('rejected', {'card_id': active.card_id, 'amend': True,
                                    'qty': new_qty, 'price': new_price, 'reason': sr.code,
                                    'message': sr.message})
        return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
    state_warning = state_eval['warning']

    suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force)

    estimate = None
    final_price = new_price
    if new_order_type == 'MARKET':
        if md.get('orderbook'):
            estimate = guards.estimate_market_fill(p['side'], new_qty, md['orderbook'])
    elif new_order_type == 'AGGRESSIVE_LIMIT':
        agg_res = guards.aggressive_limit_price(p['side'], md.get('orderbook'),
                                                  fallback_price=md.get('current_price'))
        if not agg_res['ok']:
            ledger.append('rejected', {'card_id': active.card_id, 'amend': True,
                                        'qty': new_qty, 'reason': agg_res['code'],
                                        'message': agg_res['message']})
            return {'ok': False, 'message': card.format_rejected(agg_res['code'], agg_res['message'])}
        estimate = {'aggressive_price': agg_res['price'],
                    'source': agg_res['source'], 'ref_price': agg_res['ref_price']}
        final_price = agg_res['price']

    new_payload = {
        'account': new_account, 'side': p['side'], 'symbol': new_symbol,
        'symbol_name': new_symbol_name,
        'qty': new_qty, 'price': final_price, 'order_type': new_order_type,
        'routing_suffix': suffix,
    }

    pending = _pin_store.amend(new_payload, account_label=new_account)
    if pending is None:
        return {'ok': False,
                'message': card.format_rejected('NO_ACTIVE_CARD',
                                                 '수정할 활성 카드가 없습니다 (만료/소비)')}

    card_msg = card.format_card(request, md, pending.card_id, estimate, budget_conversion,
                                 state_warning, amended=True)
    pin_msg = card.format_pin_message(pending.pin)

    ledger.append('amended', {'card_id': pending.card_id, **new_payload})

    return {'ok': True, 'card_message': card_msg, 'pin_message': pin_msg,
            'card_id': pending.card_id, 'expiry_seconds': pending.expiry_seconds}


def amend_and_send(qty: Optional[int] = None, price: Optional[int] = None,
                   order_type: Optional[str] = None, budget: Optional[int] = None,
                   routing_force: Optional[str] = None,
                   symbol: Optional[str] = None, symbol_name: Optional[str] = None,
                   account: Optional[str] = None) -> dict:
    """amend_trade 후 카드+PIN 텔레그램 분리 발송. 0 입력 시 cancel + 텔레그램 발송."""
    res = amend_trade(qty, price, order_type, budget, routing_force,
                      symbol=symbol, symbol_name=symbol_name, account=account)
    if not res['ok']:
        send_telegram(res['message'], parse_mode=None)
        return res
    if 'card_message' in res:
        send_telegram(res['card_message'], parse_mode='Markdown')
        send_telegram(res['pin_message'], parse_mode=None)
    elif res.get('message'):
        # cancel 위임 결과 (card_message 없음)
        send_telegram(res['message'], parse_mode=None)
    return res


def expire_check() -> Optional[dict]:
    pending = _pin_store.peek()
    if pending is None:
        return None
    if pending.is_expired():
        cancelled = _pin_store.cancel()
        if cancelled:
            ledger.append('expired', {'card_id': cancelled.card_id, **cancelled.payload})
            return {'card_id': cancelled.card_id,
                    'message': card.format_expired(cancelled.card_id, cancelled.payload['side'])}
    return None


def start_expiry_watcher(send_func=None) -> None:
    """별도 스레드 — 1초마다 expire_check + 만료 시 send_func 으로 알림."""
    global _expiry_thread, _expiry_send
    _expiry_send = send_func or send_telegram
    if _expiry_thread and _expiry_thread.is_alive():
        return

    def _loop():
        while True:
            try:
                exp = expire_check()
                if exp and _expiry_send:
                    _expiry_send(exp['message'])
            except Exception as e:
                print(f'[expiry_watcher] {e}', file=sys.stderr)
            time.sleep(1)

    _expiry_thread = threading.Thread(target=_loop, name='order-expiry', daemon=True)
    _expiry_thread.start()


# ---- 단축어 / 명령 ----

def handle_command(command: str) -> dict:
    parts = command.strip().split()
    head = parts[0] if parts else ''
    if head == '/orders_off':
        sidecar.disable(' '.join(parts[1:]) or 'user command')
        return {'ok': True, 'message': '🚫 매매 비활성화. /orders_on 으로 재개.'}
    if head == '/orders_on':
        ok = sidecar.enable()
        return {'ok': True, 'message': '✅ 매매 활성화' if ok else '⚠️ 이미 활성화 상태'}
    if head == '/cancel':
        return cancel_active_card()
    if head == '/orders_status':
        return {'ok': True, 'message': json.dumps(status_text(), ensure_ascii=False, indent=2)}
    if head == '/positions':
        return {'ok': False,
                'message': '⚙️ /positions 는 stock_portfolio_report 모듈로 통합 예정'}
    if head == '/balance':
        return {'ok': False,
                'message': '⚙️ /balance 는 stock_portfolio_report 모듈로 통합 예정'}
    return {'ok': False, 'message': f'알 수 없는 명령: {head}'}


def _active_card_info(pending) -> dict:
    return {
        'card_id': pending.card_id,
        'account': pending.account_label,
        'side': pending.payload.get('side'),
        'symbol': pending.payload.get('symbol'),
        'symbol_name': pending.payload.get('symbol_name'),
        'qty': pending.payload.get('qty'),
        'price': pending.payload.get('price'),
        'order_type': pending.payload.get('order_type'),
        'expires_in': max(0, pending.expiry_seconds - int(time.time() - pending.issued_at)),
    }


def status_text() -> dict:
    sc = sidecar.status()
    pending = _pin_store.peek()
    out = {
        'sidecar_disabled': sc.get('disabled'),
        'sidecar_path': sc.get('path'),
        'agent_env_blocked': sc.get('agent_env_blocked'),
        'active_card': None,
    }
    if pending:
        out['active_card'] = _active_card_info(pending)
    return out


# ---- CLI ----

def _main(argv: list[str]) -> int:
    if not argv or argv[0] == 'status':
        print(json.dumps(status_text(), ensure_ascii=False, indent=2))
        return 0
    cmd = argv[0]
    if cmd == 'propose':
        if len(argv) < 7:
            print('usage: propose <account> <side> <symbol> <name> <qty|-> <type> [price] [routing] [--budget <won>] [--send]')
            return 2
        account, side, symbol, name, qty_s, otype = argv[1:7]
        price = None
        routing = None
        budget = None
        send = '--send' in argv[7:]
        extras = argv[7:]
        i = 0
        while i < len(extras):
            extra = extras[i]
            if extra == '--send':
                i += 1
                continue
            if extra == '--budget':
                if i + 1 >= len(extras):
                    print('--budget requires a value', file=sys.stderr)
                    return 2
                budget = int(extras[i + 1])
                i += 2
                continue
            if extra.isdigit():
                price = int(extra)
            else:
                routing = extra
            i += 1
        qty = None if qty_s == '-' else int(qty_s)
        fn = propose_and_send if send else propose_trade
        res = fn(account, side, symbol, name, qty, otype, price, routing, budget=budget)
        print(json.dumps(res, ensure_ascii=False, indent=2))
        return 0 if res['ok'] else 1
    if cmd == 'pin':
        if len(argv) < 2:
            print('usage: pin <input> [--live] [--send]')
            return 2
        dry_run = '--live' not in argv[2:]
        send = '--send' in argv[2:]
        fn = submit_with_pin_and_send if send else submit_with_pin
        res = fn(argv[1], dry_run=dry_run)
        print(json.dumps(res, ensure_ascii=False, indent=2))
        return 0 if res['ok'] else 1
    if cmd == 'cancel':
        send = '--send' in argv[1:]
        fn = cancel_active_card_and_send if send else cancel_active_card
        res = fn()
        print(json.dumps(res, ensure_ascii=False, indent=2))
        return 0 if res['ok'] else 1
    if cmd == 'amend':
        qty = None
        price = None
        order_type = None
        budget = None
        routing = None
        symbol = None
        symbol_name = None
        account = None
        send = '--send' in argv[1:]
        extras = argv[1:]
        i = 0
        while i < len(extras):
            extra = extras[i]
            if extra == '--send':
                i += 1; continue
            if extra == '--qty':
                qty = int(extras[i + 1]); i += 2; continue
            if extra == '--price':
                price = int(extras[i + 1]); i += 2; continue
            if extra == '--order-type':
                order_type = extras[i + 1]; i += 2; continue
            if extra == '--budget':
                budget = int(extras[i + 1]); i += 2; continue
            if extra == '--routing':
                routing = extras[i + 1]; i += 2; continue
            if extra == '--symbol':
                symbol = extras[i + 1]; i += 2; continue
            if extra == '--symbol-name':
                symbol_name = extras[i + 1]; i += 2; continue
            if extra == '--account':
                account = extras[i + 1]; i += 2; continue
            print(f'unknown amend arg: {extra}', file=sys.stderr)
            return 2
        if all(v is None for v in (qty, price, order_type, budget, routing,
                                      symbol, symbol_name, account)):
            print('usage: amend [--qty N] [--price W] [--order-type LIMIT|MARKET|AGGRESSIVE_LIMIT] [--budget W] [--routing AL|NX|KRX] [--symbol CODE] [--symbol-name NAME] [--account 일반|ISA|가희_일반|가희_ISA] [--send]\n  qty/price/budget 중 하나라도 0 → 취소', file=sys.stderr)
            return 2
        fn = amend_and_send if send else amend_trade
        res = fn(qty=qty, price=price, order_type=order_type, budget=budget,
                 routing_force=routing, symbol=symbol, symbol_name=symbol_name,
                 account=account)
        print(json.dumps(res, ensure_ascii=False, indent=2))
        return 0 if res['ok'] else 1
    if cmd == 'cmd':
        if len(argv) < 2:
            print('usage: cmd </command>')
            return 2
        res = handle_command(' '.join(argv[1:]))
        print(json.dumps(res, ensure_ascii=False, indent=2))
        return 0 if res['ok'] else 1
    if cmd == 'open-orders':
        # open-orders [--account A]
        account = None
        extras = argv[1:]
        i = 0
        while i < len(extras):
            if extras[i] == '--account':
                account = extras[i + 1]; i += 2; continue
            print(f'unknown open-orders arg: {extras[i]}', file=sys.stderr)
            return 2
        res = list_open_orders_text(account=account)
        print(json.dumps(res, ensure_ascii=False, indent=2))
        return 0 if res['ok'] else 1
    if cmd == 'cancel-order':
        # cancel-order [--ord-no N] [--account A] [--live] [--send]
        ord_no = None
        account = None
        send = '--send' in argv[1:]
        live = '--live' in argv[1:]
        extras = argv[1:]
        i = 0
        while i < len(extras):
            if extras[i] in ('--send', '--live'):
                i += 1; continue
            if extras[i] == '--ord-no':
                ord_no = extras[i + 1]; i += 2; continue
            if extras[i] == '--account':
                account = extras[i + 1]; i += 2; continue
            print(f'unknown cancel-order arg: {extras[i]}', file=sys.stderr)
            return 2
        fn = cancel_open_order_and_send if send else cancel_open_order
        res = fn(ord_no=ord_no, account=account, dry_run=not live)
        print(json.dumps(res, ensure_ascii=False, indent=2))
        return 0 if res['ok'] else 1
    if cmd == 'modify-order':
        # modify-order [--ord-no N] [--account A] [--qty Q] [--price P] [--live] [--send]
        ord_no = None
        account = None
        qty = None
        price = None
        send = '--send' in argv[1:]
        live = '--live' in argv[1:]
        extras = argv[1:]
        i = 0
        while i < len(extras):
            if extras[i] in ('--send', '--live'):
                i += 1; continue
            if extras[i] == '--ord-no':
                ord_no = extras[i + 1]; i += 2; continue
            if extras[i] == '--account':
                account = extras[i + 1]; i += 2; continue
            if extras[i] == '--qty':
                qty = int(extras[i + 1]); i += 2; continue
            if extras[i] == '--price':
                price = int(extras[i + 1]); i += 2; continue
            print(f'unknown modify-order arg: {extras[i]}', file=sys.stderr)
            return 2
        if qty is None and price is None:
            print('usage: modify-order [--ord-no N] [--account A] --qty Q | --price P  [--live] [--send]', file=sys.stderr)
            return 2
        fn = modify_open_order_and_send if send else modify_open_order
        res = fn(ord_no=ord_no, account=account, qty=qty, price=price, dry_run=not live)
        print(json.dumps(res, ensure_ascii=False, indent=2))
        return 0 if res['ok'] else 1
    print(f'unknown CLI command: {cmd}', file=sys.stderr)
    return 2


if __name__ == '__main__':
    sys.exit(_main(sys.argv[1:]))
