import os
import json
import asyncio
import hashlib
import logging
import aiohttp
import re
import secrets
import time
from functools import wraps
from html import unescape
from io import BytesIO
from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.error import NetworkError, TelegramError
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
from datetime import datetime, timedelta
from upbit_trader import UpbitTrader, reconstruct_virtual_account
from backtest import run_backtest, format_backtest_result
from app_master_service import (
    APP_LABELS,
    app_master_service,
    app_stat_categories,
    format_app_event_message,
    format_app_stats_message,
    list_apps,
    resolve_app_key,
)
from official_mail_service import official_mail_service
from persistence import (
    save_session,
    save_alerts,
    clear_positions,
    load_session,
    load_positions,
    load_alerts,
    save_bot_state,
    load_bot_state,
    has_app_event_delivery,
    mark_app_event_delivered,
    create_notification_invite,
    consume_notification_invite,
    save_notification_subscriber,
    load_notification_subscribers,
    delete_notification_subscriber,
)

logger = logging.getLogger(__name__)

BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
DEFAULT_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
CHAT_ID_STATE_KEY = "default_chat_id"
_runtime_chat_id = str(DEFAULT_CHAT_ID).strip()
_missing_chat_id_warned = False
_configured_allowed_chat_ids = {
    item.strip()
    for item in os.environ.get("TELEGRAM_ALLOWED_CHAT_IDS", "").split(",")
    if item.strip()
}
if DEFAULT_CHAT_ID:
    _configured_allowed_chat_ids.add(str(DEFAULT_CHAT_ID).strip())

APP_EVENT_CHECKPOINT_STATE_KEY = "app_event_last_checked_at"


def _positive_int_env(name: str, fallback: int, minimum: int = 1) -> int:
    try:
        return max(minimum, int(os.environ.get(name, fallback)))
    except (TypeError, ValueError):
        return fallback


APP_EVENT_POLL_SECONDS = _positive_int_env(
    "APP_MASTER_EVENT_POLL_SECONDS",
    10,
    minimum=5,
)
TELEGRAM_LONG_POLL_SECONDS = _positive_int_env(
    "TELEGRAM_LONG_POLL_SECONDS",
    30,
    minimum=10,
)
TELEGRAM_LONG_POLL_READ_TIMEOUT = _positive_int_env(
    "TELEGRAM_LONG_POLL_READ_TIMEOUT",
    TELEGRAM_LONG_POLL_SECONDS + 15,
    minimum=TELEGRAM_LONG_POLL_SECONDS + 5,
)
TELEGRAM_CONNECT_TIMEOUT = _positive_int_env(
    "TELEGRAM_CONNECT_TIMEOUT",
    10,
    minimum=5,
)

trader = UpbitTrader()
_send_counter = 0  # 모니터 메시지 위치 추적용
BINANCE_TICKER_URL = "https://api.binance.com/api/v3/ticker/price"
BINANCE_EXCHANGE_URL = "https://api.binance.com/api/v3/exchangeInfo"
UPBIT_MARKET_URL = "https://api.upbit.com/v1/market/all"
UPBIT_TICKER_URL = "https://api.upbit.com/v1/ticker"
HANA_EXCHANGE_QUERY_URL = "https://www.kebhana.com/cms/rate/wpfxd651_01i_01.do"
HANA_EXCHANGE_CACHE_SECONDS = 300
YAHOO_FINANCE_SEARCH_URL = "https://query1.finance.yahoo.com/v1/finance/search"
YAHOO_FINANCE_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
KRX_CORP_LIST_URL = "https://kind.krx.co.kr/corpgeneral/corpList.do"
KRX_STOCK_CACHE_SECONDS = 21600
ALERT_POLL_SECONDS = _positive_int_env("ALERT_POLL_SECONDS", 10, minimum=1)
YAHOO_REQUEST_HEADERS = {"User-Agent": "Mozilla/5.0"}
YAHOO_SUPPORTED_US_EXCHANGES = {"NMS", "NYQ", "ASE", "PCX", "NCM", "NGM"}
YAHOO_SUPPORTED_KR_SUFFIXES = (".KS", ".KQ")

_upbit_markets_cache: list[dict] | None = None
_upbit_cache_time: float = 0
_rate_limit_until: float = 0  # 429 발생 시 대기 시각 (time.time 기준)
_hana_usd_krw_cache: dict | None = None
_krx_stocks_cache: list[dict] | None = None
_krx_stocks_cache_time: float = 0
_notification_subscribers: dict[str, dict] = {}


def _normalize_chat_id(value) -> str:
    if value is None:
        return ""
    return str(value).strip()


def _set_default_chat_id(chat_id, source: str = "runtime") -> None:
    global _runtime_chat_id, _missing_chat_id_warned
    normalized = _normalize_chat_id(chat_id)
    if not normalized:
        return
    if _runtime_chat_id != normalized:
        logger.info(f"Default chat_id updated from {source}: {normalized}")
    _runtime_chat_id = normalized
    _missing_chat_id_warned = False


def _get_default_chat_id() -> str:
    global _missing_chat_id_warned
    chat_id = _normalize_chat_id(_runtime_chat_id or DEFAULT_CHAT_ID)
    if chat_id:
        _missing_chat_id_warned = False
        return chat_id
    if not _missing_chat_id_warned:
        logger.warning("No default chat_id set yet. Send any bot command once to bind notifications.")
        _missing_chat_id_warned = True
    return ""


def _get_operational_notification_targets() -> list[str]:
    targets = []
    owner_chat_id = _get_default_chat_id()
    if owner_chat_id:
        targets.append(owner_chat_id)
    for chat_id in _notification_subscribers:
        normalized = _normalize_chat_id(chat_id)
        if normalized and normalized not in targets:
            targets.append(normalized)
    return targets


def _is_authorized_chat(chat_id: str) -> bool:
    if _configured_allowed_chat_ids:
        return chat_id in _configured_allowed_chat_ids
    current_chat_id = _normalize_chat_id(_runtime_chat_id or DEFAULT_CHAT_ID)
    return not current_chat_id or chat_id == current_chat_id


async def _remember_chat_context(update: Update | None) -> bool:
    if not update or not update.effective_chat:
        return False
    chat_id = _normalize_chat_id(update.effective_chat.id)
    if not chat_id:
        return False
    if not _is_authorized_chat(chat_id):
        logger.warning("Rejected Telegram command from unauthorized chat_id=%s", chat_id)
        return False
    if chat_id != _runtime_chat_id:
        _set_default_chat_id(chat_id, source="telegram command")
        await save_bot_state(CHAT_ID_STATE_KEY, chat_id)
    return True


def _bind_chat_context(handler):
    @wraps(handler)
    async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE):
        if not await _remember_chat_context(update):
            if update.callback_query:
                await update.callback_query.answer(
                    "이 봇을 사용할 권한이 없습니다.",
                    show_alert=True,
                )
            elif update.effective_message:
                await update.effective_message.reply_text("이 봇을 사용할 권한이 없습니다.")
            return None
        return await handler(update, context)

    return wrapped


async def _safe_telegram_call(coro):
    """텔레그램 API 호출 래퍼 - 429 발생 시 자동 대기"""
    import time
    global _rate_limit_until
    now = time.time()
    if now < _rate_limit_until:
        wait = _rate_limit_until - now
        logger.warning(f"Rate limit active, skipping ({wait:.0f}s remaining)")
        return None
    try:
        return await coro
    except Exception as e:
        err_str = str(e).lower()
        if "flood control" in err_str or "429" in err_str:
            # Retry in N seconds 파싱
            import re
            match = re.search(r'retry in (\d+)', err_str)
            retry_secs = int(match.group(1)) if match else 60
            _rate_limit_until = time.time() + retry_secs
            logger.warning(f"Telegram 429 - waiting {retry_secs}s")
            return None
        raise


async def _get_upbit_markets() -> list[dict]:
    """업비트 KRW 마켓 코인 목록 (캐시 1시간)"""
    global _upbit_markets_cache, _upbit_cache_time
    import time
    now = time.time()
    if _upbit_markets_cache and now - _upbit_cache_time < 3600:
        return _upbit_markets_cache

    session = await _get_session()
    try:
        async with session.get(UPBIT_MARKET_URL, params={"is_details": "false"}) as resp:
            if resp.status == 200:
                data = await resp.json()
                _upbit_markets_cache = [
                    m for m in data if m["market"].startswith("KRW-")
                ]
                _upbit_cache_time = now
                return _upbit_markets_cache
    except Exception as e:
        logger.error(f"Error fetching upbit markets: {e}")
    return _upbit_markets_cache or []

# In-memory alert storage
# {chat_id: [{"id": int, "symbol": str, "target_price": float, "direction": "above"|"below"}, ...]}
_alerts: dict[int, list[dict]] = {}
_alert_id_counter = 0
_http_session: aiohttp.ClientSession | None = None


def _next_alert_id() -> int:
    global _alert_id_counter
    _alert_id_counter += 1
    return _alert_id_counter


