"""handler.propose_trade 의 budget 입력 통합 회귀 테스트.

datasource·sidecar·pin_store·ledger 를 mock 해서 budget 환산 후 12가드 체인이 그대로 흘러가는지 검증.
"""

from __future__ import annotations

import unittest
from datetime import datetime, timedelta, timezone
from unittest import mock

from orders import handler, ledger

KST = timezone(timedelta(hours=9))


def _md(now=None, orderbook=None):
    return {
        'now': now or datetime(2026, 5, 6, 10, 0, tzinfo=KST),
        'is_holiday': False,
        'nxt_eligible': True,
        'current_price': 75050,
        'prev_close': 75000,
        'upper_limit': 100000,
        'lower_limit': 50000,
        'halt': False,
        'vi': False,
        'orderbook': orderbook if orderbook is not None else {
            'asks': [{'price': 75100, 'qty': 200}, {'price': 75200, 'qty': 150}],
            'bids': [{'price': 75000, 'qty': 180}, {'price': 74900, 'qty': 220}],
        },
        'broker_executions': [],
        'broker_query_error': None,
        'balance_d2': 5_000_000,
    }


class HandlerBudgetTests(unittest.TestCase):
    def setUp(self):
        # 모든 외부 의존 mock — budget 환산 → guards → pin_store 흐름만 검증
        self.collect = mock.patch.object(handler.datasource, 'collect_market_data',
                                          return_value=_md()).start()
        mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None).start()
        mock.patch.object(handler, '_sweep_expired_and_notify', return_value=None).start()
        mock.patch.object(handler.ledger, 'append', return_value=None).start()
        mock.patch.object(ledger, 'last_terminal_event', return_value=None).start()
        mock.patch.object(ledger, 'last_event_for_symbol', return_value=None).start()
        # pin_store.issue 가 카드 발행 — 단순한 fake 객체 반환
        fake_pending = mock.MagicMock()
        fake_pending.card_id = 'TEST'
        fake_pending.pin = '1234'
        fake_pending.expiry_seconds = 120
        fake_pending.account_label = '일반'
        mock.patch.object(handler._pin_store, 'issue', return_value=fake_pending).start()
        self.addCleanup(mock.patch.stopall)

    def test_budget_market_buy_succeeds_with_correct_qty(self):
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=None, order_type='MARKET', budget=1_000_000,
        )
        self.assertTrue(res['ok'], msg=res.get('message'))
        # ref_price=75,100원 ≤ 30만원 → BUY +1 적용. 13 → 14주.
        call_args = handler._pin_store.issue.call_args
        self.assertIsNotNone(call_args)
        payload = call_args[0][1]
        self.assertEqual(payload['qty'], 14)
        self.assertEqual(payload['order_type'], 'MARKET')

    def test_budget_too_small_rejects_before_card(self):
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=None, order_type='MARKET', budget=50_000,
        )
        self.assertFalse(res['ok'])
        self.assertIn('BUDGET_TOO_SMALL', res['message'])
        # 환산 거부 시 카드 발행 없어야 함
        handler._pin_store.issue.assert_not_called()

    def test_budget_invalid_zero_rejects(self):
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=None, order_type='MARKET', budget=0,
        )
        self.assertFalse(res['ok'])
        self.assertIn('BUDGET_INVALID', res['message'])

    def test_qty_and_budget_both_set_rejected(self):
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=5, order_type='MARKET', budget=1_000_000,
        )
        self.assertFalse(res['ok'])
        self.assertIn('AMBIGUOUS_INPUT', res['message'])

    def test_neither_qty_nor_budget_rejected(self):
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=None, order_type='MARKET', budget=None,
        )
        self.assertFalse(res['ok'])
        self.assertIn('MISSING_QTY', res['message'])

    def test_budget_sell_uses_bid1(self):
        sell_md = _md()
        sell_md.pop('balance_d2', None)
        sell_md['position_qty'] = 100
        self.collect.return_value = sell_md
        res = handler.propose_trade(
            account='ISA', side='SELL', symbol='005930', symbol_name='삼성전자',
            qty=None, order_type='MARKET', budget=500_000,
        )
        self.assertTrue(res['ok'], msg=res.get('message'))
        payload = handler._pin_store.issue.call_args[0][1]
        self.assertEqual(payload['qty'], 6)  # 500_000 // 75_000
        self.assertEqual(payload['side'], 'SELL')

    def test_budget_passes_balance_guard_when_sufficient(self):
        # 잔고 5,000,000원 / 환산 13주 × 75,100 ≒ 976,300원 → 통과해야 함
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=None, order_type='MARKET', budget=1_000_000,
        )
        self.assertTrue(res['ok'])

    def test_budget_buy_fallback_uses_current_price_when_orderbook_missing(self):
        """호가창 비어도 current_price 로 환산해서 카드 발행 통과."""
        md = _md()
        md['orderbook'] = None
        md['current_price'] = 75050
        self.collect.return_value = md
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=None, order_type='LIMIT', price=75000, budget=1_000_000,
        )
        self.assertTrue(res['ok'], msg=res.get('message'))
        payload = handler._pin_store.issue.call_args[0][1]
        # ref_price=75,050(fallback) ≤ 30만원 → BUY +1 적용 → 14주
        self.assertEqual(payload['qty'], 14)

    def test_aggressive_limit_buy_fallback_when_orderbook_missing(self):
        """AGGRESSIVE_LIMIT BUY — 호가창 없어도 current_price+1tick 으로 카드 발행."""
        md = _md()
        md['orderbook'] = None
        md['current_price'] = 75050
        self.collect.return_value = md
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=10, order_type='AGGRESSIVE_LIMIT', budget=None,
        )
        self.assertTrue(res['ok'], msg=res.get('message'))
        payload = handler._pin_store.issue.call_args[0][1]
        self.assertEqual(payload['order_type'], 'AGGRESSIVE_LIMIT')
        self.assertEqual(payload['price'], 75150)  # 75050 + 100tick

    def test_aggressive_limit_buy_no_orderbook_no_quote_rejects(self):
        md = _md()
        md['orderbook'] = None
        md['current_price'] = 0
        self.collect.return_value = md
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=10, order_type='AGGRESSIVE_LIMIT', budget=None,
        )
        self.assertFalse(res['ok'])
        self.assertIn('NO_ORDERBOOK', res['message'])

    def test_budget_blocked_by_balance_guard_when_insufficient(self):
        # 잔고를 50만원으로 낮추고 100만원 예산 시도 → 환산 후 잔고 가드에서 거부
        self.collect.return_value = _md()
        self.collect.return_value['balance_d2'] = 500_000
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=None, order_type='MARKET', budget=1_000_000,
        )
        self.assertFalse(res['ok'])
        self.assertIn('INSUFFICIENT_BALANCE', res['message'])

    def test_budget_buy_no_bump_when_ref_price_above_threshold(self):
        # ref_price 가 30만원 초과면 +1 정책 미적용 → floor 유지
        md = _md()
        md['orderbook'] = {
            'asks': [{'price': 350_000, 'qty': 50}, {'price': 350_500, 'qty': 30}],
            'bids': [{'price': 349_500, 'qty': 40}, {'price': 349_000, 'qty': 60}],
        }
        md['balance_d2'] = 50_000_000
        self.collect.return_value = md
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=None, order_type='MARKET', budget=1_000_000,
        )
        self.assertTrue(res['ok'], msg=res.get('message'))
        payload = handler._pin_store.issue.call_args[0][1]
        # 1,000,000 // 350,000 = 2 (remainder 300,000) — but ref_price > 300,000 → bump 안 함
        self.assertEqual(payload['qty'], 2)

    def test_budget_buy_no_bump_when_remainder_zero(self):
        # 예산이 ref_price 의 정확한 배수면 remainder=0 → +1 안 함
        md = _md()
        md['orderbook'] = {
            'asks': [{'price': 100_000, 'qty': 50}, {'price': 100_100, 'qty': 30}],
            'bids': [{'price': 99_900, 'qty': 40}, {'price': 99_800, 'qty': 60}],
        }
        md['balance_d2'] = 50_000_000
        self.collect.return_value = md
        res = handler.propose_trade(
            account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
            qty=None, order_type='MARKET', budget=1_000_000,
        )
        self.assertTrue(res['ok'], msg=res.get('message'))
        payload = handler._pin_store.issue.call_args[0][1]
        # 1,000,000 // 100,000 = 10 정확히 — bump 안 함
        self.assertEqual(payload['qty'], 10)

    def test_budget_sell_never_bumps_even_under_threshold(self):
        # SELL 은 ref_price 가 30만원 이하여도 +1 안 함 (보유수량 초과 매도 방지)
        sell_md = _md()
        sell_md.pop('balance_d2', None)
        sell_md['position_qty'] = 100
        self.collect.return_value = sell_md
        res = handler.propose_trade(
            account='ISA', side='SELL', symbol='005930', symbol_name='삼성전자',
            qty=None, order_type='MARKET', budget=500_000,
        )
        self.assertTrue(res['ok'], msg=res.get('message'))
        payload = handler._pin_store.issue.call_args[0][1]
        # 500,000 // 75,000 = 6 (remainder 50,000) — SELL 이라 bump 안 함
        self.assertEqual(payload['qty'], 6)


if __name__ == '__main__':
    unittest.main(verbosity=2)
