#!/usr/bin/env python3
"""
Telegram AI Conversational Bot

Bot that provides customized responses to user requests:
- /evaluate command to provide analysis and advice on holdings
- /report command to generate detailed analysis reports and HTML files for specific stocks
- /history command to check analysis history for specific stocks
- Available only to channel subscribers
"""
import asyncio
import json
import logging
import os
import re
import signal
import traceback
from datetime import datetime
from pathlib import Path
from queue import Queue

from dotenv import load_dotenv
from telegram import Update, InputFile, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
    Application, CommandHandler, MessageHandler, filters, ContextTypes,
    ConversationHandler, CallbackQueryHandler,
)
from telegram.request import HTTPXRequest

from analysis_manager import (
    AnalysisRequest, analysis_queue, start_background_worker
)
# Internal module imports
from report_generator import (
    generate_evaluation_response, get_cached_report, generate_follow_up_response,
    get_or_create_global_mcp_app, cleanup_global_mcp_app,
    generate_us_evaluation_response, generate_us_follow_up_response,
    get_cached_us_report, generate_journal_conversation_response,
    generate_firecrawl_search_response, generate_firecrawl_followup_response
)
from tracking.user_memory import UserMemoryManager
from firecrawl_client import firecrawl_agent
from cores.disclaimer_utils import strip_trailing_disclaimer as _strip_trailing_disclaimer
from datetime import timedelta
from dataclasses import dataclass
from typing import Dict, Optional

# Load environment variables
load_dotenv()

# Logger setup
from logging.handlers import RotatingFileHandler
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.StreamHandler(),
        RotatingFileHandler(
            f"ai_bot_{datetime.now().strftime('%Y%m%d')}.log",
            maxBytes=10*1024*1024,  # 10MB
            backupCount=5
        )
    ]
)
logger = logging.getLogger(__name__)

from apscheduler.schedulers.asyncio import AsyncIOScheduler

# Constants definition
REPORTS_DIR = Path("reports")
REPORTS_DIR.mkdir(exist_ok=True)  # Create directory if not exists
HTML_REPORTS_DIR = Path("html_reports")
HTML_REPORTS_DIR.mkdir(exist_ok=True)  # HTML reports directory

# Conversation state definitions
CHOOSING_TICKER, ENTERING_AVGPRICE, ENTERING_PERIOD, ENTERING_TONE, ENTERING_BACKGROUND = range(5)
REPORT_CHOOSING_TICKER = 0  # State for /report command
HISTORY_CHOOSING_TICKER = 0  # State for /history command

# US stocks conversation state definitions
US_CHOOSING_TICKER, US_ENTERING_AVGPRICE, US_ENTERING_PERIOD, US_ENTERING_TONE, US_ENTERING_BACKGROUND = range(5, 10)
US_REPORT_CHOOSING_TICKER = 10  # State for /us_report command

# Journal conversation state definitions
JOURNAL_ENTERING = 20  # State for /journal command

# Firecrawl command conversation states
SIGNAL_ENTERING_QUERY = 30
US_SIGNAL_ENTERING_QUERY = 31
THEME_ENTERING_QUERY = 32
US_THEME_ENTERING_QUERY = 33
ASK_ENTERING_QUERY = 34
INSIGHT_ENTERING_QUERY = 35

# Channel ID
CHANNEL_ID = int(os.getenv("TELEGRAM_CHANNEL_ID", "0"))