def _normalize_asset_code(symbol: str) -> str:
    asset = symbol.upper().strip()
    if asset.startswith("KRW-"):
        asset = asset[4:]
    if asset.endswith("USDT"):
        asset = asset[:-4]
    if re.fullmatch(r"\d{6}\.(KS|KQ)", asset):
        asset = asset.split(".", 1)[0]
    return asset


def _normalize_symbol(symbol: str) -> str:
    return f"{_normalize_asset_code(symbol)}USDT"


def _format_krw(value: float) -> str:
    abs_value = abs(value)
    if abs_value >= 100:
        return f"{value:,.0f}원"
    if abs_value >= 1:
        return f"{value:,.2f}원"
    if abs_value >= 0.1:
        return f"{value:,.4f}원"
    return f"{value:,.6f}원"


def _format_usd(value: float) -> str:
    return f"${value:,.4f}"


def _format_price_by_unit(value: float, unit: str) -> str:
    if unit == "KRW":
        return _format_krw(value)
    return _format_usd(value)


def _contains_hangul(text: str) -> bool:
    return any("\uac00" <= ch <= "\ud7a3" for ch in text)


def _is_crypto_symbol(symbol: str) -> bool:
    return symbol.startswith("KRW-") or symbol.endswith("USDT")


def _is_supported_stock_quote_item(item: dict) -> bool:
    if item.get("quoteType") != "EQUITY":
        return False
    symbol = str(item.get("symbol") or "").upper()
    exchange = str(item.get("exchange") or "").upper()
    return symbol.endswith(YAHOO_SUPPORTED_KR_SUFFIXES) or exchange in YAHOO_SUPPORTED_US_EXCHANGES


def _market_to_yahoo_suffix(market: str) -> str | None:
    if market in {"코스피", "유가증권", "유가"}:
        return ".KS"
    if market == "코스닥":
        return ".KQ"
    return None


async def _get_session() -> aiohttp.ClientSession:
    global _http_session
    if _http_session is None or _http_session.closed:
        _http_session = aiohttp.ClientSession()
    return _http_session


async def _get_upbit_prices(markets: list[str]) -> dict[str, float]:
    if not markets:
        return {}
    session = await _get_session()
    prices: dict[str, float] = {}
    chunk_size = 50
    for i in range(0, len(markets), chunk_size):
        chunk = markets[i:i + chunk_size]
        try:
            async with session.get(
                UPBIT_TICKER_URL,
                params={"markets": ",".join(chunk)},
            ) as resp:
                if resp.status == 200:
                    data = await resp.json()
                    for item in data:
                        prices[item["market"]] = float(item["trade_price"])
        except Exception as e:
            logger.error(f"Error fetching upbit prices: {e}")
        if i + chunk_size < len(markets):
            await asyncio.sleep(0.05)
    return prices


async def _get_krx_stocks() -> list[dict]:
    global _krx_stocks_cache, _krx_stocks_cache_time
    now = time.time()
    if _krx_stocks_cache and now - _krx_stocks_cache_time < KRX_STOCK_CACHE_SECONDS:
        return _krx_stocks_cache

    session = await _get_session()
    try:
        async with session.get(
            KRX_CORP_LIST_URL,
            params={"method": "download", "searchType": "13"},
            headers=YAHOO_REQUEST_HEADERS,
        ) as resp:
            if resp.status != 200:
                return _krx_stocks_cache or []
            html = await resp.text(encoding="euc-kr", errors="ignore")
    except Exception as e:
        logger.error(f"Error fetching KRX stock list: {e}")
        return _krx_stocks_cache or []

    rows = re.findall(r"<tr>(.*?)</tr>", html, re.S)
    if not rows:
        return _krx_stocks_cache or []

    stocks: list[dict] = []
    for row in rows[1:]:
        cells = re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", row, re.S)
        if len(cells) < 3:
            continue
        values = [
            unescape(re.sub(r"<[^>]+>", "", cell).replace("&nbsp;", " ").strip())
            for cell in cells
        ]
        company_name = values[0]
        market = values[1]
        code = values[2]
        if not re.fullmatch(r"\d{6}", code):
            continue
        suffix = _market_to_yahoo_suffix(market)
        if not suffix:
            continue
        market_label = "코스피" if market == "유가" else market
        stocks.append(
            {
                "symbol": f"{code}{suffix}",
                "display_symbol": code,
                "company_name": company_name,
                "market": market_label,
                "code": code,
            }
        )

    _krx_stocks_cache = stocks
    _krx_stocks_cache_time = now
    return stocks


async def _get_price(symbol: str) -> float | None:
    session = await _get_session()
    try:
        async with session.get(BINANCE_TICKER_URL, params={"symbol": symbol}) as resp:
            if resp.status == 200:
                data = await resp.json()
                return float(data["price"])
    except Exception as e:
        logger.error(f"Error fetching price for {symbol}: {e}")
    return None


async def _search_symbols(keyword: str) -> list[str]:
    """바이낸스에서 USDT 마켓 심볼 검색"""
    session = await _get_session()
    try:
        async with session.get(BINANCE_EXCHANGE_URL, params={"permissions": "SPOT"}) as resp:
            if resp.status == 200:
                data = await resp.json()
                keyword = keyword.upper()
                results = []
                for s in data["symbols"]:
                    if s["quoteAsset"] == "USDT" and s["status"] == "TRADING":
                        base = s["baseAsset"]
                        if keyword in base:
                            results.append(base)
                results.sort(key=lambda x: (x != keyword, len(x), x))
                return results[:30]
    except Exception as e:
        logger.error(f"Error searching symbols: {e}")
    return []


async def _get_prices(symbols: list[str]) -> dict[str, float]:
    if not symbols:
        return {}
    session = await _get_session()
    prices = {}
    try:
        symbols_param = json.dumps(symbols)
        async with session.get(BINANCE_TICKER_URL, params={"symbols": symbols_param}) as resp:
            if resp.status == 200:
                data = await resp.json()
                for item in data:
                    prices[item["symbol"]] = float(item["price"])
    except Exception as e:
        logger.error(f"Error fetching prices: {e}")
    return prices


async def _search_krx_stocks(keyword: str) -> list[dict]:
    stocks = await _get_krx_stocks()
    if not stocks:
        return []

    keyword = keyword.strip()
    keyword_upper = keyword.upper()
    matches = []
    for stock in stocks:
        company_name = stock["company_name"]
        display_symbol = stock["display_symbol"]
        if (
            keyword == company_name
            or keyword_upper == display_symbol
            or keyword in company_name
            or keyword_upper in display_symbol
        ):
            exact = keyword == company_name or keyword_upper == display_symbol
            prefix = company_name.startswith(keyword) or display_symbol.startswith(keyword_upper)
            matches.append(
                (
                    0 if exact else 1,
                    0 if prefix else 1,
                    company_name,
                    stock,
                )
            )

    matches.sort(key=lambda item: item[:3])
    return [item[3] for item in matches[:20]]


async def _search_yahoo_stocks(keyword: str) -> list[dict]:
    keyword = keyword.strip()
    if not keyword or _contains_hangul(keyword):
        return []

    session = await _get_session()
    try:
        async with session.get(
            YAHOO_FINANCE_SEARCH_URL,
            params={"q": keyword},
            headers=YAHOO_REQUEST_HEADERS,
        ) as resp:
            if resp.status != 200:
                return []
            data = await resp.json()
    except Exception as e:
        logger.error(f"Error searching stocks: {e}")
        return []

    keyword_upper = keyword.upper()
    results = []
    seen = set()
    for item in data.get("quotes", []):
        if not _is_supported_stock_quote_item(item):
            continue
        symbol = str(item.get("symbol") or "").upper()
        if not symbol or symbol in seen:
            continue
        seen.add(symbol)
        display_symbol = _normalize_asset_code(symbol)
        company_name = item.get("longname") or item.get("shortname") or symbol
        market = item.get("exchDisp") or item.get("exchange") or ""
        exact = keyword_upper == symbol or keyword_upper == display_symbol
        prefix = symbol.startswith(keyword_upper) or display_symbol.startswith(keyword_upper)
        results.append(
            (
                0 if exact else 1,
                0 if prefix else 1,
                len(symbol),
                {
                    "symbol": symbol,
                    "display_symbol": display_symbol,
                    "company_name": company_name,
                    "market": market,
                },
            )
        )

    results.sort(key=lambda item: item[:3])
    return [item[3] for item in results[:20]]


async def _search_stocks(keyword: str) -> list[dict]:
    results: list[dict] = []
    seen = set()

    if _contains_hangul(keyword) or any(ch.isdigit() for ch in keyword):
        for item in await _search_krx_stocks(keyword):
            if item["symbol"] in seen:
                continue
            seen.add(item["symbol"])
            results.append(item)

    if not _contains_hangul(keyword):
        for item in await _search_yahoo_stocks(keyword):
            if item["symbol"] in seen:
                continue
            seen.add(item["symbol"])
            results.append(item)

    return results[:20]


async def _resolve_stock_candidate(query: str) -> dict | None:
    query = query.strip()
    query_upper = query.upper()

    if re.fullmatch(r"\d{6}\.(KS|KQ)", query_upper):
        return {
            "symbol": query_upper,
            "display_symbol": query_upper.split(".", 1)[0],
            "company_name": None,
            "market": "Korea",
        }

    if re.fullmatch(r"\d{6}", query_upper):
        krx_matches = await _search_krx_stocks(query_upper)
        for item in krx_matches:
            if item["display_symbol"] == query_upper:
                return item
        if len(krx_matches) == 1:
            return krx_matches[0]

    if _contains_hangul(query):
        krx_matches = await _search_krx_stocks(query)
        exact_name_matches = [item for item in krx_matches if item["company_name"] == query]
        if exact_name_matches:
            return exact_name_matches[0]
        if len(krx_matches) == 1:
            return krx_matches[0]
        return None

    direct_symbol = re.sub(r"\s+", "", query_upper)
    if re.fullmatch(r"[A-Z0-9.\-]{1,15}", direct_symbol):
        return {
            "symbol": direct_symbol,
            "display_symbol": _normalize_asset_code(direct_symbol),
            "company_name": None,
            "market": None,
        }

    yahoo_matches = await _search_yahoo_stocks(query)
    exact_symbol_matches = [
        item for item in yahoo_matches
        if item["symbol"] == query_upper or item["display_symbol"] == query_upper
    ]
    if exact_symbol_matches:
        return exact_symbol_matches[0]
    exact_name_matches = [
        item for item in yahoo_matches
        if str(item.get("company_name") or "").upper() == query_upper
    ]
    if exact_name_matches:
        return exact_name_matches[0]
    if len(yahoo_matches) == 1:
        return yahoo_matches[0]
    return None


async def _get_stock_quote(symbol: str) -> dict | None:
    session = await _get_session()
    try:
        async with session.get(
            YAHOO_FINANCE_CHART_URL.format(symbol=symbol),
            params={"interval": "1d", "range": "5d"},
            headers=YAHOO_REQUEST_HEADERS,
        ) as resp:
            if resp.status != 200:
                return None
            data = await resp.json()
    except Exception as e:
        logger.error(f"Error fetching stock quote for {symbol}: {e}")
        return None

    result = (data.get("chart") or {}).get("result") or []
    if not result:
        return None
    meta = result[0].get("meta") or {}
    if str(meta.get("instrumentType") or "").upper() != "EQUITY":
        return None

    price = meta.get("regularMarketPrice")
    if price is None:
        price = meta.get("chartPreviousClose")
    if price is None:
        return None

    resolved_symbol = str(meta.get("symbol") or symbol).upper()
    return {
        "symbol": resolved_symbol,
        "display_symbol": _normalize_asset_code(resolved_symbol),
        "company_name": meta.get("longName") or meta.get("shortName") or resolved_symbol,
        "currency": str(meta.get("currency") or "").upper(),
        "price": float(price),
        "market": meta.get("fullExchangeName") or meta.get("exchangeName") or "",
    }


async def _get_stock_quotes(symbols: list[str]) -> dict[str, dict]:
    if not symbols:
        return {}
    results = await asyncio.gather(
        *[_get_stock_quote(symbol) for symbol in symbols],
        return_exceptions=True,
    )
    quotes: dict[str, dict] = {}
    for symbol, item in zip(symbols, results):
        if isinstance(item, Exception):
            logger.error(f"Error resolving stock quote for {symbol}: {item}")
            continue
        if item is not None:
            quotes[symbol] = item
    return quotes


async def _find_upbit_market(asset_code: str) -> str | None:
    market = f"KRW-{asset_code}"
    markets = await _get_upbit_markets()
    for item in markets:
        if item["market"] == market:
            return market
    return None


async def _get_hana_usd_krw_rate() -> dict | None:
    global _hana_usd_krw_cache
    now = datetime.now()
    if _hana_usd_krw_cache:
        age = (now - _hana_usd_krw_cache["fetched_at"]).total_seconds()
        if age < HANA_EXCHANGE_CACHE_SECONDS:
            return _hana_usd_krw_cache

    session = await _get_session()
    body = {
        "curCd": "USD",
        "tmpInqStrDt": now.strftime("%Y-%m-%d"),
        "pbldDvCd": "3",
        "inqStrDt": now.strftime("%Y%m%d"),
        "inqKindCd": "1",
    }

    try:
        async with session.post(HANA_EXCHANGE_QUERY_URL, data=body) as resp:
            if resp.status != 200:
                return None
            html = await resp.text(encoding="utf-8", errors="ignore")
    except Exception as e:
        logger.error(f"Error fetching Hana exchange rate: {e}")
        return None

    row_match = re.search(r"<tr>\s*<td class=\"tc\">.*?미국 USD.*?</tr>", html, re.S)
    if not row_match:
        return None

    numeric_cells = re.findall(r'<td class="txtAr">([\d,]+(?:\.\d+)?)</td>', row_match.group(0))
    if len(numeric_cells) < 8:
        return None

    try:
        deal_base_rate = float(numeric_cells[7].replace(",", ""))
    except ValueError:
        return None

    published_at = None
    sequence = None
    published_match = re.search(
        r"고시일시.*?(\d{4}년\d{2}월\d{2}일)\s*</strong>\s*<strong>(\d{2}시\d{2}분\d{2}초)\s*</strong>\s*<strong>\((\d+)회차\)",
        html,
        re.S,
    )
    if published_match:
        published_at = f"{published_match.group(1)} {published_match.group(2)}"
        sequence = published_match.group(3)

    _hana_usd_krw_cache = {
        "rate": deal_base_rate,
        "published_at": published_at,
        "sequence": sequence,
        "source": "하나은행 매매기준율",
        "fetched_at": now,
    }
    return _hana_usd_krw_cache


async def _get_crypto_price_info(symbol: str) -> dict | None:
    asset = _normalize_asset_code(symbol)

    upbit_market = await _find_upbit_market(asset)
    if upbit_market:
        upbit_prices = await _get_upbit_prices([upbit_market])
        upbit_price = upbit_prices.get(upbit_market)
        if upbit_price is not None:
            return {
                "asset": asset,
                "symbol": upbit_market,
                "price_unit": "KRW",
                "current_price": upbit_price,
                "source": "업비트 KRW 마켓",
                "display_mode": "direct_krw",
            }

    binance_symbol = _normalize_symbol(asset)
    usd_price = await _get_price(binance_symbol)
    if usd_price is None:
        return None

    fx_info = await _get_hana_usd_krw_rate()
    if fx_info is None:
        return None

    krw_price = usd_price * fx_info["rate"]
    return {
        "asset": asset,
        "symbol": binance_symbol,
        "price_unit": "KRW",
        "current_price": krw_price,
        "source": "Binance USDT x 하나은행 매매기준율",
        "display_mode": "converted_krw",
        "usd_price": usd_price,
        "usd_krw_rate": fx_info["rate"],
        "rate_source": fx_info["source"],
        "rate_published_at": fx_info.get("published_at"),
        "rate_sequence": fx_info.get("sequence"),
    }


async def _get_stock_price_info(query: str) -> dict | None:
    candidate = await _resolve_stock_candidate(query)
    if candidate is None:
        return None

    quote = await _get_stock_quote(candidate["symbol"])
    if quote is None:
        return None

    display_symbol = candidate.get("display_symbol") or quote["display_symbol"]
    display_name = candidate.get("company_name") or quote["company_name"]
    market = candidate.get("market") or quote["market"]

    if quote["currency"] == "KRW":
        return {
            "asset_type": "stock",
            "asset": display_symbol,
            "display_name": display_name,
            "symbol": quote["symbol"],
            "price_unit": "KRW",
            "current_price": quote["price"],
            "source": f"Yahoo Finance {market}".strip(),
            "display_mode": "direct_krw",
        }

    if quote["currency"] != "USD":
        return None

    fx_info = await _get_hana_usd_krw_rate()
    if fx_info is None:
        return None

    krw_price = quote["price"] * fx_info["rate"]
    return {
        "asset_type": "stock",
        "asset": display_symbol,
        "display_name": display_name,
        "symbol": quote["symbol"],
        "price_unit": "KRW",
        "current_price": krw_price,
        "source": f"Yahoo Finance {market} x 하나은행 매매기준율".strip(),
        "display_mode": "converted_krw",
        "usd_price": quote["price"],
        "usd_krw_rate": fx_info["rate"],
        "rate_source": fx_info["source"],
        "rate_published_at": fx_info.get("published_at"),
        "rate_sequence": fx_info.get("sequence"),
    }


async def _get_price_info(symbol: str) -> dict | None:
    crypto_price_info = await _get_crypto_price_info(symbol)
    if crypto_price_info is not None:
        crypto_price_info["asset_type"] = "crypto"
        return crypto_price_info
    return await _get_stock_price_info(symbol)


# === Command Handlers ===