def generate_triggers_message(db_path: str) -> str:
    """
    Generate trigger reliability report message from database.

    Args:
        db_path: Path to SQLite database

    Returns:
        Formatted message string with trigger reliability data
    """
    import sqlite3

    try:
        conn = sqlite3.connect(db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()

        # Query KR analysis data
        kr_analysis = {}
        try:
            cursor.execute("""
                SELECT trigger_type,
                       COUNT(*) as total,
                       SUM(CASE WHEN tracking_status = 'completed' THEN 1 ELSE 0 END) as completed,
                       AVG(CASE WHEN tracking_status = 'completed' THEN tracked_30d_return ELSE NULL END) as avg_return,
                       SUM(CASE WHEN tracking_status = 'completed' AND tracked_30d_return > 0 THEN 1 ELSE 0 END) as wins,
                       SUM(CASE WHEN tracking_status = 'completed' AND tracked_30d_return <= 0 THEN 1 ELSE 0 END) as losses
                FROM analysis_performance_tracker
                WHERE trigger_type IS NOT NULL
                GROUP BY trigger_type
                ORDER BY completed DESC
            """)
            for row in cursor.fetchall():
                kr_analysis[row['trigger_type']] = dict(row)
        except sqlite3.Error:
            pass

        # Query KR trading data
        kr_trading = {}
        try:
            cursor.execute("""
                SELECT COALESCE(trigger_type, 'AI분석') as trigger_type,
                       COUNT(*) as count,
                       SUM(CASE WHEN profit_rate > 0 THEN 1 ELSE 0 END) as wins,
                       AVG(profit_rate) as avg_profit
                FROM trading_history
                GROUP BY COALESCE(trigger_type, 'AI분석')
            """)
            for row in cursor.fetchall():
                kr_trading[row['trigger_type']] = dict(row)
        except sqlite3.Error:
            pass

        # Query US analysis data
        us_analysis = {}
        try:
            # Check if table exists
            cursor.execute("""
                SELECT name FROM sqlite_master
                WHERE type='table' AND name='us_analysis_performance_tracker'
            """)
            if cursor.fetchone():
                cursor.execute("""
                    SELECT trigger_type,
                           COUNT(*) as total,
                           SUM(CASE WHEN return_30d IS NOT NULL THEN 1 ELSE 0 END) as completed,
                           AVG(CASE WHEN return_30d IS NOT NULL THEN return_30d ELSE NULL END) as avg_return,
                           SUM(CASE WHEN return_30d IS NOT NULL AND return_30d > 0 THEN 1 ELSE 0 END) as wins,
                           SUM(CASE WHEN return_30d IS NOT NULL AND return_30d <= 0 THEN 1 ELSE 0 END) as losses
                    FROM us_analysis_performance_tracker
                    WHERE trigger_type IS NOT NULL
                    GROUP BY trigger_type
                    ORDER BY completed DESC
                """)
                for row in cursor.fetchall():
                    us_analysis[row['trigger_type']] = dict(row)
        except sqlite3.Error:
            pass

        # Query US trading data
        us_trading = {}
        try:
            cursor.execute("""
                SELECT COALESCE(trigger_type, 'AI Analysis') as trigger_type,
                       COUNT(*) as count,
                       SUM(CASE WHEN profit_rate > 0 THEN 1 ELSE 0 END) as wins,
                       AVG(profit_rate) as avg_profit
                FROM us_trading_history
                GROUP BY COALESCE(trigger_type, 'AI Analysis')
            """)
            for row in cursor.fetchall():
                us_trading[row['trigger_type']] = dict(row)
        except sqlite3.Error:
            pass

        conn.close()

        # Compute grades and format message
        def compute_grade(trigger_type, analysis_data, trading_data):
            """Compute grade for a trigger"""
            completed = analysis_data.get('completed', 0) or 0

            if completed < 3:
                return 'D'

            # Analysis win rate
            wins = analysis_data.get('wins', 0) or 0
            analysis_win_rate = (wins / completed * 100) if completed > 0 else 0

            # Trading win rate
            trade_count = trading_data.get('count', 0) or 0
            trade_wins = trading_data.get('wins', 0) or 0
            trading_win_rate = (trade_wins / trade_count * 100) if trade_count > 0 else 0

            if analysis_win_rate >= 60 and trading_win_rate >= 60 and trade_count >= 5:
                return 'A'
            elif analysis_win_rate >= 50:
                return 'B'
            else:
                return 'C'

        grade_emoji = {'A': '🟢', 'B': '🔵', 'C': '🟡', 'D': '⚪'}
        grade_label = {
            'A': '높은 신뢰 — 분석+매매 모두 검증됨',
            'B': '보통 신뢰 — 분석 정확도 양호',
            'C': '낮은 신뢰 — 주의 필요',
            'D': '판단 보류 — 데이터 부족',
        }

        def _format_trigger_line(trigger_type, analysis_data, trading_data):
            """Format a single trigger line with detailed stats."""
            completed = analysis_data.get('completed', 0) or 0
            total = analysis_data.get('total', 0) or 0
            wins = analysis_data.get('wins', 0) or 0
            avg_return = analysis_data.get('avg_return')
            trade_count = trading_data.get('count', 0) or 0
            trade_wins = trading_data.get('wins', 0) or 0
            avg_profit = trading_data.get('avg_profit')

            grade = compute_grade(trigger_type, analysis_data, trading_data)
            emoji = grade_emoji[grade]

            if completed == 0:
                line = f"{emoji} {trigger_type} [{grade}]\n   추적 중 ({total}건 분석 대기)"
            elif completed < 3:
                line = f"{emoji} {trigger_type} [{grade}]\n   데이터 부족 — {completed}건 완료 (최소 3건 필요)"
            else:
                analysis_win_rate = int(wins / completed * 100)
                return_str = f", 평균수익 {avg_return * 100:+.1f}%" if avg_return is not None else ""
                parts = [f"{emoji} {trigger_type} [{grade}]"]
                parts.append(f"   분석 승률 {analysis_win_rate}% ({wins}/{completed}건{return_str})")
                if trade_count > 0:
                    trading_win_rate = int(trade_wins / trade_count * 100)
                    profit_str = f", 평균손익 {avg_profit * 100:+.1f}%" if avg_profit is not None else ""
                    parts.append(f"   매매 승률 {trading_win_rate}% ({trade_wins}/{trade_count}건{profit_str})")
                else:
                    parts.append("   매매 이력 없음")
                line = '\n'.join(parts)

            return grade, completed, line

        # Build message
        msg_parts = ["📡 트리거 신뢰도 리포트"]
        msg_parts.append("━━━━━━━━━━━━━━━━━━━━")
        msg_parts.append("AI가 종목을 발견한 '이유(트리거)'별로")
        msg_parts.append("과거 분석 정확도와 실매매 성과를 비교합니다.\n")

        # Grade legend
        msg_parts.append("등급 기준:")
        for g in ['A', 'B', 'C', 'D']:
            msg_parts.append(f"  {grade_emoji[g]} {g} — {grade_label[g]}")

        # KR section
        msg_parts.append("\n🇰🇷 한국시장")
        msg_parts.append("─────────────────")
        kr_triggers = []
        for trigger_type, analysis_data in kr_analysis.items():
            trading_data = kr_trading.get(trigger_type, {})
            kr_triggers.append(_format_trigger_line(trigger_type, analysis_data, trading_data))

        grade_order = {'A': 0, 'B': 1, 'C': 2, 'D': 3}
        kr_triggers.sort(key=lambda x: (grade_order[x[0]], -x[1]))

        if kr_triggers:
            for _, _, line in kr_triggers:
                msg_parts.append(line)
        else:
            msg_parts.append("  데이터 없음")

        # US section
        msg_parts.append("\n🇺🇸 미국시장")
        msg_parts.append("─────────────────")
        us_triggers = []
        for trigger_type, analysis_data in us_analysis.items():
            trading_data = us_trading.get(trigger_type, {})
            us_triggers.append(_format_trigger_line(trigger_type, analysis_data, trading_data))

        us_triggers.sort(key=lambda x: (grade_order[x[0]], -x[1]))

        if us_triggers:
            for _, _, line in us_triggers:
                msg_parts.append(line)
        else:
            msg_parts.append("  데이터 없음")

        # Summary & insight
        msg_parts.append("\n━━━━━━━━━━━━━━━━━━━━")

        all_triggers = kr_triggers + us_triggers
        a_grade = [t for t in all_triggers if t[0] == 'A']
        c_or_d = [t for t in all_triggers if t[0] in ('C', 'D')]

        if a_grade:
            best_name = a_grade[0][2].split('[')[0].strip().lstrip('🟢🔵🟡⚪ ')
            msg_parts.append(f"💡 가장 믿을 만한 트리거: {best_name}")
            msg_parts.append("   → 이 트리거가 발동되면 매수 적극 검토")
        elif all_triggers:
            best = all_triggers[0]
            best_name = best[2].split('[')[0].strip().lstrip('🟢🔵🟡⚪ ')
            msg_parts.append(f"💡 현재 최고 트리거: {best_name} ({best[0]}등급)")

        if c_or_d:
            weak_names = [t[2].split('[')[0].strip().lstrip('🟢🔵🟡⚪ ') for t in c_or_d[:2]]
            msg_parts.append(f"⚠️ 주의 트리거: {', '.join(weak_names)}")
            msg_parts.append("   → 이 트리거의 종목은 신중하게 판단하세요")

        msg_parts.append("\n분석 승률 = AI 예측이 맞은 비율")
        msg_parts.append("매매 승률 = 실제 매수 후 수익 비율")

        return '\n'.join(msg_parts)

    except Exception as e:
        logger.error(f"Error generating triggers message: {e}")
        return "⚠️ 트리거 신뢰도 데이터를 불러오는 중 오류가 발생했습니다."


class ConversationContext:
    """Conversation context management"""
    def __init__(self, market_type: str = "kr"):
        self.message_id = None
        self.chat_id = None
        self.user_id = None
        self.ticker = None
        self.ticker_name = None
        self.avg_price = None
        self.period = None
        self.tone = None
        self.background = None
        self.conversation_history = []
        self.created_at = datetime.now()
        self.last_updated = datetime.now()
        # Market type: "kr" (Korea) or "us" (USA)
        self.market_type = market_type
        # Currency: KRW (Korea) or USD (USA)
        self.currency = "USD" if market_type == "us" else "KRW"

    def add_to_history(self, role: str, content: str):
        self.conversation_history.append({
            "role": role,
            "content": content,
            "timestamp": datetime.now().isoformat()
        })
        self.last_updated = datetime.now()

    def get_context_for_llm(self) -> str:
        # Set currency unit
        if self.currency == "USD":
            price_str = f"${self.avg_price:,.2f}"
        else:
            price_str = f"{self.avg_price:,.0f}원"

        context = f"""
종목 정보: {self.ticker_name} ({self.ticker})
시장: {"미국" if self.market_type == "us" else "한국"}
평균 매수가: {price_str}
보유 기간: {self.period}개월
피드백 스타일: {self.tone}
매매 배경: {self.background if self.background else "없음"}

이전 대화 내역:"""

        for item in self.conversation_history:
            role_label = "AI 답변" if item['role'] == 'assistant' else "사용자 질문"
            context += f"\n\n{role_label}: {item['content']}"

        return context

    def is_expired(self, hours: int = 24) -> bool:
        return (datetime.now() - self.last_updated) > timedelta(hours=hours)


class FirecrawlConversationContext:
    """Context for Firecrawl-based command follow-up conversations."""

    def __init__(self, command: str, query: str):
        self.command = command  # "signal" | "us_signal" | "theme" | "us_theme" | "ask"
        self.query = query      # original user parameter
        self.conversation_history = []
        self.created_at = datetime.now()
        self.last_updated = datetime.now()

    def add_to_history(self, role: str, content: str):
        self.conversation_history.append({"role": role, "content": content})
        self.last_updated = datetime.now()

    def is_expired(self, hours: int = 24) -> bool:
        return (datetime.now() - self.last_updated) > timedelta(hours=hours)

    def get_context_summary(self) -> str:
        lines = [f"명령어: /{self.command}", f"초기 질의: {self.query}", "", "대화 내역:"]
        for item in self.conversation_history:
            role_label = "AI 답변" if item["role"] == "assistant" else "사용자 질문"
            lines.append(f"\n{role_label}: {item['content']}")
        return "\n".join(lines)


class InsightConversationContext:
    """Context for /insight multi-turn reply conversations (30min TTL)."""

    def __init__(self, original_question: str, user_id: int, chat_id: int):
        self.original_question = original_question
        self.user_id = user_id
        self.chat_id = chat_id
        self.last_insight_id: Optional[int] = None
        self.conversation_history: list = []
        self.created_at = datetime.now()
        self.last_updated = datetime.now()

    def is_expired(self, ttl_minutes: int = 30) -> bool:
        return (datetime.now() - self.last_updated) > timedelta(minutes=ttl_minutes)

    def add_turn(self, user_q: str, bot_a: str, insight_id: Optional[int]):
        self.conversation_history.append(
            {"q": user_q, "a": bot_a, "iid": insight_id}
        )
        if insight_id is not None:
            self.last_insight_id = insight_id
        self.last_updated = datetime.now()


@dataclass
class InsightPayload:
    """Response payload from /insight_agent or local InsightAgent."""
    answer: str
    insight_id: Optional[int]
    remaining_quota: int
    tickers_mentioned: list
    tools_used: list


class TelegramAIBot:
    """Telegram AI Conversational Bot"""

    def __init__(self):
        """Initialize"""
        self.token = os.getenv("TELEGRAM_AI_BOT_TOKEN")
        if not self.token:
            raise ValueError("Telegram bot token is not configured.")

        # Explicitly create HTML reports directory
        if not HTML_REPORTS_DIR.exists():
            HTML_REPORTS_DIR.mkdir(exist_ok=True)
            logger.info(f"HTML reports directory created: {HTML_REPORTS_DIR}")

        # Check Channel ID
        self.channel_id = int(os.getenv("TELEGRAM_CHANNEL_ID", "0"))
        if not self.channel_id:
            logger.warning("Telegram channel ID is not configured. Skipping channel subscription verification.")

        # Initialize stock information
        self.stock_map = {}
        self.stock_name_map = {}
        self.load_stock_map()

        self.stop_event = asyncio.Event()

        # Manage pending analysis requests
        self.pending_requests = {}

        # Add result processing queue
        self.result_queue = Queue()
        
        # Add conversation context storage
        self.conversation_contexts: Dict[int, ConversationContext] = {}

        # Journal context storage (for replies)
        self.journal_contexts: Dict[int, Dict] = {}

        # Firecrawl command follow-up context storage (keyed by bot message ID)
        self.firecrawl_contexts: Dict[int, FirecrawlConversationContext] = {}

        # /insight multi-turn reply context (keyed by bot message ID, 30min TTL)
        self.insight_contexts: Dict[int, InsightConversationContext] = {}

        # Initialize user memory manager
        self.memory_manager = UserMemoryManager("user_memories.sqlite")

        # Daily usage limit (user_id:command -> date)
        self.daily_report_usage: Dict[str, str] = {}

        # Daily count-based usage limit for /ask (user_id:command -> {date, count})
        self.daily_ask_usage: Dict[str, Dict] = {}

        # Create bot application (including timeout settings)
        request = HTTPXRequest(
            connection_pool_size=8,
            connect_timeout=30.0,
            read_timeout=120.0,   # Ensure sufficient time for file transfers
            write_timeout=120.0,
        )
        self.application = Application.builder().token(self.token).request(request).build()
        self.setup_handlers()

        # Start background worker
        start_background_worker(self)

        self.scheduler = AsyncIOScheduler()
        self.scheduler.add_job(self.load_stock_map, "interval", hours=12)
        # Add expired context cleanup task
        self.scheduler.add_job(self.cleanup_expired_contexts, "interval", hours=1)
        # Add user memory compression task (daily at 3 AM)
        self.scheduler.add_job(self.compress_user_memories, "cron", hour=3, minute=0)
        self.scheduler.start()
    
    async def _register_bot_commands(self):
        """Sync slash-command menu with BotFather across all scopes
        (private chats, groups, group admins) — without explicit scope the
        Telegram default only covers private chats."""
        from telegram import (
            BotCommand,
            BotCommandScopeDefault,
            BotCommandScopeAllPrivateChats,
            BotCommandScopeAllGroupChats,
            BotCommandScopeAllChatAdministrators,
        )
        commands = [
            BotCommand("evaluate",    "보유 종목 평가 시작"),
            BotCommand("us_evaluate", "미국 주식 보유 종목 평가"),
            BotCommand("report",      "국내 종목 리포트"),
            BotCommand("us_report",   "미국 종목 리포트"),
            BotCommand("history",     "분석 히스토리 조회"),
            BotCommand("triggers",    "오늘의 급등/급락 트리거"),
            BotCommand("signal",      "국내 시장 시그널 분석"),
            BotCommand("us_signal",   "미국 시장 시그널 분석"),
            BotCommand("theme",       "국내 테마 진단"),
            BotCommand("us_theme",    "미국 테마 진단"),
            BotCommand("ask",         "자유 질문 (최신 정보 기반)"),
            BotCommand("insight",     "누적 인사이트 기반 장기 분석"),
            BotCommand("journal",     "매매 저널 조회"),
            BotCommand("cancel",      "진행 중인 명령어 취소 (evaluate·us_evaluate·report·us_report·history·signal·us_signal·theme·us_theme·ask·insight·journal)"),
            BotCommand("help",        "도움말"),
        ]
        scopes = [
            ("default", BotCommandScopeDefault()),
            ("private", BotCommandScopeAllPrivateChats()),
            ("group", BotCommandScopeAllGroupChats()),
            ("group_admin", BotCommandScopeAllChatAdministrators()),
        ]
        for label, scope in scopes:
            try:
                await self.application.bot.set_my_commands(commands, scope=scope)
                logger.info(
                    f"Registered {len(commands)} bot commands (scope={label})"
                )
            except Exception as e:
                logger.warning(f"set_my_commands failed (scope={label}): {e}")

    def cleanup_expired_contexts(self):
        """Clean up expired conversation contexts"""
        expired_keys = []
        for msg_id, context in self.conversation_contexts.items():
            if context.is_expired(hours=24):
                expired_keys.append(msg_id)

        for key in expired_keys:
            del self.conversation_contexts[key]
            logger.info(f"Deleted expired context: Message ID {key}")

        # Also clean up journal contexts (older than 24 hours)
        journal_expired = []
        now = datetime.now()
        for msg_id, ctx in self.journal_contexts.items():
            if (now - ctx.get('created_at', now)).total_seconds() > 86400:  # 24 hours
                journal_expired.append(msg_id)

        for key in journal_expired:
            del self.journal_contexts[key]
            logger.info(f"Deleted expired journal context: Message ID {key}")

        # Clean up firecrawl follow-up contexts (older than 24 hours)
        firecrawl_expired = [
            msg_id for msg_id, ctx in self.firecrawl_contexts.items()
            if ctx.is_expired(hours=24)
        ]
        for key in firecrawl_expired:
            del self.firecrawl_contexts[key]
            logger.info(f"Deleted expired firecrawl context: Message ID {key}")

        # Clean up /insight multi-turn contexts (30 min TTL)
        insight_expired = [
            msg_id for msg_id, ctx in self.insight_contexts.items()
            if ctx.is_expired(ttl_minutes=30)
        ]
        for key in insight_expired:
            del self.insight_contexts[key]
        if insight_expired:
            logger.info(f"Deleted {len(insight_expired)} expired insight contexts")

        # Clean up daily usage limits (remove non-today dates)
        today = datetime.now().strftime("%Y-%m-%d")
        daily_limit_expired = [
            key for key, date in self.daily_report_usage.items()
            if date != today
        ]
        for key in daily_limit_expired:
            del self.daily_report_usage[key]
        if daily_limit_expired:
            logger.info(f"Cleaned up expired daily limits: {len(daily_limit_expired)} entries")

        # Clean up count-based daily limits (remove non-today dates)
        daily_ask_expired = [
            key for key, usage in self.daily_ask_usage.items()
            if usage.get("date") != today
        ]
        for key in daily_ask_expired:
            del self.daily_ask_usage[key]
        if daily_ask_expired:
            logger.info(f"Cleaned up expired ask limits: {len(daily_ask_expired)} entries")

    def compress_user_memories(self):
        """Compress user memories (nightly batch)"""
        if self.memory_manager:
            try:
                stats = self.memory_manager.compress_old_memories()
                logger.info(f"User memory compression complete: {stats}")
            except Exception as e:
                logger.error(f"Error during user memory compression: {e}")

    def check_daily_limit(self, user_id: int, command: str) -> bool:
        """
        Daily usage limit for report/us_report — removed (issue #307).

        Always returns True (unlimited). Kept as a no-op so existing call
        sites and refund_daily_limit() stay valid without further changes.
        """
        return True

    def refund_daily_limit(self, user_id: int, command: str):
        """
        Refund daily usage limit when report failed due to server-side error.
        This allows the user to retry after a server failure (timeout, internal error).
        """
        key = f"{user_id}:{command}"
        if key in self.daily_report_usage:
            del self.daily_report_usage[key]
            logger.info(f"Daily limit refunded (server error): user={user_id}, command={command}")

    def check_daily_limit_count(self, user_id: int, command: str, max_count: int = 3) -> tuple:
        """
        Check count-based daily usage limit.

        Args:
            user_id: User ID
            command: Command name
            max_count: Maximum allowed uses per day

        Returns:
            tuple: (allowed: bool, remaining: int)
        """
        today = datetime.now().strftime("%Y-%m-%d")
        key = f"{user_id}:{command}"

        usage = self.daily_ask_usage.get(key)
        if usage and usage.get("date") == today:
            if usage["count"] >= max_count:
                logger.info(f"Daily count limit exceeded: user={user_id}, command={command}, count={usage['count']}/{max_count}")
                return False, 0
            usage["count"] += 1
            remaining = max_count - usage["count"]
            logger.info(f"Daily count usage recorded: user={user_id}, command={command}, count={usage['count']}/{max_count}")
            return True, remaining
        else:
            self.daily_ask_usage[key] = {"date": today, "count": 1}
            remaining = max_count - 1
            logger.info(f"Daily count usage started: user={user_id}, command={command}, count=1/{max_count}")
            return True, remaining

    def peek_daily_limit_count(self, user_id: int, command: str, max_count: int = 3) -> tuple:
        """
        Check count-based daily limit WITHOUT consuming a count.
        Returns (allowed: bool, remaining: int). Use this in start handlers
        to give early feedback without charging for a cancelled conversation.
        """
        today = datetime.now().strftime("%Y-%m-%d")
        key = f"{user_id}:{command}"
        usage = self.daily_ask_usage.get(key)
        if usage and usage.get("date") == today:
            if usage["count"] >= max_count:
                return False, 0
            return True, max_count - usage["count"]
        return True, max_count

    def refund_daily_limit_count(self, user_id: int, command: str):
        """Refund one count-based daily usage when Firecrawl call fails."""
        key = f"{user_id}:{command}"
        usage = self.daily_ask_usage.get(key)
        if usage and usage["count"] > 0:
            usage["count"] -= 1
            logger.info(f"Daily count limit refunded: user={user_id}, command={command}, count={usage['count']}")

    def _is_server_error(self, request) -> bool:
        """
        Detect server-side failures that should not consume the daily limit.
        Returns True for:
          - status="failed" (subprocess timeout or unhandled exception)
          - status="completed" but result contains an error string
            (internal AI agent error that returned error text instead of report)
        """
        if request.status == "failed":
            return True
        if request.status == "completed" and request.result:
            error_markers = [
                "Error occurred during analysis",
                "Error occurred during US stock analysis",
            ]
            return any(marker in request.result for marker in error_markers)
        return False

    def load_stock_map(self):
        """
        Load dictionary mapping stock codes to names
        """
        try:
            # Stock information file path
            stock_map_file = "stock_map.json"

            logger.info(f"Attempting to load stock mapping info: {stock_map_file}")

            if os.path.exists(stock_map_file):
                with open(stock_map_file, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    self.stock_map = data.get("code_to_name", {})
                    self.stock_name_map = data.get("name_to_code", {})

                logger.info(f"Loaded {len(self.stock_map)} stock information entries")
            else:
                logger.warning(f"Stock information file does not exist: {stock_map_file}")
                # Provide default data (for testing)
                self.stock_map = {"005930": "삼성전자", "013700": "까뮤이앤씨"}
                self.stock_name_map = {"삼성전자": "005930", "까뮤이앤씨": "013700"}

        except Exception as e:
            logger.error(f"Failed to load stock information: {e}")
            # Provide default data at least
            self.stock_map = {"005930": "삼성전자", "013700": "까뮤이앤씨"}
            self.stock_name_map = {"삼성전자": "005930", "까뮤이앤씨": "013700"}

    def setup_handlers(self):
        """
        Register handlers
        """
        # Basic commands
        self.application.add_handler(CommandHandler("start", self.handle_start))
        self.application.add_handler(CommandHandler("help", self.handle_help))
        self.application.add_handler(CommandHandler("cancel", self.handle_cancel_standalone), group=1)
        self.application.add_handler(CommandHandler("memories", self.handle_memories))
        self.application.add_handler(CommandHandler("triggers", self.handle_triggers))

        # Reply handler - registered with group=1 for lower priority than ConversationHandler(group=0)
        # ConversationHandler processes first, this handler only processes unmatched replies
        self.application.add_handler(MessageHandler(
            filters.REPLY & filters.TEXT & ~filters.COMMAND,
            self.handle_reply_to_evaluation
        ), group=1)

        # Photo handler - analyze attached images (e.g. chart screenshots) via vision LLM
        self.application.add_handler(MessageHandler(
            filters.PHOTO & ~filters.COMMAND,
            self.handle_photo
        ), group=1)

        # Report command handler
        report_conv_handler = ConversationHandler(
            entry_points=[
                CommandHandler("report", self.handle_report_start),
                MessageHandler(filters.Regex(r'^/report(@\w+)?$'), self.handle_report_start)
            ],
            states={
                REPORT_CHOOSING_TICKER: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_report_ticker_input)
                ]
            },
            fallbacks=[
                CommandHandler("cancel", self.handle_cancel)
            ],
            per_chat=False,
            per_user=True,
            conversation_timeout=300,
        )
        self.application.add_handler(report_conv_handler)

        # History command handler
        history_conv_handler = ConversationHandler(
            entry_points=[
                CommandHandler("history", self.handle_history_start),
                MessageHandler(filters.Regex(r'^/history(@\w+)?$'), self.handle_history_start)
            ],
            states={
                HISTORY_CHOOSING_TICKER: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_history_ticker_input)
                ]
            },
            fallbacks=[
                CommandHandler("cancel", self.handle_cancel)
            ],
            per_chat=False,
            per_user=True,
            conversation_timeout=300,
        )
        self.application.add_handler(history_conv_handler)

        # Evaluation conversation handler
        conv_handler = ConversationHandler(
            entry_points=[
                CommandHandler("evaluate", self.handle_evaluate_start),
                # Add pattern for group chats
                MessageHandler(filters.Regex(r'^/evaluate(@\w+)?$'), self.handle_evaluate_start)
            ],
            states={
                CHOOSING_TICKER: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_ticker_input)
                ],
                ENTERING_AVGPRICE: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_avgprice_input)
                ],
                ENTERING_PERIOD: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_period_input)
                ],
                ENTERING_TONE: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_tone_input)
                ],
                ENTERING_BACKGROUND: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_background_input)
                ]
            },
            fallbacks=[
                CommandHandler("cancel", self.handle_cancel),
                # Add other commands as well
                CommandHandler("start", self.handle_cancel),
                CommandHandler("help", self.handle_cancel)
            ],
            # Distinguish messages from different users in group chats
            per_chat=False,
            per_user=True,
            # Conversation timeout (seconds)
            conversation_timeout=300,
        )
        self.application.add_handler(conv_handler)

        # ==========================================================================
        # US stocks conversation handlers
        # ==========================================================================

        # US evaluation conversation handler (/us_evaluate)
        us_evaluate_handler = ConversationHandler(
            entry_points=[
                CommandHandler("us_evaluate", self.handle_us_evaluate_start),
                MessageHandler(filters.Regex(r'^/us_evaluate(@\w+)?$'), self.handle_us_evaluate_start)
            ],
            states={
                US_CHOOSING_TICKER: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_us_ticker_input)
                ],
                US_ENTERING_AVGPRICE: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_us_avgprice_input)
                ],
                US_ENTERING_PERIOD: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_us_period_input)
                ],
                US_ENTERING_TONE: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_us_tone_input)
                ],
                US_ENTERING_BACKGROUND: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_us_background_input)
                ]
            },
            fallbacks=[
                CommandHandler("cancel", self.handle_cancel),
                CommandHandler("start", self.handle_cancel),
                CommandHandler("help", self.handle_cancel)
            ],
            per_chat=False,
            per_user=True,
            conversation_timeout=300,
        )
        self.application.add_handler(us_evaluate_handler)

        # US report conversation handler (/us_report)
        us_report_handler = ConversationHandler(
            entry_points=[
                CommandHandler("us_report", self.handle_us_report_start),
                MessageHandler(filters.Regex(r'^/us_report(@\w+)?$'), self.handle_us_report_start)
            ],
            states={
                US_REPORT_CHOOSING_TICKER: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_us_report_ticker_input)
                ]
            },
            fallbacks=[
                CommandHandler("cancel", self.handle_cancel)
            ],
            per_chat=False,
            per_user=True,
            conversation_timeout=300,
        )
        self.application.add_handler(us_report_handler)

        # ==========================================================================
        # Journal (investment diary) conversation handler (/journal)
        # ==========================================================================
        journal_conv_handler = ConversationHandler(
            entry_points=[
                CommandHandler("journal", self.handle_journal_start),
                MessageHandler(filters.Regex(r'^/journal(@\w+)?$'), self.handle_journal_start)
            ],
            states={
                JOURNAL_ENTERING: [
                    MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_journal_input)
                ]
            },
            fallbacks=[
                CommandHandler("cancel", self.handle_cancel),
                CommandHandler("start", self.handle_cancel),
                CommandHandler("help", self.handle_cancel)
            ],
            per_chat=False,
            per_user=True,
            conversation_timeout=300,
        )
        self.application.add_handler(journal_conv_handler)

        # ==========================================================================
        # Firecrawl AI Research commands — interactive ConversationHandlers
        # Each command first asks for the required parameter, then calls Firecrawl.
        # Subsequent replies to the bot's response continue via Anthropic Sonnet 5.
        # BotFather commands to register:
        #   signal - 이벤트/뉴스 임팩트 분석 (한국)
        #   us_signal - 이벤트/뉴스 임팩트 분석 (미국)
        #   theme - 테마/섹터 건강도 진단 (한국)
        #   us_theme - 테마/섹터 건강도 진단 (미국)
        #   ask - AI 투자 리서처 (자유 질문)
        # ==========================================================================

        signal_handler = ConversationHandler(
            entry_points=[
                CommandHandler("signal", self.handle_signal_start),
                MessageHandler(filters.Regex(r'^/signal(@\w+)?$'), self.handle_signal_start),
            ],
            states={SIGNAL_ENTERING_QUERY: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_signal_query)]},
            fallbacks=[CommandHandler("cancel", self.handle_cancel)],
            per_chat=False, per_user=True, conversation_timeout=300,
        )
        self.application.add_handler(signal_handler)

        us_signal_handler = ConversationHandler(
            entry_points=[
                CommandHandler("us_signal", self.handle_us_signal_start),
                MessageHandler(filters.Regex(r'^/us_signal(@\w+)?$'), self.handle_us_signal_start),
            ],
            states={US_SIGNAL_ENTERING_QUERY: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_us_signal_query)]},
            fallbacks=[CommandHandler("cancel", self.handle_cancel)],
            per_chat=False, per_user=True, conversation_timeout=300,
        )
        self.application.add_handler(us_signal_handler)

        theme_handler = ConversationHandler(
            entry_points=[
                CommandHandler("theme", self.handle_theme_start),
                MessageHandler(filters.Regex(r'^/theme(@\w+)?$'), self.handle_theme_start),
            ],
            states={THEME_ENTERING_QUERY: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_theme_query)]},
            fallbacks=[CommandHandler("cancel", self.handle_cancel)],
            per_chat=False, per_user=True, conversation_timeout=300,
        )
        self.application.add_handler(theme_handler)

        us_theme_handler = ConversationHandler(
            entry_points=[
                CommandHandler("us_theme", self.handle_us_theme_start),
                MessageHandler(filters.Regex(r'^/us_theme(@\w+)?$'), self.handle_us_theme_start),
            ],
            states={US_THEME_ENTERING_QUERY: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_us_theme_query)]},
            fallbacks=[CommandHandler("cancel", self.handle_cancel)],
            per_chat=False, per_user=True, conversation_timeout=300,
        )
        self.application.add_handler(us_theme_handler)

        ask_handler = ConversationHandler(
            entry_points=[
                CommandHandler("ask", self.handle_ask_start),
                MessageHandler(filters.Regex(r'^/ask(@\w+)?$'), self.handle_ask_start),
            ],
            states={ASK_ENTERING_QUERY: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_ask_query)]},
            fallbacks=[CommandHandler("cancel", self.handle_cancel)],
            per_chat=False, per_user=True, conversation_timeout=300,
        )
        self.application.add_handler(ask_handler)

        insight_handler = ConversationHandler(
            entry_points=[
                CommandHandler("insight", self.handle_insight_start),
                MessageHandler(filters.Regex(r'^/insight(@\w+)?$'), self.handle_insight_start),
            ],
            states={INSIGHT_ENTERING_QUERY: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_insight_query)]},
            fallbacks=[CommandHandler("cancel", self.handle_cancel)],
            per_chat=False, per_user=True, conversation_timeout=300,
        )
        self.application.add_handler(insight_handler)

        # /insight inline-keyboard feedback (👍/👎) callback
        self.application.add_handler(CallbackQueryHandler(
            self.handle_insight_feedback_callback,
            pattern=r"^insight_fb:",
        ))

        # General text messages - /help or /start guidance
        self.application.add_handler(MessageHandler(
            filters.TEXT & ~filters.COMMAND, self.handle_default_message
        ))

        # Error handler
        self.application.add_error_handler(self.handle_error)
    
    async def handle_photo(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Analyze a chart image sent as a reply to an ongoing bot conversation.

        Reply-only: the photo must reply to a bot message that still carries a
        conversation context (journal / firecrawl / insight / evaluation). A
        summary of that context is folded into the vision prompt and the context
        is re-registered on the sent reply so the thread continues. A context-less
        photo gets a short usage hint instead (bare photos never reach the bot in
        group chats under Telegram privacy mode). Rate-limited per user.
        """
        if not update.message or not update.message.photo:
            return

        user_id = update.effective_user.id if update.effective_user else 0

        # --- Context-aware reply detection ---------------------------------
        # When this photo is a reply to a prior bot message that still carries
        # a conversation context, fold a short summary of that context into the
        # vision prompt and re-register the same context on the sent reply so
        # the user can keep the thread going (mirrors the text-reply handlers).
        # A photo message has a caption (not .text) so it never reaches
        # handle_reply_to_evaluation (filters.TEXT) — no handler conflict.
        reply_msg = update.message.reply_to_message
        replied_to_msg_id = reply_msg.message_id if reply_msg else None
        context_block = None   # FULL prior-conversation text to prepend, or None
        reregister = None      # (store_dict, context_obj) to re-register, or None
        ctx_kind = None        # 'eval'|'firecrawl'|'insight'|'journal' — for history recording
        ctx_obj = None         # the live context object (to append the image turn to)

        if replied_to_msg_id is not None:
            # 1. journal_contexts — plain dict, created_at-based 30min expiry
            if replied_to_msg_id in self.journal_contexts:
                jctx = self.journal_contexts[replied_to_msg_id]
                created_at = jctx.get('created_at')
                if created_at and (datetime.now() - created_at).total_seconds() > 1800:
                    await update.message.reply_text(
                        "이전 대화 세션이 만료되었습니다.\n"
                        "/journal 명령어로 새로운 대화를 시작해주세요. 💭"
                    )
                    del self.journal_contexts[replied_to_msg_id]
                    return
                t_name = jctx.get('ticker_name') or ''
                t_code = jctx.get('ticker') or ''
                context_block = f"매매일지 종목: {t_name} ({t_code})".strip()
                reregister = (self.journal_contexts, jctx)
                ctx_kind, ctx_obj = 'journal', jctx

            # 2. firecrawl_contexts — FirecrawlConversationContext, 24h expiry
            elif replied_to_msg_id in self.firecrawl_contexts:
                fctx = self.firecrawl_contexts[replied_to_msg_id]
                if fctx.is_expired():
                    await update.message.reply_text(
                        "이전 리서치 세션이 만료되었습니다. 명령어를 다시 입력해주세요."
                    )
                    del self.firecrawl_contexts[replied_to_msg_id]
                    return
                # Full research thread (command + query + every prior Q/A)
                try:
                    context_block = fctx.get_context_summary()
                except Exception:
                    context_block = f"이전 리서치 명령어: /{getattr(fctx, 'command', '')}, 질의: {getattr(fctx, 'query', '')}".strip()
                reregister = (self.firecrawl_contexts, fctx)
                ctx_kind, ctx_obj = 'firecrawl', fctx

            # 2.5 insight_contexts — InsightConversationContext, 30min expiry
            elif replied_to_msg_id in self.insight_contexts:
                ictx = self.insight_contexts[replied_to_msg_id]
                if ictx.is_expired():
                    await update.message.reply_text(
                        "이전 /insight 세션이 만료되었습니다. /insight로 새로 시작해주세요."
                    )
                    del self.insight_contexts[replied_to_msg_id]
                    return
                # Build the full insight thread (original question + every prior turn)
                lines = [f"초기 질문: {getattr(ictx, 'original_question', '') or ''}", "", "대화 내역:"]
                for turn in getattr(ictx, 'conversation_history', []) or []:
                    lines.append(f"\n사용자 질문: {turn.get('q', '')}")
                    lines.append(f"AI 답변: {turn.get('a', '')}")
                context_block = "\n".join(lines)
                reregister = (self.insight_contexts, ictx)
                ctx_kind, ctx_obj = 'insight', ictx

            # 3. conversation_contexts — ConversationContext (report/eval), 24h expiry
            elif replied_to_msg_id in self.conversation_contexts:
                cctx = self.conversation_contexts[replied_to_msg_id]
                if cctx.is_expired():
                    if getattr(cctx, 'market_type', 'kr') == "us":
                        await update.message.reply_text(
                            "이전 대화 세션이 만료되었습니다. 새로운 평가를 시작하려면 /us_evaluate 명령어를 사용해주세요."
                        )
                    else:
                        await update.message.reply_text(
                            "이전 대화 세션이 만료되었습니다. 새로운 평가를 시작하려면 /evaluate 명령어를 사용해주세요."
                        )
                    del self.conversation_contexts[replied_to_msg_id]
                    return
                # Full evaluation context: ticker, price, holding, TONE (피드백 스타일),
                # trade background, AND the entire prior conversation history.
                try:
                    context_block = cctx.get_context_for_llm()
                except Exception:
                    t_name = getattr(cctx, 'ticker_name', '') or ''
                    t_code = getattr(cctx, 'ticker', '') or ''
                    market_label = "미국" if getattr(cctx, 'market_type', 'kr') == "us" else "한국"
                    context_block = f"평가 종목: {t_name} ({t_code}), 시장: {market_label}"
                reregister = (self.conversation_contexts, cctx)
                ctx_kind, ctx_obj = 'eval', cctx

        # Reply-only: a bare photo (no active bot-conversation context) is not a
        # supported entry point. In group chats Telegram privacy mode never even
        # delivers such messages to the bot; in DMs it would, but analyzing a
        # context-less image is not the intended flow. Guide the user instead.
        if reregister is None:
            await update.message.reply_text(
                "📈 차트 이미지 분석은 봇과의 대화에 이어서 사용해주세요.\n"
                "먼저 /evaluate · /report · /signal · /insight 등으로 대화를 시작한 뒤, "
                "봇 답변에 차트 이미지로 답장하시면 그 맥락에 맞춰 분석해 드립니다."
            )
            return

        # Rate limit (shared daily-count bucket, same cap as signal/theme commands)
        allowed, remaining = self.check_daily_limit_count(user_id, "chart", max_count=10)
        if not allowed:
            await update.message.reply_text(
                "오늘 이미지 분석 사용 한도(10회)를 모두 사용하셨습니다. 내일 다시 시도해주세요."
            )
            return

        try:
            # Largest resolution is the last entry in the PhotoSize list
            photo = update.message.photo[-1]
            tg_file = await photo.get_file()
            image_bytes = bytes(await tg_file.download_as_bytearray())
        except Exception as exc:
            logger.error(f"[CHART] image download failed: {exc}")
            self.refund_daily_limit_count(user_id, "chart")
            await update.message.reply_text("이미지를 받지 못했습니다. 다시 보내주세요.")
            return

        caption = (update.message.caption or "").strip()

        prompt = (
            "당신은 주식 차트를 읽는 전문 트레이더입니다. 첨부된 이미지를 분석해 "
            "한국어로 설명하세요.\n"
            "- 이미지가 주식/코인 차트라면: 추세(상승/하락/횡보), 주요 지지·저항 구간, "
            "거래량 특징, 눈에 띄는 캔들/패턴, 그리고 유의할 리스크를 짚어주세요.\n"
            "- 차트가 아니라면: 이미지에 무엇이 보이는지 간단히 설명하세요.\n"
            "확정적 매수·매도 지시는 하지 말고, 관찰과 시나리오 위주로 서술하세요. "
            "종목명이나 가격이 이미지에서 불명확하면 추측하지 말고 그렇게 밝히세요."
        )
        # Prepend the FULL prior conversation so the vision model continues the
        # thread — same tone/voice, aware of everything said so far. analyze_image
        # is text-prompt + image, so multi-turn is emulated by injecting this block.
        if context_block:
            prompt = (
                "아래는 사용자와 나눈 지금까지의 대화입니다. 여기에 '피드백 스타일'이 "
                "있으면 그 말투를, 없으면 직전 대화의 어투를 그대로 유지하세요.\n"
                "===== 이전 대화 =====\n"
                f"{context_block}\n"
                "====================\n\n"
                "사용자가 위 대화에 이어서 아래 차트 이미지를 첨부했습니다. 새로 시작하는 "
                "설명이 아니라, 지금까지의 대화를 이어받아 같은 종목·맥락·말투로 자연스럽게 "
                "답변하세요. 이전에 이미 말한 내용은 반복하지 말고 차트에서 새로 보이는 "
                "점 위주로 짚어주세요.\n\n"
                + prompt
            )
        if caption:
            prompt += f"\n\n[사용자 메모] {caption}"

        try:
            await update.message.chat.send_action(action="typing")
        except Exception:
            pass

        # analyze_image never raises: returns None on error / when vision is off
        from cores.llm.features.vision import analyze_image

        result = await analyze_image(image_bytes, prompt)

        if not result:
            self.refund_daily_limit_count(user_id, "chart")
            await update.message.reply_text(
                "이미지 분석 기능을 사용할 수 없습니다. (비전 모델 비활성화 또는 일시적 오류)"
            )
            return

        text = str(result).strip()

        # Record this image turn into the conversation history so subsequent
        # replies (text or photo) remember what the chart analysis said — the
        # same mechanism the text-reply handlers use to keep the thread coherent.
        user_turn = "[차트 이미지 첨부]" + (f" {caption}" if caption else "")
        try:
            if ctx_kind in ('eval', 'firecrawl') and ctx_obj is not None:
                ctx_obj.add_to_history("user", user_turn)
                ctx_obj.add_to_history("assistant", text)
            elif ctx_kind == 'insight' and ctx_obj is not None:
                ctx_obj.add_turn(user_turn, text, None)
            # journal_contexts is a plain dict with no history — nothing to record
        except Exception as exc:
            logger.warning(f"[CHART] history record skipped: {exc}")

        # Telegram hard limit is 4096 chars per message — chunk safely
        sent_message = None
        for i in range(0, len(text), 4000):
            sent_message = await update.message.reply_text(text[i:i + 4000])

        # Re-register the context on the sent reply so a follow-up reply (text or
        # photo) continues the same thread, mirroring the text-reply handlers.
        if reregister and sent_message is not None:
            store, obj = reregister
            store[sent_message.message_id] = obj

    async def handle_reply_to_evaluation(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle replies to evaluation responses"""
        if not update.message or not update.message.reply_to_message:
            return
        
        # Check replied-to message ID
        replied_to_msg_id = update.message.reply_to_message.message_id
        user_id = update.effective_user.id if update.effective_user else "unknown"
        text = update.message.text[:50] if update.message.text else "no text"

        logger.info(f"[REPLY] handle_reply_to_evaluation - user_id: {user_id}, replied_to: {replied_to_msg_id}, text: {text}")

        # 1. Check journal context (handle journal reply)
        if replied_to_msg_id in self.journal_contexts:
            journal_ctx = self.journal_contexts[replied_to_msg_id]
            logger.info(f"[REPLY] Found in journal_contexts - ticker: {journal_ctx.get('ticker')}")
            await self._handle_journal_reply(update, journal_ctx)
            return

        # 2. Check Firecrawl command context (signal/us_signal/theme/us_theme/ask follow-up)
        if replied_to_msg_id in self.firecrawl_contexts:
            fc_ctx = self.firecrawl_contexts[replied_to_msg_id]
            logger.info(f"[REPLY] Found in firecrawl_contexts - command: /{fc_ctx.command}, query: {fc_ctx.query[:40]}")
            if fc_ctx.is_expired():
                await update.message.reply_text(
                    "이전 리서치 세션이 만료되었습니다. 명령어를 다시 입력해주세요."
                )
                del self.firecrawl_contexts[replied_to_msg_id]
                return
            await self._handle_firecrawl_reply(update, fc_ctx)
            return

        # 2.5 Check /insight multi-turn reply context (30min TTL)
        if replied_to_msg_id in self.insight_contexts:
            ic = self.insight_contexts[replied_to_msg_id]
            logger.info(f"[REPLY] Found in insight_contexts - q: {ic.original_question[:40]}")
            if ic.is_expired():
                await update.message.reply_text(
                    "이전 /insight 세션이 만료되었습니다. /insight로 새로 시작해주세요."
                )
                del self.insight_contexts[replied_to_msg_id]
                return
            await self._handle_insight_reply(update, ic)
            return

        # 3. Check evaluation context
        if replied_to_msg_id not in self.conversation_contexts:
            # Treat as general message if no context exists
            logger.info(f"[REPLY] Not in conversation_contexts, skipping. keys: {list(self.conversation_contexts.keys())[:5]}")
            return
        
        conv_context = self.conversation_contexts[replied_to_msg_id]
        
        # Check context expiration
        if conv_context.is_expired():
            # Different guidance message depending on market type
            if conv_context.market_type == "us":
                await update.message.reply_text(
                    "이전 대화 세션이 만료되었습니다. 새로운 평가를 시작하려면 /us_evaluate 명령어를 사용해주세요."
                )
            else:
                await update.message.reply_text(
                    "이전 대화 세션이 만료되었습니다. 새로운 평가를 시작하려면 /evaluate 명령어를 사용해주세요."
                )
            del self.conversation_contexts[replied_to_msg_id]
            return

        # Get user message
        user_question = update.message.text.strip()

        # Waiting message (based on market type)
        if conv_context.market_type == "us":
            waiting_message = await update.message.reply_text(
                "🇺🇸 추가 질문에 대해 분석 중입니다... 잠시만 기다려주세요. 💭"
            )
        else:
            waiting_message = await update.message.reply_text(
                "추가 질문에 대해 분석 중입니다... 잠시만 기다려주세요. 💭"
            )

        try:
            # Add user question to conversation history
            conv_context.add_to_history("user", user_question)

            # Create context to pass to LLM
            full_context = conv_context.get_context_for_llm()

            # Use different response generator based on market type
            if conv_context.market_type == "us":
                # Generate response for US market
                response = await generate_us_follow_up_response(
                    conv_context.ticker,
                    conv_context.ticker_name,
                    full_context,
                    user_question,
                    conv_context.tone
                )
            else:
                # Generate response for Korean market (existing)
                response = await generate_follow_up_response(
                    conv_context.ticker,
                    conv_context.ticker_name,
                    full_context,
                    user_question,
                    conv_context.tone
                )
            
            # Delete waiting message
            await waiting_message.delete()
            
            # Send response
            sent_message = await update.message.reply_text(
                response + "\n\n💡 추가 질문이 있으시면 이 메시지에 답장(Reply)해주세요."
            )

            # Add AI response to conversation history
            conv_context.add_to_history("assistant", response)

            # Update context with new message ID
            conv_context.message_id = sent_message.message_id
            conv_context.user_id = update.effective_user.id
            self.conversation_contexts[sent_message.message_id] = conv_context

            logger.info(f"Follow-up question processed: User {update.effective_user.id}")

        except Exception as e:
            logger.error(f"Error processing follow-up question: {str(e)}, {traceback.format_exc()}")
            await waiting_message.delete()
            await update.message.reply_text(
                "죄송합니다. 추가 질문 처리 중 오류가 발생했습니다. 다시 시도해주세요."
            )

    async def send_report_result(self, request: AnalysisRequest):
        """Send analysis results to Telegram"""
        if not request.chat_id:
            logger.warning(f"Cannot send results without chat ID: {request.id}")
            return

        # Refund daily limit if the report failed due to a server-side error
        # (subprocess timeout, internal AI agent error, etc.) so the user can retry.
        if getattr(request, 'user_id', None) and self._is_server_error(request):
            command = "us_report" if request.market_type == "us" else "report"
            self.refund_daily_limit(request.user_id, command)

        try:
            # Send PDF file
            if request.pdf_path and os.path.exists(request.pdf_path):
                with open(request.pdf_path, 'rb') as file:
                    await self.application.bot.send_document(
                        chat_id=request.chat_id,
                        document=InputFile(file, filename=f"{request.company_name}_{request.stock_code}_분석.pdf"),
                        caption=f"✅ {request.company_name} ({request.stock_code}) 분석 보고서가 완료되었습니다."
                    )
            else:
                # Send results as text if PDF file is missing
                if request.result:
                    # Truncate and send if text is too long
                    max_length = 4000  # Telegram message max length
                    if len(request.result) > max_length:
                        summary = request.result[:max_length] + "...(이하 생략)"
                        await self.application.bot.send_message(
                            chat_id=request.chat_id,
                            text=f"✅ {request.company_name} ({request.stock_code}) 분석 결과:\n\n{summary}"
                        )
                    else:
                        await self.application.bot.send_message(
                            chat_id=request.chat_id,
                            text=f"✅ {request.company_name} ({request.stock_code}) 분석 결과:\n\n{request.result}"
                        )
                else:
                    await self.application.bot.send_message(
                        chat_id=request.chat_id,
                        text=f"⚠️ {request.company_name} ({request.stock_code}) 분석 결과를 찾을 수 없습니다."
                    )
        except Exception as e:
            logger.error(f"Error sending results: {str(e)}")
            logger.error(traceback.format_exc())
            await self.application.bot.send_message(
                chat_id=request.chat_id,
                text=f"⚠️ {request.company_name} ({request.stock_code}) 분석 결과 전송 중 오류가 발생했습니다."
            )

    @staticmethod
    async def handle_default_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """General messages redirect to /help or /start"""
        # Check if update.message is None
        if update.message is None:
            logger.warning(f"Received update without message: {update}")
            return

        # Debug: Check what messages are coming here
        user_id = update.effective_user.id if update.effective_user else "unknown"
        chat_id = update.effective_chat.id if update.effective_chat else "unknown"
        text = update.message.text[:50] if update.message.text else "no text"
        logger.debug(f"[DEFAULT] handle_default_message - user_id: {user_id}, chat_id: {chat_id}, text: {text}")

        return

    @staticmethod
    async def handle_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle start command"""
        user = update.effective_user
        await update.message.reply_text(
            f"안녕하세요, {user.first_name}님! 저는 프리즘 어드바이저 봇입니다.\n\n"
            "저는 보유하신 종목에 대한 평가를 제공합니다.\n\n"
            "🇰🇷 <b>한국 주식</b>\n"
            "/evaluate - 보유 종목 평가 시작\n"
            "/report - 상세 분석 보고서 요청\n"
            "/history - 특정 종목의 분석 히스토리 확인\n\n"
            "🇺🇸 <b>미국 주식</b>\n"
            "/us_evaluate - 미국 주식 평가 시작\n"
            "/us_report - 미국 주식 보고서 요청\n\n"
            "📝 <b>투자 일기</b>\n"
            "/journal - 투자 일기 기록\n"
            "/memories - 내 기억 저장소 확인\n\n"
            "📡 <b>트리거 신뢰도</b>\n"
            "/triggers - 트리거 신뢰도 리포트 보기\n\n"
            "🔥 <b>Firecrawl AI 리서치</b> (NEW!)\n"
            "/signal 이란 휴전 - 이벤트가 한국 증시에 미치는 영향\n"
            "/us_signal TSMC 실적 서프라이즈 - 미국 증시 영향\n"
            "/theme 2차전지 - 테마가 아직 살아있는지 진단\n"
            "/us_theme AI 반도체 - 미국 테마 건강도 체크\n"
            "/ask 코스피 17년래 최강 상승인데 다음주도 오를까? - 자유 질문 (일 3회)\n\n"
            "💡 평가 응답에 답장(Reply)하여 추가 질문을 할 수 있습니다!\n\n"
            "이 봇은 '프리즘 인사이트' 채널 구독자만 사용할 수 있습니다.\n"
            "채널에서는 장 시작과 마감 시 AI가 선별한 특징주 3개를 소개하고,\n"
            "각 종목에 대한 AI에이전트가 작성한 고퀄리티의 상세 분석 보고서를 제공합니다.\n\n"
            "다음 링크를 구독한 후 봇을 사용해주세요: https://t.me/stock_ai_agent",
            parse_mode="HTML"
        )

    @staticmethod
    async def handle_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle help command"""
        await update.message.reply_text(
            "📊 <b>프리즘 어드바이저 봇 도움말</b> 📊\n\n"
            "<b>기본 명령어:</b>\n"
            "/start - 봇 시작\n"
            "/help - 도움말 보기\n"
            "/cancel - 현재 진행 중인 대화 취소\n\n"
            "🇰🇷 <b>한국 주식 명령어:</b>\n"
            "/evaluate - 보유 종목 평가 시작\n"
            "/report - 상세 분석 보고서 요청\n"
            "/history - 특정 종목의 분석 히스토리 확인\n\n"
            "🇺🇸 <b>미국 주식 명령어:</b>\n"
            "/us_evaluate - 미국 주식 평가 시작\n"
            "/us_report - 미국 주식 보고서 요청\n\n"
            "📝 <b>투자 일기:</b>\n"
            "/journal - 투자 생각 기록\n"
            "/memories - 내 기억 저장소 확인\n"
            "  • 종목 코드/티커와 함께 입력 가능\n"
            "  • 과거 평가 시 기억으로 활용됨\n\n"
            "📡 <b>트리거 신뢰도:</b>\n"
            "/triggers - KR & US 트리거 신뢰도 리포트 보기\n\n"
            "🔥 <b>Firecrawl AI 리서치:</b>\n"
            "/signal [이벤트] - 이벤트가 한국 증시에 미치는 영향 분석\n"
            "  예: <code>/signal 이란 미국 휴전</code>\n"
            "  예: <code>/signal 삼성전자 역대급 실적</code>\n"
            "/us_signal [이벤트] - 이벤트가 미국 증시에 미치는 영향 분석\n"
            "  예: <code>/us_signal TSMC 매출 35% 급증</code>\n"
            "/theme [테마] - 한국 테마/섹터 건강도 진단\n"
            "  예: <code>/theme 2차전지</code>\n"
            "  예: <code>/theme 방산 조선</code>\n"
            "/us_theme [테마] - 미국 테마/섹터 건강도 진단\n"
            "  예: <code>/us_theme AI 데이터센터</code>\n"
            "/ask [질문] - AI에게 투자 관련 자유 질문 (일 3회)\n"
            "  예: <code>/ask 코스피 17년래 최강 상승 다음주도 오를까?</code>\n"
            "  예: <code>/ask 워렌 버핏이 올해 뭘 샀어?</code>\n\n"
            "🖼️ <b>차트 이미지 분석 (일 10회):</b>\n"
            "차트 스크린샷을 그냥 사진으로 보내면 AI가 추세·지지/저항·거래량·리스크를 분석해 드립니다.\n"
            "  • 사진에 캡션을 달면 참고 정보로 활용됩니다\n\n"
            "<b>보유 종목 평가 방법 (한국/미국 동일):</b>\n"
            "1. /evaluate 또는 /us_evaluate 명령어 입력\n"
            "2. 종목 코드/티커 입력 (예: 005930 또는 AAPL)\n"
            "3. 평균 매수가 입력 (원 또는 달러)\n"
            "4. 보유 기간 입력\n"
            "5. 원하는 피드백 스타일 입력\n"
            "6. 매매 배경 입력 (선택사항)\n"
            "7. 💡 AI 응답에 답장(Reply)하여 추가 질문 가능!\n\n"
            "<b>✨ 추가 질문 기능:</b>\n"
            "• AI의 평가 메시지에 답장하여 추가 질문\n"
            "• 이전 대화 컨텍스트를 유지하여 연속적인 대화 가능\n"
            "• 24시간 동안 대화 세션 유지\n\n"
            "<b>상세 분석 보고서 요청:</b>\n"
            "1. /report 명령어 입력\n"
            "2. 종목 코드 또는 이름 입력\n"
            "3. 5-10분 후 상세 보고서가 제공됩니다(요청이 많을 경우 더 길어짐)\n\n"
            "<b>주의:</b>\n"
            "이 봇은 채널 구독자만 사용할 수 있습니다.",
            parse_mode="HTML"
        )

    async def handle_memories(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle my memories lookup command"""
        user_id = update.effective_user.id
        user_name = update.effective_user.first_name

        try:
            # Query memory statistics
            stats = self.memory_manager.get_memory_stats(user_id)

            if not stats or stats.get('total', 0) == 0:
                await update.message.reply_text(
                    f"📭 {user_name}님의 저장된 기억이 없습니다.\n\n"
                    "/journal 명령어로 투자 일기를 기록해보세요!",
                    parse_mode="HTML"
                )
                return

            # Query memory list
            memories = self.memory_manager.get_memories(user_id, limit=20)

            # Create response message
            msg_parts = [f"🧠 <b>{user_name}님의 기억 저장소</b>\n"]

            # Statistics
            by_type = stats.get('by_type', {})
            msg_parts.append(f"\n📊 <b>저장된 기억: {stats.get('total', 0)}개</b>")
            if by_type:
                type_labels = {
                    'journal': '📝 저널',
                    'evaluation': '📈 평가',
                    'report': '📋 보고서',
                    'conversation': '💬 대화'
                }
                for mem_type, count in by_type.items():
                    label = type_labels.get(mem_type, mem_type)
                    msg_parts.append(f"  • {label}: {count}개")

            # Statistics by ticker
            by_ticker = stats.get('by_ticker', {})
            if by_ticker:
                msg_parts.append("\n🏷️ <b>종목별 기록:</b>")
                for ticker, count in list(by_ticker.items())[:5]:
                    msg_parts.append(f"  • {ticker}: {count}개")

            # Recent memory details
            msg_parts.append("\n\n📜 <b>최근 기억 (최대 10개):</b>\n")
            for i, mem in enumerate(memories[:10], 1):
                created = mem.get('created_at', '')[:10]
                mem_type = mem.get('memory_type', '')
                ticker = mem.get('ticker', '')
                ticker_name = mem.get('ticker_name', '')
                content = mem.get('content', {})

                # Content preview (100 chars)
                text = content.get('text', content.get('response_summary', ''))[:100]
                if len(text) >= 100:
                    text = text[:97] + "..."

                # Display ticker
                ticker_str = f" [{ticker_name or ticker}]" if ticker else ""

                # Type emoji
                type_emoji = {'journal': '📝', 'evaluation': '📈', 'report': '📋', 'conversation': '💬'}.get(mem_type, '💭')

                msg_parts.append(f"{i}. {type_emoji} {created}{ticker_str}")
                if text:
                    msg_parts.append(f"   <i>{text}</i>")
                msg_parts.append("")

            response = "\n".join(msg_parts)

            # Message length limit (4096 chars)
            if len(response) > 4000:
                response = response[:3997] + "..."

            await update.message.reply_text(response, parse_mode="HTML")

        except Exception as e:
            logger.error(f"Error in handle_memories: {e}", exc_info=True)
            await update.message.reply_text(
                "⚠️ 기억 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."
            )

    async def handle_triggers(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /triggers command — show trigger reliability report"""
        try:
            db_path = str(Path(__file__).parent / "stock_tracking_db.sqlite")
            message = generate_triggers_message(db_path)
            await update.message.reply_text(message, parse_mode="HTML")
        except Exception as e:
            logger.error(f"Error in /triggers: {e}")
            await update.message.reply_text("트리거 신뢰도 데이터를 불러오는 중 오류가 발생했습니다.")

    async def handle_report_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle report command - first step"""
        user_id = update.effective_user.id
        user_name = update.effective_user.first_name

        # Check channel subscription
        is_subscribed = await self.check_channel_subscription(user_id)

        if not is_subscribed:
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\n"
                "https://t.me/stock_ai_agent"
            )
            return ConversationHandler.END

        # Check daily usage limit
        if not self.check_daily_limit(user_id, "report"):
            await update.message.reply_text(
                "⚠️ /report 명령어는 하루에 1회만 사용할 수 있습니다.\n\n"
                "내일 다시 이용해 주세요."
            )
            return ConversationHandler.END

        # Check if group chat or private chat
        is_group = update.effective_chat.type in ["group", "supergroup"]
        greeting = f"{user_name}님, " if is_group else ""

        await update.message.reply_text(
            f"{greeting}상세 분석 보고서를 생성할 종목 코드나 이름을 입력해주세요.\n"
            "예: 005930 또는 삼성전자"
        )

        return REPORT_CHOOSING_TICKER

    async def handle_report_ticker_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle stock input for report request"""
        user_id = update.effective_user.id
        user_input = update.message.text.strip()
        chat_id = update.effective_chat.id

        logger.info(f"Received report stock input - User: {user_id}, Input: {user_input}")

        # Process stock code or name
        stock_code, stock_name, error_message = await self.get_stock_code(user_input)

        if error_message:
            # Notify user of error and request re-input
            await update.message.reply_text(error_message)
            return REPORT_CHOOSING_TICKER

        # Send waiting message
        waiting_message = await update.message.reply_text(
            f"📊 {stock_name} ({stock_code}) 분석 보고서 생성 요청이 등록되었습니다.\n\n"
            f"요청은 도착 순서대로 처리되며, 한 건당 분석에 약 5-10분이 소요됩니다.\n\n"
            f"다른 사용자의 요청이 많을 경우 대기 시간이 길어질 수 있습니다.\n\n "
            f"완료되면 바로 알려드리겠습니다."
        )

        # Create analysis request and add to queue
        request = AnalysisRequest(
            stock_code=stock_code,
            company_name=stock_name,
            chat_id=chat_id,
            message_id=waiting_message.message_id,
            user_id=user_id
        )

        # Check if cached report exists
        is_cached, cached_content, cached_file, cached_pdf = get_cached_report(stock_code)

        if is_cached:
            logger.info(f"Found cached report: {cached_file}")
            # Send result immediately if cached report exists
            request.result = cached_content
            request.status = "completed"
            request.report_path = cached_file
            request.pdf_path = cached_pdf

            await waiting_message.edit_text(
                f"✅ {stock_name} ({stock_code}) 분석 보고서가 준비되었습니다. 잠시 후 전송됩니다."
            )

            # Send result
            await self.send_report_result(request)
        else:
            # New analysis needed
            self.pending_requests[request.id] = request
            analysis_queue.put(request)

        return ConversationHandler.END

    async def handle_history_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle history command - first step"""
        user_id = update.effective_user.id
        user_name = update.effective_user.first_name

        # Check channel subscription
        is_subscribed = await self.check_channel_subscription(user_id)

        if not is_subscribed:
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\n"
                "https://t.me/stock_ai_agent"
            )
            return ConversationHandler.END

        # Check if group chat or private chat
        is_group = update.effective_chat.type in ["group", "supergroup"]
        greeting = f"{user_name}님, " if is_group else ""

        await update.message.reply_text(
            f"{greeting}분석 히스토리를 확인할 종목 코드나 이름을 입력해주세요.\n"
            "예: 005930 또는 삼성전자"
        )

        return HISTORY_CHOOSING_TICKER

    async def handle_history_ticker_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle stock input for history request"""
        user_id = update.effective_user.id
        user_input = update.message.text.strip()

        logger.info(f"Received history stock input - User: {user_id}, Input: {user_input}")

        # Process stock code or name
        stock_code, stock_name, error_message = await self.get_stock_code(user_input)

        if error_message:
            # Notify user of error and request re-input
            await update.message.reply_text(error_message)
            return HISTORY_CHOOSING_TICKER

        # Find history
        reports = list(REPORTS_DIR.glob(f"{stock_code}_*.md"))

        if not reports:
            await update.message.reply_text(
                f"{stock_name} ({stock_code}) 종목에 대한 분석 히스토리가 없습니다.\n"
                f"/report 명령어를 사용하여 새 분석을 요청해보세요."
            )
            return ConversationHandler.END

        # Sort by date
        reports.sort(key=lambda x: x.stat().st_mtime, reverse=True)

        # Compose history message
        history_msg = f"📋 {stock_name} ({stock_code}) 분석 히스토리:\n\n"

        for i, report in enumerate(reports[:5], 1):
            report_date = datetime.fromtimestamp(report.stat().st_mtime).strftime('%Y-%m-%d %H:%M')
            history_msg += f"{i}. {report_date}\n"

            # Add file size
            file_size = report.stat().st_size / 1024  # KB
            history_msg += f"   크기: {file_size:.1f} KB\n"

            # Add first line preview
            try:
                with open(report, 'r', encoding='utf-8') as f:
                    first_line = next(f, "").strip()
                    if first_line:
                        preview = first_line[:50] + "..." if len(first_line) > 50 else first_line
                        history_msg += f"   미리보기: {preview}\n"
            except Exception:
                pass

            history_msg += "\n"

        if len(reports) > 5:
            history_msg += f"그 외 {len(reports) - 5}개의 분석 기록이 있습니다.\n"

        history_msg += "\n최신 분석 보고서를 확인하려면 /report 명령어를 사용하세요."

        await update.message.reply_text(history_msg)
        return ConversationHandler.END

    async def check_channel_subscription(self, user_id):
        """
        Check if user is subscribed to the channel

        Args:
            user_id: User ID

        Returns:
            bool: Subscription status
        """
        try:
            # Always return true if Channel ID is not configured
            if not self.channel_id:
                return True

            # Admin ID allowlist
            admin_ids_str = os.getenv("TELEGRAM_ADMIN_IDS", "")
            admin_ids = [int(id_str) for id_str in admin_ids_str.split(",") if id_str.strip()]

            # Always allow if admin
            if user_id in admin_ids:
                logger.info(f"Admin {user_id} access granted")
                return True

            member = await self.application.bot.get_chat_member(
                self.channel_id, user_id
            )
            # Add status check and logging
            logger.info(f"User {user_id} channel membership status: {member.status}")

            # Allow channel members, admins, creators/owners
            # 'creator' is used in early versions, some versions may change to 'owner'
            valid_statuses = ['member', 'administrator', 'creator', 'owner']

            # Always allow if channel owner
            if member.status == 'creator' or getattr(member, 'is_owner', False):
                return True

            return member.status in valid_statuses
        except Exception as e:
            logger.error(f"Error checking channel subscription: {e}")
            # Log exception details for debugging
            logger.error(f"Detailed error: {traceback.format_exc()}")
            return False

    async def handle_evaluate_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle evaluate command - first step"""
        user_id = update.effective_user.id
        user_name = update.effective_user.first_name

        # Check channel subscription
        is_subscribed = await self.check_channel_subscription(user_id)

        if not is_subscribed:
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\n"
                "https://t.me/stock_ai_agent"
            )
            return ConversationHandler.END

        # Check if group chat or private chat
        is_group = update.effective_chat.type in ["group", "supergroup"]

        logger.info(f"Evaluation command started - User: {user_name}, Chat type: {'group' if is_group else 'private'}")

        # Mention username in group chats
        greeting = f"{user_name}님, " if is_group else ""

        await update.message.reply_text(
            f"{greeting}보유하신 종목의 코드나 이름을 입력해주세요. \n"
            "예: 005930 또는 삼성전자"
        )
        return CHOOSING_TICKER

    async def handle_ticker_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle ticker input"""
        user_id = update.effective_user.id
        user_input = update.message.text.strip()
        logger.info(f"Received ticker input - User: {user_id}, Input: {user_input}")

        # Process stock code or name
        stock_code, stock_name, error_message = await self.get_stock_code(user_input)

        if error_message:
            # Notify user of error and request re-input
            await update.message.reply_text(error_message)
            return CHOOSING_TICKER

        # Save stock information
        context.user_data['ticker'] = stock_code
        context.user_data['ticker_name'] = stock_name

        logger.info(f"Stock selected: {stock_name} ({stock_code})")

        await update.message.reply_text(
            f"{stock_name} ({stock_code}) 종목을 선택하셨습니다.\n\n"
            f"평균 매수가를 입력해주세요. (숫자만 입력)\n"
            f"예: 68500"
        )

        logger.info(f"State transition: ENTERING_AVGPRICE - User: {user_id}")
        return ENTERING_AVGPRICE

    @staticmethod
    async def handle_avgprice_input(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle average price input"""
        try:
            avg_price = float(update.message.text.strip().replace(',', ''))
            context.user_data['avg_price'] = avg_price

            await update.message.reply_text(
                "보유 기간을 입력해주세요. (월 단위)\n"
                "예: 6 (6개월)"
            )
            return ENTERING_PERIOD

        except ValueError:
            await update.message.reply_text(
                "숫자 형식으로 입력해주세요. 쉼표 제외.\n"
                "예: 68500"
            )
            return ENTERING_AVGPRICE

    @staticmethod
    async def handle_period_input(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle holding period input"""
        try:
            period = int(update.message.text.strip())
            context.user_data['period'] = period

            # Next step: Receive desired feedback style/tone input
            await update.message.reply_text(
                "어떤 스타일이나 톤으로 피드백을 받고 싶으신가요?\n"
                "예: 직설적으로, 전문가처럼, 친구처럼, 간결하게 등"
            )
            return ENTERING_TONE

        except ValueError:
            await update.message.reply_text(
                "숫자 형식으로 입력해주세요.\n"
                "예: 6"
            )
            return ENTERING_PERIOD

    @staticmethod
    async def handle_tone_input(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle desired feedback style/tone input"""
        tone = update.message.text.strip()
        context.user_data['tone'] = tone

        await update.message.reply_text(
            "이 종목을 매매한 이유나 주요 매매 이력이 있다면 알려주세요.\n"
            "(선택 사항, 없으면 '없음'을 입력하세요)"
        )
        return ENTERING_BACKGROUND

    async def handle_background_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle trading background input and generate AI response"""
        background = update.message.text.strip()
        context.user_data['background'] = background if background.lower() not in ['none', '없음'] else ""

        # Waiting response message
        waiting_message = await update.message.reply_text(
            "종목을 분석 중입니다... 잠시 기다려주세요."
        )

        # Request analysis from AI agent
        ticker = context.user_data['ticker']
        ticker_name = context.user_data.get('ticker_name', f"종목_{ticker}")
        avg_price = context.user_data['avg_price']
        period = context.user_data['period']
        tone = context.user_data['tone']
        background = context.user_data['background']
        chat_id = update.effective_chat.id
        user_id = update.effective_user.id

        try:
            # Query user memory context
            memory_context = ""
            if self.memory_manager:
                memory_context = self.memory_manager.build_llm_context(
                    user_id=user_id,
                    ticker=ticker,
                    max_tokens=4000
                )
                if memory_context:
                    logger.info(f"User memory context loaded: {len(memory_context)} chars")

            # Generate AI response (including memory_context)
            response = await generate_evaluation_response(
                ticker, ticker_name, avg_price, period, tone, background,
                memory_context=memory_context
            )

            # Check if response is empty
            if not response or not response.strip():
                response = "응답 생성 중 오류가 발생했습니다. 다시 시도해주세요."
                logger.error(f"Empty response generated: {ticker_name}({ticker})")

            # Delete waiting message
            await waiting_message.delete()

            # Send response
            sent_message = await update.message.reply_text(
                response + "\n\n💡 추가 질문이 있으시면 이 메시지에 답장(Reply)해주세요."
            )

            # Save conversation context
            conv_context = ConversationContext()
            conv_context.message_id = sent_message.message_id
            conv_context.chat_id = chat_id
            conv_context.user_id = update.effective_user.id
            conv_context.ticker = ticker
            conv_context.ticker_name = ticker_name
            conv_context.avg_price = avg_price
            conv_context.period = period
            conv_context.tone = tone
            conv_context.background = background
            conv_context.add_to_history("assistant", response)
            
            # Save context
            self.conversation_contexts[sent_message.message_id] = conv_context
            logger.info(f"Conversation context saved: Message ID {sent_message.message_id}")

            # Save evaluation result to user memory
            if self.memory_manager:
                self.memory_manager.save_memory(
                    user_id=user_id,
                    memory_type=self.memory_manager.MEMORY_EVALUATION,
                    content={
                        'ticker': ticker,
                        'ticker_name': ticker_name,
                        'avg_price': avg_price,
                        'period': period,
                        'tone': tone,
                        'background': background,
                        'response_summary': response[:500]  # Save response summary
                    },
                    ticker=ticker,
                    ticker_name=ticker_name,
                    market_type='kr',
                    command_source='/evaluate',
                    message_id=sent_message.message_id
                )
                logger.info(f"Evaluation result saved to memory: user={user_id}, ticker={ticker}")

        except Exception as e:
            logger.error(f"Error generating or sending response: {str(e)}, {traceback.format_exc()}")
            await waiting_message.delete()
            await update.message.reply_text("분석 중 오류가 발생했습니다. 다시 시도해주세요.")

        # End conversation
        return ConversationHandler.END

    @staticmethod
    async def handle_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle conversation cancellation (called from within ConversationHandler)"""
        # Initialize user data
        context.user_data.clear()

        await update.message.reply_text(
            "요청이 취소되었습니다.\n\n"
            "취소 가능한 명령어: /evaluate, /us_evaluate, /report, /us_report, /history, "
            "/signal, /us_signal, /theme, /us_theme, /ask, /insight, /journal"
        )
        return ConversationHandler.END

    @staticmethod
    async def handle_cancel_standalone(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle conversation cancellation (called from outside conversation)"""
        await update.message.reply_text(
            "현재 진행 중인 대화가 없습니다.\n\n"
            "취소 가능한 명령어: /evaluate, /us_evaluate, /report, /us_report, /history, "
            "/signal, /us_signal, /theme, /us_theme, /ask, /insight, /journal"
        )

    @staticmethod
    async def handle_error(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle error"""
        error_msg = str(context.error)
        logger.error(f"Error occurred: {error_msg}")

        # Error message to show user
        user_msg = "죄송합니다, 오류가 발생했습니다. 다시 시도해주세요."

        # Handle timeout error
        if "timed out" in error_msg.lower():
            user_msg = "요청 처리 시간이 초과되었습니다. 네트워크 상태를 확인하고 다시 시도해주세요."
        # Handle permission error
        elif "permission" in error_msg.lower():
            user_msg = "봇이 메시지를 보낼 권한이 없습니다. 그룹 설정을 확인해주세요."
        # Log various error information
        logger.error(f"Error details: {traceback.format_exc()}")

        # Send error response
        if update and update.effective_message:
            await update.effective_message.reply_text(user_msg)

    async def get_stock_code(self, stock_input):
        """
        Convert stock name or code input to stock code

        Args:
            stock_input (str): Stock code or name

        Returns:
            tuple: (stock code, stock name, error message)
        """
        # Input value defense code
        if not stock_input:
            logger.warning("Empty input value passed")
            return None, None, "종목명이나 코드를 입력해주세요."

        if not isinstance(stock_input, str):
            logger.warning(f"Invalid input type: {type(stock_input)}")
            stock_input = str(stock_input)

        original_input = stock_input
        stock_input = stock_input.strip()

        logger.info(f"Stock search started - Input: '{original_input}' -> Cleaned input: '{stock_input}'")

        # Check stock_name_map status
        if not hasattr(self, 'stock_name_map') or self.stock_name_map is None:
            logger.error("stock_name_map is not initialized")
            return None, None, "시스템 오류: 주식 데이터가 로드되지 않았습니다."

        if not isinstance(self.stock_name_map, dict):
            logger.error(f"stock_name_map type error: {type(self.stock_name_map)}")
            return None, None, "시스템 오류: 주식 데이터 형식이 잘못되었습니다."

        logger.info(f"stock_name_map status - Size: {len(self.stock_name_map)}")

        # Check stock_map status
        if not hasattr(self, 'stock_map') or self.stock_map is None:
            logger.warning("stock_map is not initialized")
            self.stock_map = {}

        # If already a stock code (6-digit number)
        if re.match(r'^\d{6}$', stock_input):
            logger.info(f"Recognized as 6-digit numeric code: {stock_input}")
            stock_code = stock_input
            stock_name = self.stock_map.get(stock_code)

            if stock_name:
                logger.info(f"Stock code match successful: {stock_code} -> {stock_name}")
                return stock_code, stock_name, None
            else:
                logger.warning(f"No name information for stock code {stock_code}")
                return stock_code, f"종목_{stock_code}", "해당 종목 코드에 대한 정보가 없습니다. 코드가 정확한지 확인해주세요."

        # If entered as stock name - check for exact match
        logger.info(f"Starting exact name match search: '{stock_input}'")

        # Log key samples for debugging
        sample_keys = list(self.stock_name_map.keys())[:5]
        logger.debug(f"stock_name_map key samples: {sample_keys}")

        # Exact match check
        if stock_input in self.stock_name_map:
            stock_code = self.stock_name_map[stock_input]
            logger.info(f"Exact match successful: '{stock_input}' -> {stock_code}")
            return stock_code, stock_input, None
        else:
            logger.info(f"Exact match failed: '{stock_input}'")

            # Log input value details
            logger.debug(f"Input details - Length: {len(stock_input)}, "
                         f"Bytes: {stock_input.encode('utf-8')}, "
                         f"Unicode: {[ord(c) for c in stock_input]}")

        # Partial stock name match search
        logger.info("Starting partial match search")
        possible_matches = []

        try:
            for name, code in self.stock_name_map.items():
                if not isinstance(name, str) or not isinstance(code, str):
                    logger.warning(f"Invalid data type: name={type(name)}, code={type(code)}")
                    continue

                if stock_input.lower() in name.lower():
                    possible_matches.append((name, code))
                    logger.debug(f"Partial match found: '{name}' ({code})")

        except Exception as e:
            logger.error(f"Error during partial match search: {e}")
            return None, None, "검색 중 오류가 발생했습니다."

        logger.info(f"Partial match results: {len(possible_matches)} found")

        if len(possible_matches) == 1:
            # Use if single match found
            stock_name, stock_code = possible_matches[0]
            logger.info(f"Single partial match successful: '{stock_name}' ({stock_code})")
            return stock_code, stock_name, None
        elif len(possible_matches) > 1:
            # Return error message if multiple matches
            logger.info(f"Multiple matches: {[f'{name}({code})' for name, code in possible_matches]}")
            match_info = "\n".join([f"{name} ({code})" for name, code in possible_matches[:5]])
            if len(possible_matches) > 5:
                match_info += f"\n... 외 {len(possible_matches)-5}개"

            return None, None, f"'{stock_input}'에 해당하는 종목이 여러 개 있습니다. 정확한 종목명이나 코드를 입력해주세요:\n{match_info}"
        else:
            # Return error message if no matches
            logger.warning(f"No matching stock: '{stock_input}'")
            return None, None, f"'{stock_input}'에 해당하는 종목을 찾을 수 없습니다. 정확한 종목명이나 코드를 입력해주세요."

    # US ticker validation cache
    _us_ticker_cache: dict = {}

    async def validate_us_ticker(self, ticker_input: str) -> tuple:
        """
        Validate US stock ticker symbol

        Args:
            ticker_input (str): Ticker symbol (e.g., AAPL, MSFT, GOOGL)

        Returns:
            tuple: (ticker, company_name, error_message)
        """
        if not ticker_input:
            return None, None, "티커 심볼을 입력해주세요. (예: AAPL, MSFT)"

        ticker = ticker_input.strip().upper()
        logger.info(f"Starting US ticker validation: {ticker}")

        # Check cache
        if ticker in self._us_ticker_cache:
            cached = self._us_ticker_cache[ticker]
            logger.info(f"Using cached US ticker info: {ticker} -> {cached['name']}")
            return ticker, cached['name'], None

        # Validate ticker format (1-5 letter alphabets)
        if not re.match(r'^[A-Z]{1,5}$', ticker):
            return None, None, (
                f"'{ticker_input}'는 유효한 미국 티커 형식이 아닙니다.\n"
                "미국 티커는 1-5개의 영문 알파벳입니다. (예: AAPL, MSFT, GOOGL)"
            )

        # Validate ticker with yfinance
        try:
            import yfinance as yf

            stock = yf.Ticker(ticker)
            info = stock.info

            # Extract company name
            company_name = info.get('longName') or info.get('shortName')

            if not company_name:
                return None, None, (
                    f"'{ticker}' 티커에 대한 정보를 찾을 수 없습니다.\n"
                    "티커 심볼이 올바른지 확인해주세요."
                )

            # Save to cache
            self._us_ticker_cache[ticker] = {'name': company_name}
            logger.info(f"US ticker validation successful: {ticker} -> {company_name}")

            return ticker, company_name, None

        except Exception as e:
            logger.error(f"Error validating US ticker: {e}")
            # Default handling if yfinance is missing or error occurs
            return ticker, f"{ticker} (미확인)", None

    # ==========================================================================
    # US stock evaluation handler (/us_evaluate)
    # ==========================================================================

    async def handle_us_evaluate_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle US evaluate command - first step"""
        user_id = update.effective_user.id
        user_name = update.effective_user.first_name

        # Check channel subscription
        is_subscribed = await self.check_channel_subscription(user_id)

        if not is_subscribed:
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\n"
                "https://t.me/stock_ai_agent"
            )
            return ConversationHandler.END

        # Check if group chat or private chat
        is_group = update.effective_chat.type in ["group", "supergroup"]

        logger.info(f"US evaluation command started - User: {user_name}, Chat type: {'group' if is_group else 'private'}")

        # Mention username in group chats
        greeting = f"{user_name}님, " if is_group else ""

        await update.message.reply_text(
            f"{greeting}🇺🇸 미국 주식 평가를 시작합니다.\n\n"
            "보유하신 종목의 티커 심볼을 입력해주세요.\n"
            "예: AAPL, MSFT, GOOGL, NVDA"
        )
        return US_CHOOSING_TICKER

    async def handle_us_ticker_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle US ticker input"""
        user_id = update.effective_user.id
        user_input = update.message.text.strip().upper()
        logger.info(f"Received US ticker input - User: {user_id}, Input: {user_input}")

        # Validate ticker
        ticker, company_name, error_message = await self.validate_us_ticker(user_input)

        if error_message:
            await update.message.reply_text(error_message)
            return US_CHOOSING_TICKER

        # Save stock information
        context.user_data['us_ticker'] = ticker
        context.user_data['us_ticker_name'] = company_name

        logger.info(f"US stock selected: {company_name} ({ticker})")

        await update.message.reply_text(
            f"🇺🇸 {company_name} ({ticker}) 종목을 선택하셨습니다.\n\n"
            f"USD 기준 평균 매수가를 입력해주세요. (숫자만 입력)\n"
            f"예: 150.50"
        )

        logger.info(f"State transition: US_ENTERING_AVGPRICE - User: {user_id}")
        return US_ENTERING_AVGPRICE

    @staticmethod
    async def handle_us_avgprice_input(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle US average purchase price input (USD)"""
        try:
            avg_price = float(update.message.text.strip().replace(',', '').replace('$', ''))
            context.user_data['us_avg_price'] = avg_price

            await update.message.reply_text(
                "보유 기간을 입력해주세요. (월 단위)\n"
                "예: 6 (6개월)"
            )
            return US_ENTERING_PERIOD

        except ValueError:
            await update.message.reply_text(
                "숫자 형식으로 입력해주세요. (예: 150.50)\n"
                "달러 기호($)와 쉼표는 자동으로 제거됩니다."
            )
            return US_ENTERING_AVGPRICE

    @staticmethod
    async def handle_us_period_input(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle US holding period input"""
        try:
            period = int(update.message.text.strip())
            context.user_data['us_period'] = period

            await update.message.reply_text(
                "어떤 스타일이나 톤으로 피드백을 받고 싶으신가요?\n"
                "예: 직설적으로, 전문가처럼, 친구처럼, 간결하게 등"
            )
            return US_ENTERING_TONE

        except ValueError:
            await update.message.reply_text(
                "숫자 형식으로 입력해주세요.\n"
                "예: 6"
            )
            return US_ENTERING_PERIOD

    @staticmethod
    async def handle_us_tone_input(update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle US feedback style/tone input"""
        tone = update.message.text.strip()
        context.user_data['us_tone'] = tone

        await update.message.reply_text(
            "이 종목을 매매한 이유나 주요 매매 이력이 있다면 알려주세요.\n"
            "(선택 사항, 없으면 '없음'을 입력하세요)"
        )
        return US_ENTERING_BACKGROUND

    async def handle_us_background_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle US trading background input and generate AI response"""
        background = update.message.text.strip()
        context.user_data['us_background'] = background if background.lower() not in ['none', '없음'] else ""

        # Waiting response message
        waiting_message = await update.message.reply_text(
            "🇺🇸 미국 주식을 분석 중입니다... 잠시 기다려주세요."
        )

        # Request analysis from AI agent
        ticker = context.user_data['us_ticker']
        ticker_name = context.user_data.get('us_ticker_name', ticker)
        avg_price = context.user_data['us_avg_price']
        period = context.user_data['us_period']
        tone = context.user_data['us_tone']
        background = context.user_data['us_background']
        chat_id = update.effective_chat.id
        user_id = update.effective_user.id

        try:
            # Query user memory context
            memory_context = ""
            if self.memory_manager:
                memory_context = self.memory_manager.build_llm_context(
                    user_id=user_id,
                    ticker=ticker,
                    max_tokens=4000
                )
                if memory_context:
                    logger.info(f"US user memory context loaded: {len(memory_context)} chars")

            # Generate US AI response (including memory_context)
            response = await generate_us_evaluation_response(
                ticker, ticker_name, avg_price, period, tone, background,
                memory_context=memory_context
            )

            # Check if response is empty
            if not response or not response.strip():
                response = "응답 생성 중 오류가 발생했습니다. 다시 시도해주세요."
                logger.error(f"Empty response generated: {ticker_name}({ticker})")

            # Delete waiting message
            await waiting_message.delete()

            # Send response
            sent_message = await update.message.reply_text(
                response + "\n\n💡 추가 질문이 있으시면 이 메시지에 답장(Reply)해주세요."
            )

            # Save conversation context (US market)
            conv_context = ConversationContext(market_type="us")
            conv_context.message_id = sent_message.message_id
            conv_context.chat_id = chat_id
            conv_context.user_id = update.effective_user.id
            conv_context.ticker = ticker
            conv_context.ticker_name = ticker_name
            conv_context.avg_price = avg_price
            conv_context.period = period
            conv_context.tone = tone
            conv_context.background = background
            conv_context.add_to_history("assistant", response)

            # Save context
            self.conversation_contexts[sent_message.message_id] = conv_context
            logger.info(f"US conversation context saved: Message ID {sent_message.message_id}")

            # Save evaluation result to user memory
            if self.memory_manager:
                self.memory_manager.save_memory(
                    user_id=user_id,
                    memory_type=self.memory_manager.MEMORY_EVALUATION,
                    content={
                        'ticker': ticker,
                        'ticker_name': ticker_name,
                        'avg_price': avg_price,
                        'period': period,
                        'tone': tone,
                        'background': background,
                        'response_summary': response[:500]  # Save response summary
                    },
                    ticker=ticker,
                    ticker_name=ticker_name,
                    market_type='us',
                    command_source='/us_evaluate',
                    message_id=sent_message.message_id
                )
                logger.info(f"US evaluation result saved to memory: user={user_id}, ticker={ticker}")

        except Exception as e:
            logger.error(f"Error generating or sending US response: {str(e)}, {traceback.format_exc()}")
            await waiting_message.delete()
            await update.message.reply_text("분석 중 오류가 발생했습니다. 다시 시도해주세요.")

        # End conversation
        return ConversationHandler.END

    # ==========================================================================
    # US stock report handler (/us_report)
    # ==========================================================================

    async def handle_us_report_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle US report command - first step"""
        user_id = update.effective_user.id
        user_name = update.effective_user.first_name

        # Check channel subscription
        is_subscribed = await self.check_channel_subscription(user_id)

        if not is_subscribed:
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\n"
                "https://t.me/stock_ai_agent"
            )
            return ConversationHandler.END

        # Check daily usage limit
        if not self.check_daily_limit(user_id, "us_report"):
            await update.message.reply_text(
                "⚠️ /us_report 명령어는 하루에 1회만 사용할 수 있습니다.\n\n"
                "내일 다시 이용해 주세요."
            )
            return ConversationHandler.END

        # Check if group chat or private chat
        is_group = update.effective_chat.type in ["group", "supergroup"]
        greeting = f"{user_name}님, " if is_group else ""

        await update.message.reply_text(
            f"{greeting}🇺🇸 미국 주식 분석 보고서 요청입니다.\n\n"
            "분석할 종목의 티커 심볼을 입력해주세요.\n"
            "예: AAPL, MSFT, GOOGL, NVDA"
        )

        return US_REPORT_CHOOSING_TICKER

    async def handle_us_report_ticker_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle ticker input for US report request"""
        user_id = update.effective_user.id
        user_input = update.message.text.strip()
        chat_id = update.effective_chat.id

        logger.info(f"Received US report ticker input - User: {user_id}, Input: {user_input}")

        # Validate ticker
        ticker, company_name, error_message = await self.validate_us_ticker(user_input)

        if error_message:
            await update.message.reply_text(error_message)
            return US_REPORT_CHOOSING_TICKER

        # Send waiting message
        waiting_message = await update.message.reply_text(
            f"🇺🇸 {company_name} ({ticker}) 분석 보고서 생성 요청이 등록되었습니다.\n\n"
            f"요청은 접수 순서대로 처리되며, 분석에는 약 5-10분이 소요됩니다.\n\n"
            f"다른 사용자의 요청이 많을 경우 대기 시간이 길어질 수 있습니다.\n\n"
            f"완료되면 바로 알려드리겠습니다."
        )

        # Create US analysis request and add to queue
        request = AnalysisRequest(
            stock_code=ticker,
            company_name=company_name,
            chat_id=chat_id,
            message_id=waiting_message.message_id,
            market_type="us",  # Explicitly mark as US stock
            user_id=user_id
        )

        # Check if cached US report exists
        is_cached, cached_content, cached_file, cached_pdf = get_cached_us_report(ticker)

        if is_cached:
            logger.info(f"Found cached US report: {cached_file}")
            # Send result immediately if cached report exists
            request.result = cached_content
            request.status = "completed"
            request.report_path = cached_file
            request.pdf_path = cached_pdf

            await waiting_message.edit_text(
                f"✅ {company_name} ({ticker}) 분석 보고서가 준비되었습니다. 곧 전송됩니다."
            )

            # Send result
            await self.send_report_result(request)
        else:
            # New analysis needed - add to queue
            self.pending_requests[request.id] = request
            analysis_queue.put(request)

        return ConversationHandler.END

    # ==========================================================================
    # Journal (investment diary) handler (/journal)
    # ==========================================================================

    async def handle_journal_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle journal command - first step"""
        user_id = update.effective_user.id
        user_name = update.effective_user.first_name
        chat_id = update.effective_chat.id
        chat_type = update.effective_chat.type

        logger.info(f"[JOURNAL] handle_journal_start - user_id: {user_id}, chat_id: {chat_id}, chat_type: {chat_type}")

        # Check channel subscription
        is_subscribed = await self.check_channel_subscription(user_id)

        if not is_subscribed:
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\n"
                "https://t.me/stock_ai_agent"
            )
            return ConversationHandler.END

        # Check if group chat or private chat
        is_group = update.effective_chat.type in ["group", "supergroup"]
        greeting = f"{user_name}님, " if is_group else ""

        await update.message.reply_text(
            f"{greeting}📝 투자 일지를 작성해주세요.\n\n"
            "종목 코드/티커와 함께 입력하면 해당 종목과 연결됩니다:\n"
            "예: \"AAPL 170달러까지 보유 예정\"\n"
            "예: \"005930 반도체 바닥 판단 중\"\n\n"
            "또는 자유롭게 생각을 적어주세요."
        )

        logger.info(f"[JOURNAL] Transitioned to JOURNAL_ENTERING state - user_id: {user_id}")
        return JOURNAL_ENTERING

    async def handle_journal_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle journal input"""
        user_id = update.effective_user.id
        chat_id = update.effective_chat.id
        text = update.message.text.strip()

        logger.info(f"[JOURNAL] handle_journal_input called - user_id: {user_id}, chat_id: {chat_id}")
        logger.info(f"[JOURNAL] Received journal input - User: {user_id}, Input: {text[:50]}...")

        # Extract ticker (regex)
        ticker, ticker_name, market_type = self._extract_ticker_from_text(text)

        # Save memory
        memory_id = self.memory_manager.save_journal(
            user_id=user_id,
            text=text,
            ticker=ticker,
            ticker_name=ticker_name,
            market_type=market_type,
            message_id=update.message.message_id
        )

        # Compose confirmation message
        # Add notice if over 500 characters
        length_note = ""
        if len(text) > 500:
            length_note = f"\n⚠️ 참고: AI 대화에서는 처음 500자만 참고됩니다. (현재: {len(text)}자)"

        if ticker:
            confirm_msg = (
                f"✅ 투자 일지에 기록되었습니다!\n\n"
                f"📝 종목: {ticker_name} ({ticker})\n"
                f"💭 \"{text[:100]}{'...' if len(text) > 100 else ''}\"\n"
                f"📅 {datetime.now().strftime('%Y-%m-%d %H:%M')}"
                f"{length_note}\n\n"
                f"💡 이 메시지에 답장하면 대화를 이어갈 수 있습니다!"
            )
        else:
            confirm_msg = (
                f"✅ 투자 일지에 기록되었습니다!\n\n"
                f"💭 \"{text[:100]}{'...' if len(text) > 100 else ''}\"\n"
                f"📅 {datetime.now().strftime('%Y-%m-%d %H:%M')}"
                f"{length_note}\n\n"
                f"💡 이 메시지에 답장하면 대화를 이어갈 수 있습니다!"
            )

        sent_message = await update.message.reply_text(confirm_msg)

        # Save journal context (for replies - AI conversation support)
        self.journal_contexts[sent_message.message_id] = {
            'user_id': user_id,
            'ticker': ticker,
            'ticker_name': ticker_name,
            'market_type': market_type,
            'conversation_history': [],  # AI conversation history
            'created_at': datetime.now()
        }

        logger.info(f"Journal saved: user={user_id}, ticker={ticker}, memory_id={memory_id}")

        return ConversationHandler.END

    async def _handle_journal_reply(self, update: Update, journal_ctx: Dict):
        """Handle replies to journal messages - AI conversation feature"""
        user_id = update.effective_user.id
        text = update.message.text.strip()

        logger.info(f"[JOURNAL_REPLY] Processing journal conversation - user_id: {user_id}, text: {text[:50]}...")

        # Check context expiration (extended to 30 minutes - conversation continuity)
        created_at = journal_ctx.get('created_at')
        if created_at and (datetime.now() - created_at).total_seconds() > 1800:
            await update.message.reply_text(
                "이전 대화 세션이 만료되었습니다.\n"
                "/journal 명령어로 새로운 대화를 시작해주세요. 💭"
            )
            return

        # Get ticker information (if available)
        ticker = journal_ctx.get('ticker')
        ticker_name = journal_ctx.get('ticker_name')
        market_type = journal_ctx.get('market_type', 'kr')
        conversation_history = journal_ctx.get('conversation_history', [])

        # Waiting message
        waiting_message = await update.message.reply_text(
            "💭 생각 중..."
        )

        try:
            # Build user memory context (also load stocks mentioned in current message)
            memory_context = self.memory_manager.build_llm_context(
                user_id=user_id,
                ticker=ticker,
                max_tokens=4000,
                user_message=text  # For extracting ticker from current message
            )

            # Add user message to conversation history
            conversation_history.append({'role': 'user', 'content': text})

            # Generate AI response
            response = await generate_journal_conversation_response(
                user_id=user_id,
                user_message=text,
                memory_context=memory_context,
                ticker=ticker,
                ticker_name=ticker_name,
                conversation_history=conversation_history
            )

            # Delete waiting message
            await waiting_message.delete()

            # Send response
            sent_message = await update.message.reply_text(
                response + "\n\n💡 답장으로 대화를 이어가세요!"
            )

            # Add AI response to conversation history
            conversation_history.append({'role': 'assistant', 'content': response})

            # Update context with new message ID
            self.journal_contexts[sent_message.message_id] = {
                'user_id': user_id,
                'ticker': ticker,
                'ticker_name': ticker_name,
                'market_type': market_type,
                'conversation_history': conversation_history,
                'created_at': datetime.now()
            }

            # Save user message to journal (optional)
            self.memory_manager.save_journal(
                user_id=user_id,
                text=text,
                ticker=ticker,
                ticker_name=ticker_name,
                market_type=market_type,
                message_id=update.message.message_id
            )

            logger.info(f"[JOURNAL_REPLY] AI conversation response complete: user={user_id}, response_len={len(response)}")

        except Exception as e:
            logger.error(f"[JOURNAL_REPLY] Error: {e}")
            await waiting_message.delete()
            await update.message.reply_text(
                "죄송합니다, 응답 생성 중 문제가 발생했습니다. 다시 시도해주세요. 💭"
            )

    def _extract_ticker_from_text(self, text: str) -> tuple:
        """
        Extract ticker/stock code from text

        Args:
            text: Input text

        Returns:
            tuple: (ticker, ticker_name, market_type)

        Note:
            Check Korean stocks first (Korean stocks are more common in Korean text)
        """
        # Korean stock code pattern (6-digit number)
        kr_pattern = r'\b(\d{6})\b'
        # US ticker pattern (1-5 uppercase letters, word boundary)
        us_pattern = r'\b([A-Z]{1,5})\b'

        # 1. Check Korean stock code first (priority)
        kr_matches = re.findall(kr_pattern, text)
        for code in kr_matches:
            if code in self.stock_map:
                return code, self.stock_map[code], 'kr'

        # 2. Find Korean stock name (search in stock_name_map)
        for name, code in self.stock_name_map.items():
            if name in text:
                return code, name, 'kr'

        # 3. Find US ticker (only when no Korean stock found)
        # Exclude words: common English words + financial terms
        excluded_words = {
            # Common English words
            'I', 'A', 'AN', 'THE', 'IN', 'ON', 'AT', 'TO', 'FOR', 'OF',
            'AND', 'OR', 'IS', 'IT', 'AI', 'AM', 'PM', 'VS', 'OK', 'NO',
            'IF', 'AS', 'BY', 'SO', 'UP', 'BE', 'WE', 'HE', 'ME', 'MY',
            # Financial indicators/terms
            'PER', 'PBR', 'ROE', 'ROA', 'EPS', 'BPS', 'PSR', 'PCR',
            'EBITDA', 'EBIT', 'YOY', 'QOQ', 'MOM', 'YTD', 'TTM',
            'PE', 'PS', 'PB', 'EV', 'FCF', 'DCF', 'WACC', 'CAGR',
            'IPO', 'M', 'B', 'K', 'KRW', 'USD', 'EUR', 'JPY', 'CNY',
            # Other abbreviations
            'CEO', 'CFO', 'CTO', 'COO', 'IR', 'PR', 'HR', 'IT', 'AI',
            'HBM', 'DRAM', 'NAND', 'SSD', 'GPU', 'CPU', 'AP', 'PC',
        }

        us_matches = re.findall(us_pattern, text)
        for ticker in us_matches:
            if ticker in excluded_words:
                continue
            # Check cache
            if ticker in self._us_ticker_cache:
                return ticker, self._us_ticker_cache[ticker]['name'], 'us'
            # Validate with yfinance
            try:
                import yfinance as yf
                stock = yf.Ticker(ticker)
                info = stock.info
                company_name = info.get('longName') or info.get('shortName')
                if company_name:
                    self._us_ticker_cache[ticker] = {'name': company_name}
                    return ticker, company_name, 'us'
            except Exception:
                pass

        return None, None, 'kr'

    # ==========================================================================
    # Firecrawl AI Research handlers
    # ==========================================================================

    _DISCLAIMER_KR = "\n\n⚠️ 본 내용은 투자 참고용이며, 투자 판단의 책임은 본인에게 있습니다."
    _DISCLAIMER_US = "\n\n⚠️ This is for informational purposes only. Investment decisions are your own responsibility."

    # gh #263: strip LLM-emitted disclaimer block before appending our canonical one.
    _strip_trailing_disclaimer = staticmethod(_strip_trailing_disclaimer)

    # Grounding directive appended to every Firecrawl agent (Spark) prompt.
    # Forces the agent to actually scrape the live web and cite sources rather than
    # answering from its own parametric knowledge — verified to make spark-1-mini
    # emit source URLs instead of ungrounded text.
    _FIRECRAWL_GROUNDING = (
        "\n\n반드시 웹을 검색하고 실제 뉴스/기사 페이지를 스크랩하여 각 주장에 출처 URL을 명시하라. "
        "너의 사전지식이 아니라 오늘 기준 최신 웹 데이터에 근거하라."
    )

    # Firecrawl Spark(/AGENT) jobs can hang server-side in IN_PROGRESS or FAIL
    # without returning, which would otherwise block the executor thread until the
    # 300s ConversationHandler timeout. Cap the wait and fall back to search+Claude.
    _FIRECRAWL_AGENT_TIMEOUT = 120  # seconds

    async def _run_firecrawl_command(self, update: Update, prompt: str, disclaimer: str,
                                     model: str = "spark-1-mini", max_credits: int = 200,
                                     fallback_search_query: str = None,
                                     fallback_analysis_prompt: str = None,
                                     timeout: int = None):
        """
        Common helper for Firecrawl-based commands.
        Sends a waiting message, calls firecrawl_agent, then replaces it with the result.

        Args:
            model: Spark agent model — "spark-1-mini" (default) or "spark-1-pro".
            max_credits: Per-call credit ceiling passed to the agent.
            fallback_search_query: When set together with fallback_analysis_prompt,
                a Firecrawl Spark timeout/failure/empty result triggers a search+Claude
                fallback (generate_firecrawl_search_response) instead of erroring out.
            fallback_analysis_prompt: Analysis instruction passed to the Claude fallback.
            timeout: Seconds to wait for the Spark agent before giving up (defaults to
                _FIRECRAWL_AGENT_TIMEOUT). Heavy spark-1-pro jobs need a larger budget so
                legitimate deep-research runs are not cut off prematurely.

        Returns:
            tuple: (success: bool, response_text: str | None, sent_msg_id: int | None)
        """
        chat_id = update.effective_chat.id
        agent_timeout = timeout or self._FIRECRAWL_AGENT_TIMEOUT
        waiting_msg = await update.message.reply_text("🔍 리서치 중...")

        try:
            try:
                result = await asyncio.wait_for(
                    asyncio.get_event_loop().run_in_executor(
                        None, lambda: firecrawl_agent(prompt, max_credits=max_credits, model=model)
                    ),
                    timeout=agent_timeout,
                )
            except asyncio.TimeoutError:
                logger.warning(
                    f"Firecrawl agent timed out after {agent_timeout}s "
                    f"(model={model}) — Spark job likely stuck server-side"
                )
                result = None

            # Fallback: Spark hung/failed/returned empty — retry via search+Claude (Anthropic).
            if not result and fallback_search_query and fallback_analysis_prompt:
                logger.info("Falling back to search+Claude after Firecrawl agent miss")
                try:
                    await waiting_msg.edit_text("🔍 리서치 중... (보조 엔진 전환)")
                except Exception:
                    pass
                try:
                    result = await generate_firecrawl_search_response(
                        fallback_search_query, fallback_analysis_prompt
                    )
                except Exception as fe:
                    logger.error(f"Search+Claude fallback failed: {fe}", exc_info=True)
                    result = None

            # Delete waiting message
            try:
                await waiting_msg.delete()
            except Exception:
                pass

            if result:
                # Strip any LLM-emitted disclaimer so it does not double up with our canonical one (gh #263).
                result = self._strip_trailing_disclaimer(result)
                # Telegram message limit is 4096 chars — chunk if needed
                followup_hint = "\n\n💬 이 메시지에 답장(Reply)하여 추가 질문할 수 있습니다!"
                full_text = result + disclaimer + followup_hint
                if len(full_text) > 4096:
                    for i in range(0, len(result), 4096):
                        chunk = result[i:i + 4096]
                        await self.application.bot.send_message(chat_id=chat_id, text=chunk)
                    sent_msg = await self.application.bot.send_message(
                        chat_id=chat_id, text=disclaimer.strip() + followup_hint
                    )
                else:
                    sent_msg = await self.application.bot.send_message(chat_id=chat_id, text=full_text)
                return True, result, sent_msg.message_id
            else:
                await self.application.bot.send_message(
                    chat_id=chat_id,
                    text="⚠️ Firecrawl AI로부터 응답을 받지 못했습니다.\n"
                         "가능한 원인: 크레딧 부족, 서버 타임아웃, 또는 검색 결과 없음.\n"
                         "잠시 후 다시 시도하거나 질문을 바꿔보세요."
                )
                return False, None, None

        except Exception as e:
            logger.error(f"Firecrawl command error: {e}", exc_info=True)
            try:
                await waiting_msg.delete()
            except Exception:
                pass
            await self.application.bot.send_message(
                chat_id=chat_id,
                text=f"⚠️ 리서치 중 오류가 발생했습니다: {type(e).__name__}\n"
                     "잠시 후 다시 시도해주세요."
            )
            return False, None, None

    async def _run_search_and_claude(self, update: Update, search_query: str, analysis_prompt: str, disclaimer: str):
        """
        Cost-efficient helper using Firecrawl /search (2 credits) + Claude Sonnet 5.
        Uses the same global MCPApp + AnthropicAugmentedLLM pattern as /evaluate.

        Returns:
            tuple: (success: bool, response_text: str | None, sent_msg_id: int | None)
        """
        chat_id = update.effective_chat.id
        waiting_msg = await update.message.reply_text("🔍 리서치 중...")

        try:
            result = await generate_firecrawl_search_response(search_query, analysis_prompt)

            try:
                await waiting_msg.delete()
            except Exception:
                pass

            if result:
                # Strip any LLM-emitted disclaimer so it does not double up with our canonical one (gh #263).
                result = self._strip_trailing_disclaimer(result)
                followup_hint = "\n\n💬 이 메시지에 답장(Reply)하여 추가 질문할 수 있습니다!"
                full_text = result + disclaimer + followup_hint
                if len(full_text) > 4096:
                    for i in range(0, len(result), 4096):
                        chunk = result[i:i + 4096]
                        await self.application.bot.send_message(chat_id=chat_id, text=chunk)
                    sent_msg = await self.application.bot.send_message(
                        chat_id=chat_id, text=disclaimer.strip() + followup_hint
                    )
                else:
                    sent_msg = await self.application.bot.send_message(chat_id=chat_id, text=full_text)
                return True, result, sent_msg.message_id
            else:
                await self.application.bot.send_message(
                    chat_id=chat_id,
                    text="⚠️ 검색 결과가 부족하거나 분석 생성에 실패했습니다.\n"
                         "질문을 바꿔서 다시 시도해보세요."
                )
                return False, None, None

        except Exception as e:
            logger.error(f"Search+Claude command error: {e}", exc_info=True)
            try:
                await waiting_msg.delete()
            except Exception:
                pass
            await self.application.bot.send_message(
                chat_id=chat_id,
                text=f"⚠️ 리서치 중 오류가 발생했습니다: {type(e).__name__}\n"
                     "잠시 후 다시 시도해주세요."
            )
            return False, None, None

    # ==========================================================================
    # /signal — KR event impact
    # ==========================================================================

    async def handle_signal_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        user_id = update.effective_user.id
        if not await self.check_channel_subscription(user_id):
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\nhttps://t.me/stock_ai_agent"
            )
            return ConversationHandler.END
        allowed, _ = self.peek_daily_limit_count(user_id, "signal", max_count=10)
        if not allowed:
            await update.message.reply_text("⚠️ 오늘의 /signal 사용 횟수(10회)를 모두 소진하였습니다.")
            return ConversationHandler.END
        await update.message.reply_text(
            "🇰🇷 어떤 이벤트/뉴스가 한국 증시에 미치는 영향을 분석할까요?\n"
            "예: 트럼프 관세 인상, 이란 미국 휴전, 삼성전자 역대급 실적"
        )
        return SIGNAL_ENTERING_QUERY

    async def handle_signal_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        user_id = update.effective_user.id
        allowed, _ = self.check_daily_limit_count(user_id, "signal", max_count=10)
        if not allowed:
            await update.message.reply_text("⚠️ 오늘의 /signal 사용 횟수(10회)를 모두 소진하였습니다.")
            return ConversationHandler.END
        event = update.message.text.strip()[:200]
        today = datetime.now().strftime("%Y년 %m월")
        logger.info(f"/signal query - user={user_id}, event='{event[:50]}'")
        prompt = (
            f"오늘은 {today} 기준입니다. '최근/현재'는 이 시점으로 해석하세요.\n"
            f"'{event}'가 한국 주식시장에 미치는 영향을 분석해줘.\n"
            "1. 수혜 예상 섹터와 대표 종목 3개\n"
            "2. 피해 예상 섹터와 대표 종목 3개\n"
            "3. 과거 유사 사례\n"
            "4. 개인투자자 대응 전략\n"
            "텔레그램 메시지 형태로 이모지 포함하여 작성. 3000자 이내."
        ) + self._FIRECRAWL_GROUNDING
        success, response_text, msg_id = await self._run_firecrawl_command(
            update, prompt, self._DISCLAIMER_KR, model="spark-1-mini",
            fallback_search_query=event, fallback_analysis_prompt=prompt
        )
        if success and msg_id and response_text:
            ctx = FirecrawlConversationContext("signal", event)
            ctx.add_to_history("assistant", response_text)
            self.firecrawl_contexts[msg_id] = ctx
        return ConversationHandler.END

    # ==========================================================================
    # /us_signal — US event impact
    # ==========================================================================

    async def handle_us_signal_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        user_id = update.effective_user.id
        if not await self.check_channel_subscription(user_id):
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\nhttps://t.me/stock_ai_agent"
            )
            return ConversationHandler.END
        allowed, _ = self.peek_daily_limit_count(user_id, "us_signal", max_count=10)
        if not allowed:
            await update.message.reply_text("⚠️ 오늘의 /us_signal 사용 횟수(10회)를 모두 소진하였습니다.")
            return ConversationHandler.END
        await update.message.reply_text(
            "🇺🇸 어떤 이벤트/뉴스가 미국 증시에 미치는 영향을 분석할까요?\n"
            "예: TSMC 실적 서프라이즈, 연준 금리 인상, 엔비디아 신제품 출시"
        )
        return US_SIGNAL_ENTERING_QUERY

    async def handle_us_signal_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        user_id = update.effective_user.id
        allowed, _ = self.check_daily_limit_count(user_id, "us_signal", max_count=10)
        if not allowed:
            await update.message.reply_text("⚠️ 오늘의 /us_signal 사용 횟수(10회)를 모두 소진하였습니다.")
            return ConversationHandler.END
        event = update.message.text.strip()[:200]
        today = datetime.now().strftime("%Y %B")
        logger.info(f"/us_signal query - user={user_id}, event='{event[:50]}'")
        prompt = (
            f"오늘은 {today} 기준입니다. '최근/현재'는 이 시점으로 해석하세요.\n"
            f"'{event}'가 미국 주식시장(S&P500, NASDAQ)에 미치는 영향을 분석해줘.\n"
            "1. 수혜 예상 섹터와 대표 종목 3개 (최근 주가 흐름 포함)\n"
            "2. 피해 예상 섹터와 대표 종목 3개 (최근 주가 흐름 포함)\n"
            "3. 과거 유사 사례\n"
            "4. 개인투자자 대응 전략\n"
            "한국어로, 텔레그램 메시지 형태로 이모지 포함하여 작성. 3000자 이내."
        ) + self._FIRECRAWL_GROUNDING
        success, response_text, msg_id = await self._run_firecrawl_command(
            update, prompt, self._DISCLAIMER_KR, model="spark-1-mini",
            fallback_search_query=event, fallback_analysis_prompt=prompt
        )
        if success and msg_id and response_text:
            ctx = FirecrawlConversationContext("us_signal", event)
            ctx.add_to_history("assistant", response_text)
            self.firecrawl_contexts[msg_id] = ctx
        return ConversationHandler.END

    # ==========================================================================
    # /theme — KR theme health check
    # ==========================================================================

    async def handle_theme_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        user_id = update.effective_user.id
        if not await self.check_channel_subscription(user_id):
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\nhttps://t.me/stock_ai_agent"
            )
            return ConversationHandler.END
        allowed, _ = self.peek_daily_limit_count(user_id, "theme", max_count=10)
        if not allowed:
            await update.message.reply_text("⚠️ 오늘의 /theme 사용 횟수(10회)를 모두 소진하였습니다.")
            return ConversationHandler.END
        await update.message.reply_text(
            "🇰🇷 어떤 한국 테마/섹터의 건강도를 진단할까요?\n"
            "예: 2차전지, 방산, AI 반도체, 조선"
        )
        return THEME_ENTERING_QUERY

    async def handle_theme_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        user_id = update.effective_user.id
        allowed, _ = self.check_daily_limit_count(user_id, "theme", max_count=10)
        if not allowed:
            await update.message.reply_text("⚠️ 오늘의 /theme 사용 횟수(10회)를 모두 소진하였습니다.")
            return ConversationHandler.END
        theme = update.message.text.strip()[:200]
        today = datetime.now().strftime("%Y년 %m월")
        logger.info(f"/theme query - user={user_id}, theme='{theme[:50]}'")
        prompt = (
            f"오늘은 {today} 기준입니다. '최근/현재'는 이 시점으로 해석하세요.\n"
            f"한국 주식시장에서 '{theme}' 테마의 현재 건강도를 진단해줘.\n"
            "1. 테마 온도 (🟢과열/🟡적정/🔴냉각 중 택1, 근거 포함)\n"
            "2. 대장주 3개와 최근 주가 동향\n"
            "3. 긍정 요인 3개\n"
            "4. 부정 요인 3개\n"
            "5. 진입 타이밍 의견\n"
            "텔레그램 메시지 형태로 이모지 포함하여 작성. 3000자 이내."
        ) + self._FIRECRAWL_GROUNDING
        success, response_text, msg_id = await self._run_firecrawl_command(
            update, prompt, self._DISCLAIMER_KR, model="spark-1-mini",
            fallback_search_query=theme, fallback_analysis_prompt=prompt
        )
        if success and msg_id and response_text:
            ctx = FirecrawlConversationContext("theme", theme)
            ctx.add_to_history("assistant", response_text)
            self.firecrawl_contexts[msg_id] = ctx
        return ConversationHandler.END

    # ==========================================================================
    # /us_theme — US theme health check
    # ==========================================================================

    async def handle_us_theme_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        user_id = update.effective_user.id
        if not await self.check_channel_subscription(user_id):
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\nhttps://t.me/stock_ai_agent"
            )
            return ConversationHandler.END
        allowed, _ = self.peek_daily_limit_count(user_id, "us_theme", max_count=10)
        if not allowed:
            await update.message.reply_text("⚠️ 오늘의 /us_theme 사용 횟수(10회)를 모두 소진하였습니다.")
            return ConversationHandler.END
        await update.message.reply_text(
            "🇺🇸 어떤 미국 테마/섹터의 건강도를 진단할까요?\n"
            "예: AI 데이터센터, 바이오, 방산, 클라우드"
        )
        return US_THEME_ENTERING_QUERY

    async def handle_us_theme_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        user_id = update.effective_user.id
        allowed, _ = self.check_daily_limit_count(user_id, "us_theme", max_count=10)
        if not allowed:
            await update.message.reply_text("⚠️ 오늘의 /us_theme 사용 횟수(10회)를 모두 소진하였습니다.")
            return ConversationHandler.END
        theme = update.message.text.strip()[:200]
        today = datetime.now().strftime("%Y %B")
        logger.info(f"/us_theme query - user={user_id}, theme='{theme[:50]}'")
        prompt = (
            f"오늘은 {today} 기준입니다. '최근/현재'는 이 시점으로 해석하세요.\n"
            f"미국 주식시장(S&P500, NASDAQ)에서 '{theme}' 테마의 현재 건강도를 진단해줘.\n"
            "1. 테마 온도 (🟢과열/🟡적정/🔴냉각 중 택1, 근거 포함)\n"
            "2. 대장주 3개와 최근 주가 동향\n"
            "3. 긍정 요인 3개\n"
            "4. 부정 요인 3개\n"
            "5. 진입 타이밍 의견\n"
            "한국어로, 텔레그램 메시지 형태로 이모지 포함하여 작성. 3000자 이내."
        ) + self._FIRECRAWL_GROUNDING
        success, response_text, msg_id = await self._run_firecrawl_command(
            update, prompt, self._DISCLAIMER_KR, model="spark-1-mini",
            fallback_search_query=theme, fallback_analysis_prompt=prompt
        )
        if success and msg_id and response_text:
            ctx = FirecrawlConversationContext("us_theme", theme)
            ctx.add_to_history("assistant", response_text)
            self.firecrawl_contexts[msg_id] = ctx
        return ConversationHandler.END

    # ==========================================================================
    # /ask — free-form investment research
    # ==========================================================================

    async def handle_ask_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        user_id = update.effective_user.id
        if not await self.check_channel_subscription(user_id):
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\nhttps://t.me/stock_ai_agent"
            )
            return ConversationHandler.END
        # Peek (no consume) — consume happens in handle_ask_query when the query is actually made
        allowed, remaining = self.peek_daily_limit_count(user_id, "ask", max_count=3)
        if not allowed:
            await update.message.reply_text(
                "⚠️ 오늘의 /ask 사용 횟수(3회)를 모두 소진하였습니다.\n내일 다시 이용해주세요."
            )
            return ConversationHandler.END
        await update.message.reply_text(
            f"💬 투자 관련 질문을 입력해주세요. (오늘 남은 횟수: {remaining}회)\n"
            "예: 워렌 버핏이 올해 뭘 샀어? / 코스피 다음주도 오를까?"
        )
        return ASK_ENTERING_QUERY

    async def handle_ask_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        user_id = update.effective_user.id
        # Consume the count here (after user actually submits the query)
        allowed, remaining = self.check_daily_limit_count(user_id, "ask", max_count=3)
        if not allowed:
            await update.message.reply_text(
                "⚠️ 오늘의 /ask 사용 횟수(3회)를 모두 소진하였습니다.\n내일 다시 이용해주세요."
            )
            return ConversationHandler.END
        question = update.message.text.strip()[:500]
        today = datetime.now().strftime("%Y년 %m월 %d일")
        logger.info(f"/ask query - user={user_id}, question='{question[:50]}', remaining={remaining}")
        prompt = (
            f"오늘은 {today}입니다. 다음 투자 관련 질문에 대해 최신 정보를 기반으로 답변해줘:\n\n"
            f"{question}\n\n"
            "한국어로, 텔레그램 메시지 형태로 이모지 포함하여 작성. 3000자 이내."
        ) + self._FIRECRAWL_GROUNDING
        success, response_text, msg_id = await self._run_firecrawl_command(
            update, prompt, self._DISCLAIMER_KR, model="spark-1-pro", max_credits=2000,
            fallback_search_query=question, fallback_analysis_prompt=prompt, timeout=240
        )
        if success:
            if remaining > 0:
                await update.message.reply_text(f"📊 오늘 남은 /ask 횟수: {remaining}회")
            if msg_id and response_text:
                ctx = FirecrawlConversationContext("ask", question)
                ctx.add_to_history("assistant", response_text)
                self.firecrawl_contexts[msg_id] = ctx
        else:
            self.refund_daily_limit_count(user_id, "ask")
            logger.info(f"/ask refunded for user={user_id} due to failure")
        return ConversationHandler.END

    # ==========================================================================
    # /insight — PRISM archive query (server-to-server or direct)
    # ==========================================================================

    async def handle_insight_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Handle /insight command — ask user for their archive query."""
        user_id = update.effective_user.id
        if not await self.check_channel_subscription(user_id):
            await update.message.reply_text(
                "이 봇은 채널 구독자만 사용할 수 있습니다.\n"
                "아래 링크를 통해 채널을 구독해주세요:\n\nhttps://t.me/stock_ai_agent"
            )
            return ConversationHandler.END
        await update.message.reply_text(
            "🗂 PRISM 아카이브에 쌓인 실제 분석 데이터를 기반으로 답변합니다.\n\n"
            "질문을 입력해주세요:\n"
            "예: 하락장에서 분석된 반도체 종목들 30일 수익률은?\n"
            "예: 손절 발동 후 회복한 종목 비율은?"
        )
        return INSIGHT_ENTERING_QUERY

    @staticmethod
    def _insight_feedback_keyboard(insight_id: Optional[int]):
        """Build 👍/👎 inline keyboard for /insight answers."""
        if insight_id is None:
            return None
        return InlineKeyboardMarkup([[
            InlineKeyboardButton("👍 도움됨", callback_data=f"insight_fb:1:{insight_id}"),
            InlineKeyboardButton("👎 부정확", callback_data=f"insight_fb:-1:{insight_id}"),
        ]])

    async def handle_insight_feedback_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Process inline keyboard 👍/👎 → POST /feedback (or local CRUD)."""
        query = update.callback_query
        if not query or not query.data:
            return
        try:
            await query.answer()
        except Exception:
            pass
        try:
            _, score_str, iid_str = query.data.split(":", 2)
            score = int(score_str)
            insight_id = int(iid_str)
        except Exception:
            return
        user_id = query.from_user.id if query.from_user else 0

        ok, new_conf = await self._call_insight_feedback(insight_id, user_id, score)
        if ok:
            label = "👍 도움됨" if score > 0 else "👎 부정확"
            try:
                await query.edit_message_reply_markup(
                    reply_markup=InlineKeyboardMarkup([[
                        InlineKeyboardButton(
                            f"{label} 기록됨 (신뢰도 {new_conf:+.2f})",
                            callback_data="insight_fb:noop:0",
                        )
                    ]])
                )
            except Exception:
                pass
        else:
            try:
                await query.answer("⚠️ 피드백 저장 실패", show_alert=False)
            except Exception:
                pass

    async def _call_insight_feedback(
        self, insight_id: int, user_id: int, score: int,
    ) -> tuple:
        """Returns (ok, new_confidence)."""
        api_url = os.getenv("ARCHIVE_API_URL", "").rstrip("/")
        api_key = os.getenv("ARCHIVE_API_KEY", "")
        if api_url:
            import aiohttp
            payload = {
                "insight_id": insight_id, "user_id": user_id, "score": score,
            }
            headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
            try:
                async with aiohttp.ClientSession() as s:
                    async with s.post(
                        f"{api_url}/feedback",
                        json=payload, headers=headers,
                        timeout=aiohttp.ClientTimeout(total=10),
                    ) as resp:
                        if resp.status != 200:
                            logger.error(f"/feedback {resp.status}: {await resp.text()}")
                            return False, 0.0
                        data = await resp.json()
                        return True, float(data.get("new_confidence", 0.0))
            except Exception as e:
                logger.error(f"_call_insight_feedback HTTP error: {e}", exc_info=True)
                return False, 0.0
        else:
            try:
                from cores.archive.persistent_insights import record_feedback
                from cores.archive.archive_db import ARCHIVE_DB_PATH
                import aiosqlite
                ok = await record_feedback(
                    insight_id=insight_id, user_id=user_id, score=score,
                )
                if not ok:
                    return False, 0.0
                async with aiosqlite.connect(str(ARCHIVE_DB_PATH)) as db:
                    cur = await db.execute(
                        "SELECT COALESCE(confidence_score, 0.0) FROM persistent_insights WHERE id=?",
                        (insight_id,),
                    )
                    row = await cur.fetchone()
                    new_conf = float(row[0]) if row else 0.0
                return True, new_conf
            except Exception as e:
                logger.error(f"local feedback failed: {e}", exc_info=True)
                return False, 0.0

    async def handle_insight_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
        """Route to InsightAgent (retrieval + function calling + auto-save)."""
        chat_id = update.effective_chat.id
        user_id = update.effective_user.id
        question = update.message.text.strip()
        if len(question) > 2000:
            await update.message.reply_text("질문은 2000자 이내로 입력해주세요.")
            return ConversationHandler.END
        logger.info(f"/insight - user={user_id}, q='{question[:60]}'")

        waiting_msg = await update.message.reply_text(
            "🧭 누적 인사이트 + 시장 데이터 결합 중…"
        )

        try:
            payload = await self._call_insight_agent(
                question=question, user_id=user_id, chat_id=chat_id,
            )
            try:
                await waiting_msg.delete()
            except Exception:
                pass

            if not payload:
                await self.application.bot.send_message(
                    chat_id=chat_id,
                    text="⚠️ 인사이트 조회에 실패했습니다. 잠시 후 다시 시도해주세요.",
                )
                return ConversationHandler.END

            header = "🧭 PRISM 장기 인사이트"
            body = payload.answer
            quota_line = (
                f"\n\n📊 오늘 남은 /insight: {payload.remaining_quota}회"
                if payload.remaining_quota >= 0 else ""
            )
            full = f"{header}\n\n{body}{quota_line}\n\n{self._DISCLAIMER_KR.strip()}"

            kb = self._insight_feedback_keyboard(payload.insight_id)
            if len(full) > 4096:
                # split but keep final message_id for reply mapping
                chunks = [full[i:i + 4096] for i in range(0, len(full), 4096)]
                last_sent = None
                for idx, ch in enumerate(chunks):
                    is_last = (idx == len(chunks) - 1)
                    last_sent = await self.application.bot.send_message(
                        chat_id=chat_id, text=ch,
                        reply_markup=(kb if is_last else None),
                    )
                sent = last_sent
            else:
                sent = await self.application.bot.send_message(
                    chat_id=chat_id, text=full, reply_markup=kb,
                )

            # Register multi-turn reply context
            if sent is not None:
                ic = InsightConversationContext(
                    original_question=question,
                    user_id=user_id, chat_id=chat_id,
                )
                ic.add_turn(question, payload.answer, payload.insight_id)
                self.insight_contexts[sent.message_id] = ic
        except Exception as e:
            logger.error(f"/insight error: {e}", exc_info=True)
            try:
                await waiting_msg.delete()
            except Exception:
                pass
            await self.application.bot.send_message(
                chat_id=chat_id,
                text=f"⚠️ 인사이트 처리 중 오류: {type(e).__name__}\n잠시 후 다시 시도해주세요.",
            )
        return ConversationHandler.END

    async def _call_insight_agent(
        self,
        question: str,
        user_id: int,
        chat_id: int,
        previous_insight_id: Optional[int] = None,
    ) -> Optional["InsightPayload"]:
        """Dispatch to pipeline API (two-server) or local agent (single-server)."""
        api_url = os.getenv("ARCHIVE_API_URL", "").rstrip("/")
        api_key = os.getenv("ARCHIVE_API_KEY", "")
        daily_limit = int(os.getenv("INSIGHT_DAILY_LIMIT", "20"))

        if api_url:
            import aiohttp
            payload_body = {
                "question": question,
                "user_id": user_id,
                "chat_id": chat_id,
                "daily_limit": daily_limit,
                "previous_insight_id": previous_insight_id,
            }
            headers = (
                {"Authorization": f"Bearer {api_key}"} if api_key else {}
            )
            try:
                async with aiohttp.ClientSession() as s:
                    async with s.post(
                        f"{api_url}/insight_agent",
                        json=payload_body, headers=headers,
                        timeout=aiohttp.ClientTimeout(total=90),
                    ) as resp:
                        if resp.status != 200:
                            logger.error(
                                f"/insight_agent {resp.status}: "
                                f"{await resp.text()}"
                            )
                            return None
                        data = await resp.json()
                        return InsightPayload(
                            answer=data["answer"],
                            insight_id=data.get("insight_id"),
                            remaining_quota=int(data.get("remaining_quota", -1)),
                            tickers_mentioned=data.get("tickers_mentioned", []),
                            tools_used=data.get("tools_used", []),
                        )
            except Exception as e:
                logger.error(
                    f"_call_insight_agent HTTP error: {e}", exc_info=True,
                )
                return None
        else:
            try:
                from cores.archive.insight_agent import InsightAgent
                agent = InsightAgent()
                r = await agent.run(
                    question=question, user_id=user_id, chat_id=chat_id,
                    daily_limit=daily_limit,
                    previous_insight_id=previous_insight_id,
                )
                return InsightPayload(
                    answer=r.answer,
                    insight_id=r.insight_id,
                    remaining_quota=r.remaining_quota,
                    tickers_mentioned=r.tickers_mentioned,
                    tools_used=r.tools_used,
                )
            except Exception as e:
                logger.error(
                    f"Local insight agent failed: {e}", exc_info=True,
                )
                return None

    async def _handle_insight_reply(
        self, update: Update, ic: "InsightConversationContext",
    ):
        """Reply-based multi-turn follow-up on /insight answers (30min TTL)."""
        user_question = update.message.text.strip()
        if len(user_question) > 2000:
            await update.message.reply_text("질문은 2000자 이내로 입력해주세요.")
            return
        chat_id = update.effective_chat.id
        waiting = await update.message.reply_text(
            "🧭 누적 인사이트 + 시장 데이터 결합 중…"
        )
        try:
            payload = await self._call_insight_agent(
                question=user_question,
                user_id=update.effective_user.id,
                chat_id=chat_id,
                previous_insight_id=ic.last_insight_id,
            )
            try:
                await waiting.delete()
            except Exception:
                pass
            if not payload:
                await update.message.reply_text(
                    "⚠️ 인사이트 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."
                )
                return
            quota_line = (
                f"\n\n📊 오늘 남은 /insight: {payload.remaining_quota}회"
                if payload.remaining_quota >= 0 else ""
            )
            text = f"🧭 {payload.answer}{quota_line}"
            kb = self._insight_feedback_keyboard(payload.insight_id)
            if len(text) > 4096:
                chunks = [text[i:i + 4096] for i in range(0, len(text), 4096)]
                last_sent = None
                for idx, ch in enumerate(chunks):
                    is_last = (idx == len(chunks) - 1)
                    last_sent = await update.message.reply_text(
                        ch, reply_markup=(kb if is_last else None),
                    )
                sent = last_sent
            else:
                sent = await update.message.reply_text(text, reply_markup=kb)
            ic.add_turn(user_question, payload.answer, payload.insight_id)
            if sent is not None:
                self.insight_contexts[sent.message_id] = ic
        except Exception as e:
            logger.error(f"_handle_insight_reply error: {e}", exc_info=True)
            try:
                await waiting.delete()
            except Exception:
                pass
            await update.message.reply_text(
                f"⚠️ 오류: {type(e).__name__}"
            )

    async def _call_archive_query(self, question: str, market: Optional[str] = None) -> Optional[str]:
        """
        Call archive query engine.
        - If ARCHIVE_API_URL is set: call pipeline server HTTP API (two-server setup)
        - Otherwise: call query_engine directly (single-server setup)
        """
        api_url = os.getenv("ARCHIVE_API_URL", "").rstrip("/")
        api_key = os.getenv("ARCHIVE_API_KEY", "")

        if api_url:
            # Two-server mode: call pipeline server API
            import aiohttp
            headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
            payload = {"question": question, "market": market, "model": "gpt-5.4-mini"}
            async with aiohttp.ClientSession() as session:
                async with session.post(
                    f"{api_url}/query",
                    json=payload,
                    headers=headers,
                    timeout=aiohttp.ClientTimeout(total=30),
                ) as resp:
                    if resp.status != 200:
                        logger.error(f"Archive API returned {resp.status}: {await resp.text()}")
                        return None
                    data = await resp.json()
                    return data.get("answer")
        else:
            # Single-server mode: call query_engine directly
            try:
                from cores.archive.query_engine import ask  # type: ignore[import]
                result = await ask(question, market=market, model="gpt-5.4-mini")
                return result.answer if result else None
            except Exception as e:
                logger.error(f"Local archive query failed: {e}", exc_info=True)
                return None

    # ==========================================================================
    # Firecrawl follow-up reply handler
    # ==========================================================================

    async def _handle_firecrawl_reply(self, update: Update, fc_ctx: FirecrawlConversationContext):
        """Handle a reply to a Firecrawl bot message — continue via Anthropic Sonnet 5."""
        user_question = update.message.text.strip()
        chat_id = update.effective_chat.id

        waiting_msg = await update.message.reply_text("💭 분석 중입니다... 잠시만 기다려주세요.")

        try:
            fc_ctx.add_to_history("user", user_question)
            conversation_context = fc_ctx.get_context_summary()

            response = await generate_firecrawl_followup_response(
                command=fc_ctx.command,
                query=fc_ctx.query,
                conversation_context=conversation_context,
                user_question=user_question,
            )

            await waiting_msg.delete()

            if not response:
                await update.message.reply_text(
                    "⚠️ 추가 질문 처리 중 오류가 발생했습니다. 다시 시도해주세요."
                )
                fc_ctx.conversation_history.pop()  # rollback the user turn
                return

            sent_msg = await update.message.reply_text(
                response + "\n\n💬 이 메시지에 답장(Reply)하여 추가 질문할 수 있습니다!"
            )
            fc_ctx.add_to_history("assistant", response)

            # Register new message ID so the next reply still works
            self.firecrawl_contexts[sent_msg.message_id] = fc_ctx

        except Exception as e:
            logger.error(f"_handle_firecrawl_reply error: {e}", exc_info=True)
            try:
                await waiting_msg.delete()
            except Exception:
                pass
            await update.message.reply_text(
                "⚠️ 추가 질문 처리 중 오류가 발생했습니다. 다시 시도해주세요."
            )

    async def process_results(self):
        """Check items to process from result queue"""
        logger.info("Result processing task started")
        while not self.stop_event.is_set():
            try:
                # Process if queue is not empty
                if not self.result_queue.empty():
                    # Process only one request at a time without internal loop
                    request_id = self.result_queue.get()
                    logger.info(f"Retrieved item from result queue: {request_id}")

                    if request_id in self.pending_requests:
                        request = self.pending_requests[request_id]
                        # Send result (safe because running in main event loop)
                        await self.send_report_result(request)
                        logger.info(f"Result sent successfully: {request.id} ({request.company_name})")
                    else:
                        logger.warning(f"Request ID not in pending_requests: {request_id}")

                    # Mark queue task as complete
                    self.result_queue.task_done()
                
                # Wait briefly (reduce CPU usage)
                await asyncio.sleep(0.5)
                
            except Exception as e:
                logger.error(f"Error processing result: {str(e)}")
                logger.error(traceback.format_exc())

            # Wait briefly
            await asyncio.sleep(1)

    async def run(self):
        """Run bot"""
        # Initialize global MCP App
        try:
            logger.info("Initializing global MCPApp...")
            await get_or_create_global_mcp_app()
            logger.info("Global MCPApp initialization complete")
        except Exception as e:
            logger.error(f"Global MCPApp initialization failed: {e}")
            # Start bot even if initialization fails (can retry later)
        
        # Run bot
        await self.application.initialize()
        # Sync slash-command menu with BotFather (1x on startup)
        try:
            await self._register_bot_commands()
        except Exception as e:
            logger.warning(f"_register_bot_commands failed on startup: {e}")
        await self.application.start()
        await self.application.updater.start_polling()

        # Add task for result processing
        asyncio.create_task(self.process_results())

        logger.info("Telegram AI conversational bot has started.")

        try:
            # Keep running until bot is stopped
            # Simple way to wait indefinitely
            await self.stop_event.wait()
        except asyncio.CancelledError:
            pass
        finally:
            # Clean up resources on exit
            logger.info("Bot shutdown started - cleaning up resources...")
            
            # Clean up global MCP App
            try:
                logger.info("Cleaning up global MCPApp...")
                await cleanup_global_mcp_app()
                logger.info("Global MCPApp cleanup complete")
            except Exception as e:
                logger.error(f"Global MCPApp cleanup failed: {e}")
            
            # Stop bot
            await self.application.stop()
            await self.application.shutdown()

            logger.info("Telegram AI conversational bot has stopped.")

async def shutdown(sig, loop):
    """Cleanup tasks tied to the service's shutdown."""
    logger.info(f"Received signal {sig.name}, shutting down...")
    tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]

    for task in tasks:
        task.cancel()

    logger.info(f"Cancelling {len(tasks)} outstanding tasks")
    await asyncio.gather(*tasks, return_exceptions=True)
    loop.stop()

# Main execution section
async def main():
    """
    Main function
    """
    # Set up signal handler
    loop = asyncio.get_event_loop()
    signals = (signal.SIGINT, signal.SIGTERM)

    def create_signal_handler(sig):
        return lambda: asyncio.create_task(shutdown(sig, loop))

    for s in signals:
        loop.add_signal_handler(s, create_signal_handler(s))

    bot = TelegramAIBot()
    await bot.run()

if __name__ == "__main__":
    asyncio.run(main())