HELP_TEXT = (
    "운영 관리 & 코인 자동매매 봇\n\n"
    "[앱 관리]\n"
    "/apps - 관리 앱 선택 및 통계 조회\n"
    "/stats [앱] - 앱 통계 바로 조회\n\n"
    "[알림]\n"
    "/price <심볼> - 코인/주식 가격 조회 (기본 원화)\n"
    "/coins <키워드> - 코인 검색\n"
    "/stocks <키워드> - 주식 검색\n"
    "/alert_above <심볼> <가격> - 코인/주식 이상 알림 (원화)\n"
    "/alert_below <심볼> <가격> - 코인/주식 이하 알림 (원화)\n"
    "/alerts - 알림 목록\n"
    "/remove <번호> - 알림 해제\n"
    "/remove_all - 전체 해제\n\n"
    "[운영 알림 공유]\n"
    "/notify_invite - 보조 수신 계정 초대 코드 발급\n"
    "/notify_join <초대코드> - 보조 계정 운영 알림 가입\n"
    "/notify_list - 운영 알림 수신 계정 목록\n"
    "/notify_remove <chat_id> - 보조 수신 계정 제거\n"
    "/notify_leave - 보조 계정 운영 알림 구독 해제\n\n"
    "[자동매매]\n"
    "/auto_start <투자금> [가상|복리] - 시작\n"
    "/auto_stop - 중지 (전량 매도)\n"
    "/auto_status - 현황\n"
    "/balance - 잔고\n\n"
    "[백테스트]\n"
    "/backtest <코인> [일수] - 전략 시뮬레이션 (예: /backtest BTC 30)"
)

SUBSCRIBER_HELP_TEXT = (
    "운영 알림 수신 계정\n\n"
    "결제 완료·문의·도메인 지원 등의 운영 알림을 받습니다.\n"
    "트레이딩 명령, 거래 알림, 잔고 정보는 주 계정 전용입니다.\n\n"
    "/notify_leave - 운영 알림 구독 해제\n"
    "/start 또는 /help - 이 도움말 확인"
)

INVITE_HELP_TEXT = (
    "운영 알림을 받으려면 주 계정에서 발급한 초대 코드가 필요합니다.\n"
    "/notify_join <초대코드> - 운영 알림 가입\n"
    "/start 또는 /help - 이 도움말 확인"
)


async def _reply_role_help(update: Update) -> None:
    if await _remember_chat_context(update):
        await update.message.reply_text(HELP_TEXT)
        return

    chat_id = _normalize_chat_id(update.effective_chat.id)
    if chat_id in _notification_subscribers:
        await update.message.reply_text(SUBSCRIBER_HELP_TEXT)
        return

    await update.message.reply_text(INVITE_HELP_TEXT)


async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await _reply_role_help(update)


async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await _reply_role_help(update)


async def cmd_notify_invite(update: Update, context: ContextTypes.DEFAULT_TYPE):
    owner_chat_id = _normalize_chat_id(update.effective_chat.id)
    code = secrets.token_hex(6).upper()
    token_hash = hashlib.sha256(code.encode("ascii")).hexdigest()
    await create_notification_invite(token_hash, owner_chat_id)
    await update.message.reply_text(
        "운영 알림 초대 코드가 생성되었습니다.\n\n"
        "동생 계정에서 이 봇과의 개인 채팅을 열고 아래 명령을 보내세요.\n"
        f"/notify_join {code}\n\n"
        "유효시간: 15분 / 1회만 사용 가능\n"
        "보조 계정은 결제·문의 등 운영 알림만 받으며 트레이딩 명령은 사용할 수 없습니다."
    )


async def cmd_notify_join(update: Update, context: ContextTypes.DEFAULT_TYPE):
    chat = update.effective_chat
    if not chat or chat.type != "private":
        await update.effective_message.reply_text("개인 채팅에서만 운영 알림을 구독할 수 있습니다.")
        return

    chat_id = _normalize_chat_id(chat.id)
    if chat_id == _get_default_chat_id():
        await update.message.reply_text("주 계정은 이미 모든 운영 알림을 받고 있습니다.")
        return
    if not context.args:
        await update.message.reply_text("사용법: /notify_join <초대코드>")
        return

    code = context.args[0].strip().upper()
    token_hash = hashlib.sha256(code.encode("utf-8")).hexdigest()
    if not await consume_notification_invite(token_hash):
        await update.message.reply_text("초대 코드가 올바르지 않거나 만료되었습니다.")
        return

    user = update.effective_user
    display_name = (user.full_name if user else "보조 수신 계정")[:100]
    username = user.username[:100] if user and user.username else None
    await save_notification_subscriber(chat_id, display_name, username)
    _notification_subscribers[chat_id] = {
        "chat_id": chat_id,
        "display_name": display_name,
        "username": username,
    }

    await update.message.reply_text(
        "운영 알림 구독이 완료되었습니다.\n"
        "결제 완료·문의·도메인 지원 등의 알림을 함께 받습니다.\n"
        "트레이딩 명령과 거래 알림은 주 계정 전용입니다.\n"
        "구독 해제: /notify_leave"
    )
    owner_chat_id = _get_default_chat_id()
    if owner_chat_id:
        account = f"@{username}" if username else display_name
        try:
            await context.bot.send_message(
                chat_id=owner_chat_id,
                text=f"운영 알림 수신 계정 추가: {account} ({chat_id})",
            )
        except Exception:
            logger.exception("Failed to notify owner about new subscriber")


async def cmd_notify_leave(update: Update, context: ContextTypes.DEFAULT_TYPE):
    chat_id = _normalize_chat_id(update.effective_chat.id)
    if chat_id == _get_default_chat_id():
        await update.message.reply_text("주 계정의 운영 알림은 해제할 수 없습니다.")
        return
    removed = await delete_notification_subscriber(chat_id)
    _notification_subscribers.pop(chat_id, None)
    if removed:
        await update.message.reply_text("운영 알림 구독을 해제했습니다.")
    else:
        await update.message.reply_text("현재 운영 알림을 구독하고 있지 않습니다.")


async def cmd_notify_list(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not _notification_subscribers:
        await update.message.reply_text("추가된 운영 알림 수신 계정이 없습니다.")
        return
    lines = [f"[운영 알림 수신 계정] {len(_notification_subscribers)}개"]
    for subscriber in _notification_subscribers.values():
        username = subscriber.get("username")
        name = f"@{username}" if username else subscriber.get("display_name", "이름 없음")
        lines.append(f"- {name} ({subscriber['chat_id']})")
    lines.append("\n제거: /notify_remove <chat_id>")
    await update.message.reply_text("\n".join(lines))


async def cmd_notify_remove(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not context.args:
        await update.message.reply_text("사용법: /notify_remove <chat_id>")
        return
    chat_id = _normalize_chat_id(context.args[0])
    removed = await delete_notification_subscriber(chat_id)
    _notification_subscribers.pop(chat_id, None)
    if removed:
        await update.message.reply_text(f"운영 알림 수신 계정을 제거했습니다: {chat_id}")
    else:
        await update.message.reply_text("해당 운영 알림 수신 계정을 찾을 수 없습니다.")


def _app_list_keyboard() -> InlineKeyboardMarkup:
    buttons = [
        InlineKeyboardButton(app["label"], callback_data=f"am:app:{app['key']}")
        for app in list_apps()
    ]
    rows = [buttons[index:index + 2] for index in range(0, len(buttons), 2)]
    return InlineKeyboardMarkup(rows)


def _app_stats_keyboard(app_key: str) -> InlineKeyboardMarkup:
    category_buttons = [
        InlineKeyboardButton(label, callback_data=f"am:stat:{app_key}:{category}")
        for category, label in app_stat_categories(app_key)
    ]
    rows = [
        category_buttons[index:index + 2]
        for index in range(0, len(category_buttons), 2)
    ]
    rows.append([InlineKeyboardButton("다른 앱 선택", callback_data="am:list")])
    return InlineKeyboardMarkup(rows)


async def cmd_apps(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(
        f"현재 관리 중인 앱은 {len(APP_LABELS)}개입니다.\n통계를 확인할 앱을 선택하세요.",
        reply_markup=_app_list_keyboard(),
    )


async def _send_app_stats(
    update: Update,
    app_key: str,
    category: str = "overview",
    edit: bool = False,
):
    data = await app_master_service.stats(app_key, category)
    text = format_app_stats_message(app_key, category, data)
    keyboard = _app_stats_keyboard(app_key)
    if edit and update.callback_query:
        await update.callback_query.edit_message_text(text, reply_markup=keyboard)
    else:
        await update.effective_message.reply_text(text, reply_markup=keyboard)


async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not context.args:
        await cmd_apps(update, context)
        return
    app_key = resolve_app_key(" ".join(context.args))
    if not app_key:
        await update.message.reply_text(
            "앱을 찾을 수 없습니다. /apps 에서 관리 앱을 선택해 주세요."
        )
        return
    try:
        await _send_app_stats(update, app_key)
    except Exception:
        logger.exception("App Master stats command failed: %s", app_key)
        await update.message.reply_text("앱 통계를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.")


async def handle_app_master_callback(
    update: Update,
    context: ContextTypes.DEFAULT_TYPE,
):
    query = update.callback_query
    await query.answer()
    callback = str(query.data or "")
    try:
        if callback == "am:list":
            await query.edit_message_text(
                f"현재 관리 중인 앱은 {len(APP_LABELS)}개입니다.\n통계를 확인할 앱을 선택하세요.",
                reply_markup=_app_list_keyboard(),
            )
            return

        parts = callback.split(":")
        if len(parts) == 3 and parts[:2] == ["am", "app"]:
            app_key = resolve_app_key(parts[2])
            if not app_key:
                raise ValueError("지원하지 않는 앱입니다.")
            await _send_app_stats(update, app_key, edit=True)
            return

        if len(parts) == 4 and parts[:2] == ["am", "stat"]:
            app_key = resolve_app_key(parts[2])
            category = parts[3]
            allowed_categories = {item[0] for item in app_stat_categories(app_key or "")}
            if not app_key or category not in allowed_categories:
                raise ValueError("지원하지 않는 통계입니다.")
            await _send_app_stats(update, app_key, category, edit=True)
            return

        raise ValueError("지원하지 않는 메뉴입니다.")
    except Exception:
        logger.exception("App Master callback failed: %s", callback)
        await query.edit_message_text(
            "앱 통계를 불러오지 못했습니다. 다시 선택해 주세요.",
            reply_markup=_app_list_keyboard(),
        )


async def cmd_coins(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not context.args:
        await update.message.reply_text("사용법: /coins <키워드>\n예: /coins 비트코인\n예: /coins 트론\n예: /coins BTC")
        return

    keyword = " ".join(context.args)
    markets = await _get_upbit_markets()
    if not markets:
        await update.message.reply_text("코인 목록을 가져올 수 없습니다.")
        return

    keyword_upper = keyword.upper()
    results = []
    for m in markets:
        symbol = m["market"].replace("KRW-", "")
        kr_name = m.get("korean_name", "")
        en_name = m.get("english_name", "")
        if (keyword in kr_name
                or keyword_upper in symbol
                or keyword_upper in en_name.upper()):
            results.append((symbol, kr_name, en_name))

    if not results:
        await update.message.reply_text(f"'{keyword}' 관련 코인을 찾을 수 없습니다.")
        return

    results = results[:20]
    lines = [f"'{keyword}' 검색 결과 (업비트 KRW 마켓):\n"]
    for symbol, kr, en in results:
        lines.append(f"  {symbol} | {kr} | {en}")
    await update.message.reply_text("\n".join(lines))


async def cmd_stocks(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not context.args:
        await update.message.reply_text("사용법: /stocks <키워드>\n예: /stocks 삼성전자\n예: /stocks AAPL\n예: /stocks 005930")
        return

    keyword = " ".join(context.args).strip()
    results = await _search_stocks(keyword)
    if not results:
        await update.message.reply_text(f"'{keyword}' 관련 주식을 찾을 수 없습니다.")
        return

    lines = [f"'{keyword}' 주식 검색 결과:\n"]
    for item in results[:20]:
        symbol = item.get("display_symbol") or _normalize_asset_code(item["symbol"])
        name = item.get("company_name") or item["symbol"]
        market = item.get("market") or "-"
        lines.append(f"  {symbol} | {name} | {market}")
    await update.message.reply_text("\n".join(lines))


def _format_price_title(price_info: dict) -> str:
    display_name = str(price_info.get("display_name") or "").strip()
    asset = price_info["asset"]
    if display_name and display_name.upper() != asset.upper():
        return f"{asset} | {display_name}"
    return asset


async def cmd_price(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not context.args:
        await update.message.reply_text("사용법: /price <심볼>\n예: /price BTC\n예: /price 삼성전자\n예: /price AAPL")
        return

    price_info = await _get_price_info(context.args[0])
    if price_info is None:
        await update.message.reply_text(
            f"'{context.args[0]}' 가격을 가져올 수 없거나 원화 환산용 고시환율 조회에 실패했습니다."
        )
        return

    display = _format_price_title(price_info)
    lines = [
        f"{display} (KRW): {_format_krw(price_info['current_price'])}",
        f"기준: {price_info['source']}",
    ]
    if price_info["display_mode"] == "converted_krw":
        lines.append(
            f"환산식: {_format_usd(price_info['usd_price'])} x {price_info['usd_krw_rate']:,.2f}원"
        )
        if price_info.get("rate_published_at"):
            seq = price_info.get("rate_sequence")
            seq_text = f" ({seq}회차)" if seq else ""
            lines.append(f"고시시각: {price_info['rate_published_at']}{seq_text}")

    await update.message.reply_text("\n".join(lines))


async def cmd_alert_above(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if len(context.args) < 2:
        await update.message.reply_text("사용법: /alert_above <심볼> <가격>\n예: /alert_above BTC 100000000\n예: /alert_above 삼성전자 300000")
        return

    price_info = await _get_price_info(context.args[0])
    try:
        target_price = float(context.args[1])
    except ValueError:
        await update.message.reply_text("가격은 숫자로 입력해주세요.")
        return

    if price_info is None:
        await update.message.reply_text(
            f"'{context.args[0]}' 가격을 가져올 수 없거나 원화 환산용 고시환율 조회에 실패했습니다."
        )
        return

    chat_id = update.effective_chat.id
    alert_id = _next_alert_id()
    _alerts.setdefault(chat_id, []).append({
        "id": alert_id,
        "symbol": price_info["symbol"],
        "target_price": target_price,
        "direction": "above",
        "price_unit": "KRW",
    })
    await save_alerts(_alerts)

    display = _format_price_title(price_info)
    lines = [
        f"[알림 #{alert_id}] 설정 완료",
        f"{display} {_format_krw(target_price)} 이상 도달 시 알림",
        f"현재 가격: {_format_krw(price_info['current_price'])}",
        f"기준: {price_info['source']}",
    ]
    if price_info["display_mode"] == "converted_krw":
        lines.append(
            f"환산식: {_format_usd(price_info['usd_price'])} x {price_info['usd_krw_rate']:,.2f}원"
        )

    await update.message.reply_text(
        "\n".join(lines)
    )


async def cmd_alert_below(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if len(context.args) < 2:
        await update.message.reply_text("사용법: /alert_below <심볼> <가격>\n예: /alert_below BTC 90000000\n예: /alert_below AAPL 450000")
        return

    price_info = await _get_price_info(context.args[0])
    try:
        target_price = float(context.args[1])
    except ValueError:
        await update.message.reply_text("가격은 숫자로 입력해주세요.")
        return

    if price_info is None:
        await update.message.reply_text(
            f"'{context.args[0]}' 가격을 가져올 수 없거나 원화 환산용 고시환율 조회에 실패했습니다."
        )
        return

    chat_id = update.effective_chat.id
    alert_id = _next_alert_id()
    _alerts.setdefault(chat_id, []).append({
        "id": alert_id,
        "symbol": price_info["symbol"],
        "target_price": target_price,
        "direction": "below",
        "price_unit": "KRW",
    })
    await save_alerts(_alerts)

    display = _format_price_title(price_info)
    lines = [
        f"[알림 #{alert_id}] 설정 완료",
        f"{display} {_format_krw(target_price)} 이하 도달 시 알림",
        f"현재 가격: {_format_krw(price_info['current_price'])}",
        f"기준: {price_info['source']}",
    ]
    if price_info["display_mode"] == "converted_krw":
        lines.append(
            f"환산식: {_format_usd(price_info['usd_price'])} x {price_info['usd_krw_rate']:,.2f}원"
        )

    await update.message.reply_text(
        "\n".join(lines)
    )


async def cmd_alerts(update: Update, context: ContextTypes.DEFAULT_TYPE):
    chat_id = update.effective_chat.id
    user_alerts = _alerts.get(chat_id, [])

    if not user_alerts:
        await update.message.reply_text("설정된 알림이 없습니다.")
        return

    lines = ["설정된 알림 목록:\n"]
    for alert in user_alerts:
        direction = "이상" if alert["direction"] == "above" else "이하"
        asset = _normalize_asset_code(alert["symbol"])
        price_unit = alert.get("price_unit", "USD")
        target_text = _format_price_by_unit(alert["target_price"], price_unit)
        lines.append(
            f"#{alert['id']} | {asset} | {target_text} {direction}"
        )

    await update.message.reply_text("\n".join(lines))


async def cmd_remove(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not context.args:
        await update.message.reply_text("사용법: /remove <알림번호>\n예: /remove 1")
        return

    try:
        alert_id = int(context.args[0])
    except ValueError:
        await update.message.reply_text("알림 번호는 숫자로 입력해주세요.")
        return

    chat_id = update.effective_chat.id
    user_alerts = _alerts.get(chat_id, [])

    for i, alert in enumerate(user_alerts):
        if alert["id"] == alert_id:
            removed = user_alerts.pop(i)
            await save_alerts(_alerts)
            direction = "이상" if removed["direction"] == "above" else "이하"
            asset = _normalize_asset_code(removed["symbol"])
            price_unit = removed.get("price_unit", "USD")
            target_text = _format_price_by_unit(removed["target_price"], price_unit)
            await update.message.reply_text(
                f"알림 #{alert_id} 해제 완료\n"
                f"{asset} {target_text} {direction}"
            )
            return

    await update.message.reply_text(f"알림 #{alert_id}를 찾을 수 없습니다.")


async def cmd_remove_all(update: Update, context: ContextTypes.DEFAULT_TYPE):
    chat_id = update.effective_chat.id
    count = len(_alerts.get(chat_id, []))
    _alerts[chat_id] = []
    await save_alerts(_alerts)
    await update.message.reply_text(f"모든 알림 해제 완료 (총 {count}개)")


# === Auto Trading Commands ===

async def cmd_auto_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if trader.running:
        await update.message.reply_text("이미 실행 중입니다.\n/auto_status 로 확인")
        return

    if not context.args:
        await update.message.reply_text(
            "사용법: /auto_start <투자금> [옵션]\n"
            "예: /auto_start 500000\n"
            "예: /auto_start 100000 가상\n"
            "예: /auto_start 500000 복리\n\n"
            "옵션:\n"
            "  가상 - 실제 주문 없이 가상머니로 테스트\n"
            "  복리 - 수익금 재투자"
        )
        return

    try:
        budget = int(context.args[0])
    except ValueError:
        await update.message.reply_text("투자금은 숫자로 입력해주세요.")
        return

    if budget < 50000:
        await update.message.reply_text("최소 투자금은 50,000원입니다.")
        return

    # 옵션 파싱
    options = [a.lower() for a in context.args[1:]]
    paper = any(o in ("가상", "paper", "테스트", "시뮬") for o in options)
    compound = any(o in ("복리", "compound") for o in options)

    if paper:
        trader.paper_mode = True
        trader._virtual_krw = float(budget)
        trader._virtual_coins.clear()
    else:
        trader.paper_mode = False
        if not trader.is_configured():
            await update.message.reply_text("UPBIT_ACCESS_KEY / UPBIT_SECRET_KEY 환경변수가 설정되지 않았습니다.\n가상 모드: /auto_start <투자금> 가상")
            return
        krw = await trader.get_balance("KRW")
        if krw < budget:
            await update.message.reply_text(f"잔고 부족\n보유: {krw:,.0f}원 / 필요: {budget:,.0f}원")
            return

    trader.total_budget = budget
    trader.compound = compound
    trader.total_realized = 0.0
    trader.total_trades = 0
    trader.positions.clear()
    trader._cooldowns.clear()
    trader.running = True
    trader.started_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    trader.request_immediate_rescan()

    await save_session(trader)
    await clear_positions()

    if paper:
        mode_label = " [가상머니 모드]"
        mode_line = "모드: 가상머니 (실제 주문 없음)\n"
    elif compound:
        mode_label = " [복리 모드]"
        mode_line = "복리: 수익금 재투자 활성화\n"
    else:
        mode_label = ""
        mode_line = ""

    await update.message.reply_text(
        f"자동매매 시작!{mode_label}\n\n"
        f"총 투자금: {budget:,.0f}원\n"
        f"{mode_line}"
        f"최대 {trader.max_coins}종목 (시그널 강도별 동적 배분)\n"
        f"종목선정: KRW 전체 종목 스캔 + 거래대금순 우선 분석\n"
        f"스캔간격: 15분\n\n"
        f"전략: 메이저 추세 + 알트 돌파 + 실시간 호가/체결 수급\n"
        f"  메이저: 눌림목 추세 재개만 선별\n"
        f"  알트: 거래량 동반 고점 돌파만 선별\n"
        f"  추세필터: 메이저 BTC상승 필수 / 알트 BTC위험 점수 반영\n"
        f"  공통필터: 개별일봉 up + 전략별 일봉ATR 범위\n"
        f"  리스크관리: 전략별 익절/손절 + 손절 후 재진입 쿨다운\n\n"
        f"목표/손절: 메이저는 길게, 알트는 빠르게 관리\n"
        f"매수 후보 스캔 중..."
    )


async def cmd_auto_stop(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not trader.running:
        await update.message.reply_text("자동매매가 실행 중이 아닙니다.")
        return

    was_paper = trader.paper_mode
    trader.running = False
    results = await trader.force_sell_all()
    await save_session(trader)
    await clear_positions()

    label = "가상매매" if was_paper else "자동매매"
    msg = f"{label} 중지\n"
    if was_paper:
        msg += f"최종 가상잔고: {trader._virtual_krw:,.0f}원\n"
        msg += f"초기 투자금: {trader.total_budget:,.0f}원\n"
        total_return = ((trader._virtual_krw - trader.total_budget) / trader.total_budget) * 100
        msg += f"총수익률: {total_return:+.2f}%\n"
    msg += f"누적수익: {trader.total_realized:+,.0f}원\n"
    msg += f"총거래: {trader.total_trades}회\n"
    if results:
        msg += "\n매도 결과:\n" + "\n".join(results)

    # 가상 모드 리셋
    if was_paper:
        trader.paper_mode = False
        trader._virtual_krw = 0.0
        trader._virtual_coins.clear()

    await update.message.reply_text(msg)


async def cmd_auto_status(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not trader.is_configured():
        await update.message.reply_text("업비트 API 키가 설정되지 않았습니다.")
        return

    text = await trader.get_status_text()
    await update.message.reply_text(text)


async def cmd_backtest(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not context.args:
        await update.message.reply_text(
            "사용법: /backtest <코인> [일수]\n"
            "예: /backtest BTC 30\n"
            "예: /backtest ETH 7\n"
            "기본: 30일"
        )
        return

    coin = context.args[0].upper()
    ticker = f"KRW-{coin}" if not coin.startswith("KRW-") else coin

    days = 30
    if len(context.args) > 1:
        try:
            days = int(context.args[1])
            days = max(3, min(days, 100))  # 3~100일 제한
        except ValueError:
            await update.message.reply_text("일수는 숫자로 입력해주세요. (3~100)")
            return

    await update.message.reply_text(f"{coin} {days}일 백테스트 실행 중... (1~2분 소요)")

    try:
        result = await run_backtest(ticker, days=days)
        text = format_backtest_result(result)
        await update.message.reply_text(text)
    except Exception as e:
        await update.message.reply_text(f"백테스트 오류: {str(e)}")


async def cmd_balance(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not trader.is_configured():
        await update.message.reply_text("업비트 API 키가 설정되지 않았습니다.")
        return

    balances = await trader.get_balances()
    if not balances:
        await update.message.reply_text("잔고 조회 실패 또는 잔고 없음")
        return

    lines = ["[업비트 잔고]\n"]
    for b in balances:
        currency = b.get("currency", "")
        bal = float(b.get("balance", 0))
        locked = float(b.get("locked", 0))
        avg_price = float(b.get("avg_buy_price", 0))

        if bal + locked <= 0:
            continue

        if currency == "KRW":
            lines.append(f"  KRW: {bal + locked:,.0f}원 (가용: {bal:,.0f})")
        else:
            lines.append(
                f"  {currency}: {bal + locked:.8f}"
                f" (평단: {avg_price:,.0f}원)"
            )

    await update.message.reply_text("\n".join(lines))


# === Bot Creation ===

def create_bot() -> Application:
    app = (
        Application.builder()
        .token(BOT_TOKEN)
        .get_updates_connect_timeout(TELEGRAM_CONNECT_TIMEOUT)
        .get_updates_read_timeout(TELEGRAM_LONG_POLL_READ_TIMEOUT)
        .build()
    )
    app.add_handler(CommandHandler("start", cmd_start))
    app.add_handler(CommandHandler("help", cmd_help))
    app.add_handler(CommandHandler("apps", _bind_chat_context(cmd_apps)))
    app.add_handler(CommandHandler("stats", _bind_chat_context(cmd_stats)))
    app.add_handler(CommandHandler("coins", _bind_chat_context(cmd_coins)))
    app.add_handler(CommandHandler("stocks", _bind_chat_context(cmd_stocks)))
    app.add_handler(CommandHandler("price", _bind_chat_context(cmd_price)))
    app.add_handler(CommandHandler("alert_above", _bind_chat_context(cmd_alert_above)))
    app.add_handler(CommandHandler("alert_below", _bind_chat_context(cmd_alert_below)))
    app.add_handler(CommandHandler("alerts", _bind_chat_context(cmd_alerts)))
    app.add_handler(CommandHandler("remove", _bind_chat_context(cmd_remove)))
    app.add_handler(CommandHandler("remove_all", _bind_chat_context(cmd_remove_all)))
    app.add_handler(CommandHandler("notify_invite", _bind_chat_context(cmd_notify_invite)))
    app.add_handler(CommandHandler("notify_list", _bind_chat_context(cmd_notify_list)))
    app.add_handler(CommandHandler("notify_remove", _bind_chat_context(cmd_notify_remove)))
    # These two commands are intentionally public; a valid one-time invite only
    # grants operational notifications, never owner or trading permissions.
    app.add_handler(CommandHandler("notify_join", cmd_notify_join))
    app.add_handler(CommandHandler("notify_leave", cmd_notify_leave))
    # Auto trading
    app.add_handler(CommandHandler("auto_start", _bind_chat_context(cmd_auto_start)))
    app.add_handler(CommandHandler("auto_stop", _bind_chat_context(cmd_auto_stop)))
    app.add_handler(CommandHandler("auto_status", _bind_chat_context(cmd_auto_status)))
    app.add_handler(CommandHandler("balance", _bind_chat_context(cmd_balance)))
    app.add_handler(CommandHandler("backtest", _bind_chat_context(cmd_backtest)))
    app.add_handler(
        CallbackQueryHandler(
            _bind_chat_context(handle_app_master_callback),
            pattern=r"^am:",
        )
    )
    return app


def _handle_polling_error(error: TelegramError) -> None:
    if isinstance(error, NetworkError):
        logger.warning(
            "Telegram polling connection interrupted; retrying automatically: %s",
            error,
        )
        return

    logger.error(
        "Unexpected Telegram polling error",
        exc_info=(type(error), error, error.__traceback__),
    )


async def start_bot_polling(bot_app: Application) -> None:
    await bot_app.updater.start_polling(
        timeout=TELEGRAM_LONG_POLL_SECONDS,
        bootstrap_retries=-1,
        error_callback=_handle_polling_error,
    )


async def configure_bot_commands(bot_app: Application):
    await bot_app.bot.set_my_commands(
        [
            BotCommand("apps", "관리 앱 선택 및 통계"),
            BotCommand("stats", "앱 통계 바로 조회"),
            BotCommand("stocks", "주식 검색"),
            BotCommand("price", "코인/주식 가격 조회"),
            BotCommand("alerts", "가격 알림 목록"),
            BotCommand("notify_invite", "운영 알림 수신 계정 초대"),
            BotCommand("notify_list", "운영 알림 수신 계정 목록"),
            BotCommand("notify_leave", "운영 알림 구독 해제"),
            BotCommand("auto_status", "자동매매 현황"),
            BotCommand("balance", "업비트 잔고"),
            BotCommand("help", "전체 명령어 안내"),
        ]
    )


# === Alert Background Checker ===

async def _broadcast_operational_message(
    bot_app: Application,
    text: str,
    disable_web_page_preview: bool = False,
) -> bool:
    global _send_counter
    targets = _get_operational_notification_targets()
    if not targets:
        return False

    owner_chat_id = _get_default_chat_id()
    owner_delivered = False
    any_delivered = False
    for chat_id in targets:
        try:
            result = await _safe_telegram_call(
                bot_app.bot.send_message(
                    chat_id=chat_id,
                    text=text,
                    disable_web_page_preview=disable_web_page_preview,
                )
            )
            if result is None:
                if chat_id == owner_chat_id:
                    break
                continue
            any_delivered = True
            if chat_id == owner_chat_id:
                owner_delivered = True
                _send_counter += 1
        except Exception:
            logger.exception("Operational notification failed: chat_id=%s", chat_id)
            if chat_id == owner_chat_id:
                break

    return owner_delivered if owner_chat_id else any_delivered


async def _send_app_event(bot_app: Application, event: dict) -> bool:
    return await _broadcast_operational_message(
        bot_app,
        format_app_event_message(event),
        disable_web_page_preview=True,
    )


async def start_app_master_event_monitor(bot_app: Application):
    checkpoint_value = await load_bot_state(APP_EVENT_CHECKPOINT_STATE_KEY)
    if checkpoint_value:
        try:
            checkpoint = datetime.fromisoformat(checkpoint_value)
        except ValueError:
            checkpoint = await app_master_service.database_now()
    else:
        checkpoint = await app_master_service.database_now() - timedelta(seconds=30)
        await save_bot_state(APP_EVENT_CHECKPOINT_STATE_KEY, checkpoint.isoformat(sep=" "))

    logger.info(
        "App Master event monitor started from %s (interval=%ss)",
        checkpoint,
        APP_EVENT_POLL_SECONDS,
    )

    while True:
        try:
            end_at = await app_master_service.database_now()
            query_start = checkpoint - timedelta(seconds=APP_EVENT_POLL_SECONDS)
            app_events, official_mail_events = await asyncio.gather(
                app_master_service.fetch_events(query_start, end_at),
                official_mail_service.fetch_events(query_start, end_at),
            )
            events = sorted(
                [*app_events, *official_mail_events],
                key=lambda event: (
                    event.get("event_time") or end_at,
                    str(event.get("event_key") or ""),
                ),
            )
            all_delivered = True

            for event in events:
                if await has_app_event_delivery(event["event_key"]):
                    continue
                try:
                    if not await _send_app_event(bot_app, event):
                        all_delivered = False
                        break
                    await mark_app_event_delivered(event)
                except Exception:
                    logger.exception(
                        "App event notification failed: %s",
                        event.get("event_key"),
                    )
                    all_delivered = False
                    break

            if all_delivered:
                checkpoint = end_at
                await save_bot_state(
                    APP_EVENT_CHECKPOINT_STATE_KEY,
                    checkpoint.isoformat(sep=" "),
                )
        except asyncio.CancelledError:
            raise
        except Exception:
            logger.exception("App Master event monitor cycle failed")

        await asyncio.sleep(APP_EVENT_POLL_SECONDS)


async def start_alert_checker(bot_app: Application):
    """10초 기본 간격으로 가격을 확인하고 조건 충족 시 알림 전송 후 해당 알림 자동 해제"""
    while True:
        try:
            upbit_symbols = set()
            binance_symbols = set()
            stock_symbols = set()
            needs_krw_fx = False
            for user_alerts in _alerts.values():
                for alert in user_alerts:
                    symbol = alert["symbol"]
                    if symbol.startswith("KRW-"):
                        upbit_symbols.add(symbol)
                    elif symbol.endswith("USDT"):
                        binance_symbols.add(symbol)
                        if alert.get("price_unit", "USD") == "KRW":
                            needs_krw_fx = True
                    else:
                        stock_symbols.add(symbol)
                        if alert.get("price_unit", "USD") == "KRW":
                            needs_krw_fx = True

            if upbit_symbols or binance_symbols or stock_symbols:
                upbit_prices = await _get_upbit_prices(list(upbit_symbols))
                binance_prices = await _get_prices(list(binance_symbols))
                stock_quotes = await _get_stock_quotes(list(stock_symbols))
                usd_krw_info = await _get_hana_usd_krw_rate() if needs_krw_fx else None

                # 각 알림 조건 체크
                triggered = []
                for chat_id, user_alerts in _alerts.items():
                    for alert in user_alerts:
                        symbol = alert["symbol"]
                        price_unit = alert.get("price_unit", "USD")

                        current_price = None
                        display_symbol = _normalize_asset_code(symbol)
                        used_fx = False
                        if symbol.startswith("KRW-"):
                            current_price = upbit_prices.get(symbol)
                        elif symbol.endswith("USDT"):
                            usd_price = binance_prices.get(symbol)
                            if usd_price is not None:
                                if price_unit == "KRW":
                                    if usd_krw_info is not None:
                                        current_price = usd_price * usd_krw_info["rate"]
                                        used_fx = True
                                else:
                                    current_price = usd_price
                        else:
                            quote = stock_quotes.get(symbol)
                            if quote is not None:
                                display_symbol = quote.get("display_symbol") or display_symbol
                                if quote["currency"] == "KRW":
                                    current_price = quote["price"]
                                elif quote["currency"] == "USD" and price_unit == "KRW" and usd_krw_info is not None:
                                    current_price = quote["price"] * usd_krw_info["rate"]
                                    used_fx = True
                                elif quote["currency"] == price_unit:
                                    current_price = quote["price"]

                        if current_price is None:
                            continue

                        hit = (
                            (alert["direction"] == "above" and current_price >= alert["target_price"])
                            or (alert["direction"] == "below" and current_price <= alert["target_price"])
                        )
                        if hit:
                            triggered.append(
                                (chat_id, alert, current_price, price_unit, usd_krw_info, display_symbol, used_fx)
                            )

                # 알림 전송 및 해제
                for chat_id, alert, current_price, price_unit, usd_krw_info, display_symbol, used_fx in triggered:
                    direction = "이상" if alert["direction"] == "above" else "이하"
                    current_text = _format_price_by_unit(current_price, price_unit)
                    target_text = _format_price_by_unit(alert["target_price"], price_unit)
                    fx_line = ""
                    if price_unit == "KRW" and used_fx and usd_krw_info is not None:
                        rate_text = f"{usd_krw_info['rate']:,.2f}원"
                        if usd_krw_info.get("published_at"):
                            seq = usd_krw_info.get("sequence")
                            seq_text = f" ({seq}회차)" if seq else ""
                            fx_line = f"\n기준환율: {usd_krw_info['source']} {rate_text} / {usd_krw_info['published_at']}{seq_text}"
                        else:
                            fx_line = f"\n기준환율: {usd_krw_info['source']} {rate_text}"
                    try:
                        await _safe_telegram_call(
                            bot_app.bot.send_message(
                                chat_id=chat_id,
                                text=(
                                    f"[알림 #{alert['id']}] 도달!\n"
                                    f"{display_symbol}: {current_text}\n"
                                    f"설정: {target_text} {direction}"
                                    f"{fx_line}"
                                ),
                            )
                        )
                    except Exception as e:
                        logger.error(f"Error sending alert to {chat_id}: {e}")

                    if chat_id in _alerts:
                        _alerts[chat_id] = [a for a in _alerts[chat_id] if a["id"] != alert["id"]]

        except asyncio.CancelledError:
            raise
        except Exception as e:
            logger.error(f"Alert checker error: {e}")

        await asyncio.sleep(ALERT_POLL_SECONDS)


async def start_auto_trader(bot_app: Application):
    """2초마다 자동매매 사이클 실행"""
    global _send_counter

    async def send_fn(text: str, photo: BytesIO | None = None):
        global _send_counter
        chat_id = _get_default_chat_id()
        if not chat_id:
            return
        try:
            if photo:
                result = await _safe_telegram_call(
                    bot_app.bot.send_photo(chat_id=chat_id, photo=photo, caption=text[:1024])
                )
            else:
                result = await _safe_telegram_call(
                    bot_app.bot.send_message(chat_id=chat_id, text=text)
                )
            if result is not None:
                _send_counter += 1
        except Exception as e:
            logger.error(f"Trade notification error: {e}")

    while True:
        try:
            if trader.running:
                await trader.execute_cycle(send_fn)
        except asyncio.CancelledError:
            raise
        except Exception as e:
            logger.error(f"Auto trader error: {e}")
        await asyncio.sleep(2)


async def start_position_monitor(bot_app: Application):
    """5초마다 보유 포지션 실시간 현황 - 항상 맨 아래 유지"""
    chat_id = _get_default_chat_id()
    msg_id = None
    last_counter = 0

    while True:
        try:
            current_chat_id = _get_default_chat_id()
            if current_chat_id != chat_id:
                if msg_id and chat_id:
                    try:
                        await bot_app.bot.delete_message(
                            chat_id=chat_id, message_id=msg_id
                        )
                    except Exception:
                        pass
                chat_id = current_chat_id
                msg_id = None
                last_counter = _send_counter

            if trader.running:
                if not chat_id:
                    await asyncio.sleep(5)
                    continue
                text = await trader.get_monitor_text()
                if text:
                    # 새 메시지가 전송됐으면 모니터를 맨 아래로 재생성
                    if _send_counter != last_counter:
                        last_counter = _send_counter
                        if msg_id:
                            try:
                                await bot_app.bot.delete_message(
                                    chat_id=chat_id, message_id=msg_id
                                )
                            except Exception:
                                pass
                            msg_id = None

                    if msg_id:
                        try:
                            result = await _safe_telegram_call(
                                bot_app.bot.edit_message_text(
                                    chat_id=chat_id,
                                    message_id=msg_id,
                                    text=text
                                )
                            )
                        except Exception as e:
                            if "not modified" not in str(e).lower():
                                try:
                                    msg = await _safe_telegram_call(
                                        bot_app.bot.send_message(
                                            chat_id=chat_id, text=text
                                        )
                                    )
                                    msg_id = msg.message_id if msg else None
                                except Exception:
                                    msg_id = None
                    else:
                        try:
                            msg = await _safe_telegram_call(
                                bot_app.bot.send_message(
                                    chat_id=chat_id, text=text
                                )
                            )
                            msg_id = msg.message_id if msg else None
                        except Exception:
                            msg_id = None
                else:
                    if msg_id:
                        try:
                            await bot_app.bot.delete_message(
                                chat_id=chat_id, message_id=msg_id
                            )
                        except Exception:
                            pass
                        msg_id = None
            else:
                if msg_id:
                    try:
                        await bot_app.bot.delete_message(
                            chat_id=chat_id, message_id=msg_id
                        )
                    except Exception:
                        pass
                    msg_id = None
        except asyncio.CancelledError:
            raise
        except Exception as e:
            logger.error(f"Monitor error: {e}")
        await asyncio.sleep(5)


async def restore_state(bot_app: Application):
    """서버 재시작 시 DB에서 트레이딩 세션 + 포지션 + 알림 복원"""
    global _alerts, _alert_id_counter, _notification_subscribers

    saved_chat_id = await load_bot_state(CHAT_ID_STATE_KEY)
    if saved_chat_id:
        _set_default_chat_id(saved_chat_id, source="db")

    subscriber_rows = await load_notification_subscribers()
    _notification_subscribers = {
        _normalize_chat_id(row["chat_id"]): {
            **row,
            "chat_id": _normalize_chat_id(row["chat_id"]),
        }
        for row in subscriber_rows
    }
    if _notification_subscribers:
        logger.info(
            "Restored %s operational notification subscribers",
            len(_notification_subscribers),
        )

    # 알림 복원
    alerts_data, max_id = await load_alerts()
    if alerts_data:
        _alerts.update(alerts_data)
        _alert_id_counter = max_id
        logger.info(f"Restored {sum(len(v) for v in alerts_data.values())} alerts")
        if not _get_default_chat_id():
            first_chat_id = next(iter(alerts_data.keys()))
            _set_default_chat_id(first_chat_id, source="alerts")

    # 트레이딩 세션 복원
    session = await load_session()
    if session and session.get("running"):
        saved_paper_mode = session.get("paper_mode")
        legacy_paper_recovery = saved_paper_mode is None
        trader.running = True
        trader.total_budget = session["total_budget"]
        trader.compound = bool(session.get("compound", 0))
        trader.total_realized = session.get("total_realized", 0.0)
        trader.total_trades = session.get("total_trades", 0)
        trader.started_at = session.get("started_at")
        # Legacy sessions did not persist the mode. Resume them as paper trades
        # rather than risking an unintended live order after a restart.
        trader.paper_mode = legacy_paper_recovery or bool(saved_paper_mode)
        trader.positions.clear()
        trader._virtual_coins.clear()
        trader.request_immediate_rescan()

        # 포지션 복원
        positions = await load_positions()
        for pos in positions:
            trader.positions[pos["ticker"]] = {
                "ticker": pos["ticker"],
                "coin": pos["coin"],
                "status": pos["status"],
                "buy_price": pos.get("buy_price"),
                "buy_volume": pos.get("buy_volume"),
                "bought_at": pos.get("bought_at"),
                "target_profit": pos["target_profit"],
                "stop_loss": pos["stop_loss"],
            }

        if trader.paper_mode:
            trader._virtual_krw, trader._virtual_coins = reconstruct_virtual_account(
                session, positions, trader.FEE_RATE
            )
        else:
            trader._virtual_krw = 0.0

        if legacy_paper_recovery:
            await save_session(trader)

        logger.info(
            f"Restored trading session: budget={trader.total_budget}, "
            f"positions={len(trader.positions)}, compound={trader.compound}, "
            f"paper={trader.paper_mode}, virtual_krw={trader._virtual_krw}"
        )

        # 복원 알림 전송
        chat_id = _get_default_chat_id()
        if chat_id:
            mode_labels = []
            if trader.paper_mode:
                mode_labels.append("가상")
            if trader.compound:
                mode_labels.append("복리")
            mode = f" [{' / '.join(mode_labels)}]" if mode_labels else ""
            balance_info = (
                f"가상잔고: {trader._virtual_krw:,.0f}원\n"
                if trader.paper_mode else ""
            )
            recovery_info = (
                "기존 세션 모드 정보가 없어 안전하게 가상 모드로 복구했습니다.\n"
                if legacy_paper_recovery else ""
            )
            pos_info = ""
            if trader.positions:
                pos_info = "\n보유 포지션:\n" + "\n".join(
                    f"  {p['coin']}: {p.get('buy_price', 0):,.0f}원"
                    for p in trader.positions.values()
                )
            try:
                await bot_app.bot.send_message(
                    chat_id=chat_id,
                    text=(
                        f"서버 재시작 - 트레이딩 자동 복원{mode}\n"
                        f"투자금: {trader.total_budget:,.0f}원\n"
                        f"{balance_info}"
                        f"누적수익: {trader.total_realized:+,.0f}원\n"
                        f"거래횟수: {trader.total_trades}회\n"
                        f"{recovery_info}"
                        f"{pos_info}"
                    )
                )
            except Exception as e:
                logger.error(f"Restore notification error: {e}")


async def stop_alert_checker():
    global _http_session
    if _http_session and not _http_session.closed:
        await _http_session.close()


async def send_message(bot_app: Application, msg: str, chat_id: str | None = None):
    target = _normalize_chat_id(chat_id)
    if target:
        await bot_app.bot.send_message(chat_id=target, text=msg)
        return
    if not await _broadcast_operational_message(bot_app, msg):
        raise ValueError("운영 알림을 전송할 chat_id가 지정되지 않았습니다.")
