import hashlib
import json
import threading
import time
from datetime import datetime, UTC
from pathlib import Path

from loguru import logger

from ...exceptions import DuplicateResearchError, ResearchTerminatedException
from ...config.llm_config import get_llm
from ...settings.manager import SnapshotSettingsContext

# Output directory for research results
from ...config.paths import get_research_outputs_directory
from ...config.search_config import get_search
from ...constants import ResearchStatus
from ...database.models import ResearchHistory, ResearchStrategy
from ...database.session_context import get_user_db_session
from ...database.thread_local_session import thread_cleanup
from ...error_handling.openai_compat_errors import (
    friendly_openai_compatible_error,
    is_openai_compat_runtime_error,
)
from ...error_handling.report_generator import ErrorReportGenerator
from ...utilities.thread_context import set_search_context
from ...report_generator import IntegratedReportGenerator
from ...search_system import AdvancedSearchSystem
from ...text_optimization import CitationFormatter, CitationMode
from ...utilities.log_utils import log_for_research
from ...utilities.search_utilities import extract_links_from_search_results
from ...utilities.threading_utils import thread_context, thread_with_app_context
from ..models.database import calculate_duration
from ...settings.env_registry import get_env_setting
from .socket_service import SocketIOService

OUTPUT_DIR = get_research_outputs_directory()


# Global concurrent research limit (server-wide, across all users)
_MAX_GLOBAL_CONCURRENT = get_env_setting(
    "server.max_concurrent_research", default=10
)
_global_research_semaphore = threading.Semaphore(_MAX_GLOBAL_CONCURRENT)

# Socket.IO emission throttling: minimum interval between progress emissions per research
_EMIT_THROTTLE_SECONDS = 0.2  # 200ms
_EMIT_TTL_SECONDS = 3600  # 1 hour — evict stale entries from orphaned research
_emit_cleanup_counter = 0
_last_emit_times: dict[str, float] = {}
_last_emit_lock = threading.Lock()


def _parse_research_metadata(research_meta) -> dict:
    """Parse research_meta into a dict, handling both dict and JSON string types."""
    if isinstance(research_meta, dict):
        return dict(research_meta)
    if isinstance(research_meta, str):
        try:
            parsed = json.loads(research_meta)
            return dict(parsed) if isinstance(parsed, dict) else {}
        except json.JSONDecodeError:
            logger.exception("Failed to parse research_meta as JSON")
            return {}
    return {}


def get_citation_formatter():
    """Get citation formatter with settings from thread context."""
    # Import here to avoid circular imports
    from ...config.search_config import get_setting_from_snapshot

    citation_format = get_setting_from_snapshot(
        "report.citation_format", "number_hyperlinks"
    )
    mode_map = {
        "number_hyperlinks": CitationMode.NUMBER_HYPERLINKS,
        "domain_hyperlinks": CitationMode.DOMAIN_HYPERLINKS,
        "domain_id_hyperlinks": CitationMode.DOMAIN_ID_HYPERLINKS,
        "domain_id_always_hyperlinks": CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS,
        "source_tagged_hyperlinks": CitationMode.SOURCE_TAGGED_HYPERLINKS,
        "no_hyperlinks": CitationMode.NO_HYPERLINKS,
    }
    mode = mode_map.get(citation_format, CitationMode.NUMBER_HYPERLINKS)
    return CitationFormatter(mode=mode)


def export_report_to_memory(
    markdown_content: str, format: str, title: str | None = None
):
    """
    Export a markdown report to different formats in memory.

    Uses the modular exporter registry to support multiple formats.
    Available formats can be queried with ExporterRegistry.get_available_formats().

    Args:
        markdown_content: The markdown content to export
        format: Export format (e.g., 'pdf', 'odt', 'latex', 'quarto', 'ris')
        title: Optional title for the document

    Returns:
        Tuple of (content_bytes, filename, mimetype)
    """
    from ...exporters import ExporterRegistry, ExportOptions

    # Normalize format
    format_lower = format.lower()

    # Get exporter from registry
    exporter = ExporterRegistry.get_exporter(format_lower)

    if exporter is None:
        available = ExporterRegistry.get_available_formats()
        raise ValueError(
            f"Unsupported export format: {format}. "
            f"Available formats: {', '.join(available)}"
        )

    # Title prepending is now handled by each exporter via _prepend_title_if_needed()
    # PDF and ODT exporters prepend titles; RIS and other formats ignore them

    # Create options
    options = ExportOptions(title=title)

    # Export
    result = exporter.export(markdown_content, options)

    logger.info(
        f"Generated {format_lower} in memory, size: {len(result.content)} bytes"
    )

    return result.content, result.filename, result.mimetype


def save_research_strategy(research_id, strategy_name, username=None):
    """
    Save the strategy used for a research to the database.

    Args:
        research_id: The ID of the research
        strategy_name: The name of the strategy used
        username: The username for database access (required for thread context)
    """
    try:
        logger.debug(
            f"save_research_strategy called with research_id={research_id}, strategy_name={strategy_name}"
        )
        with get_user_db_session(username) as session:
            # Check if a strategy already exists for this research
            existing_strategy = (
                session.query(ResearchStrategy)
                .filter_by(research_id=research_id)
                .first()
            )

            if existing_strategy:
                # Update existing strategy
                existing_strategy.strategy_name = strategy_name
                logger.debug(
                    f"Updating existing strategy for research {research_id}"
                )
            else:
                # Create new strategy record
                new_strategy = ResearchStrategy(
                    research_id=research_id, strategy_name=strategy_name
                )
                session.add(new_strategy)
                logger.debug(
                    f"Creating new strategy record for research {research_id}"
                )

            session.commit()
            logger.info(
                f"Saved strategy '{strategy_name}' for research {research_id}"
            )
    except Exception:
        logger.exception("Error saving research strategy")


def get_research_strategy(research_id, username=None):
    """
    Get the strategy used for a research.

    Args:
        research_id: The ID of the research
        username: The username for database access (required for thread context)

    Returns:
        str: The strategy name or None if not found
    """
    try:
        with get_user_db_session(username) as session:
            strategy = (
                session.query(ResearchStrategy)
                .filter_by(research_id=research_id)
                .first()
            )

            return strategy.strategy_name if strategy else None
    except Exception:
        logger.exception("Error getting research strategy")
        return None


def start_research_process(
    research_id,
    query,
    mode,
    run_research_callback,
    **kwargs,
):
    """
    Start a research process in a background thread.

    Args:
        research_id: The ID of the research
        query: The research query
        mode: The research mode (quick/detailed)
        run_research_callback: The callback function to run the research
        **kwargs: Additional parameters to pass to the research process (model, search_engine, etc.)

    Returns:
        threading.Thread: The thread running the research
    """
    from ..routes.globals import check_and_start_research

    # Pass the app context to the thread.
    run_research_callback = thread_with_app_context(run_research_callback)

    # Wrap callback with global concurrency limiter
    original_callback = run_research_callback

    def _rate_limited_callback(*args, **kw):
        _global_research_semaphore.acquire()
        try:
            return original_callback(*args, **kw)
        finally:
            _global_research_semaphore.release()

    # Prepare (but do not start) the background thread.
    thread = threading.Thread(
        target=_rate_limited_callback,
        args=(
            thread_context(),
            research_id,
            query,
            mode,
        ),
        kwargs=kwargs,
    )
    thread.daemon = True

    # Atomic check-and-start: refuses to spawn a second live thread
    # for the same research_id. Guards against the double-spawn window
    # where a post-spawn commit failure in the queue processor could
    # otherwise cause the retry loop to dispatch the same research twice.
    started = check_and_start_research(
        research_id,
        {
            "thread": thread,
            "progress": 0,
            "status": ResearchStatus.IN_PROGRESS,
            "log": [],
            "settings": kwargs,
        },
    )
    if not started:
        raise DuplicateResearchError(
            f"Research {research_id} already has a live thread"
        )

    return thread


def _generate_report_path(query: str) -> Path:
    """
    Generates a path for a new report file based on the query.

    Args:
        query: The query used for the report.

    Returns:
        The path that it generated.

    """
    # Generate a unique filename that does not contain
    # non-alphanumeric characters.
    query_hash = hashlib.md5(  # DevSkim: ignore DS126858
        query.encode("utf-8"), usedforsecurity=False
    ).hexdigest()[:10]
    return OUTPUT_DIR / (
        f"research_report_{query_hash}_{int(datetime.now(UTC).timestamp())}.md"
    )


@log_for_research
@thread_cleanup
def run_research_process(research_id, query, mode, **kwargs):
    """
    Run the research process in the background for a given research ID.

    Args:
        research_id: The ID of the research
        query: The research query
        mode: The research mode (quick/detailed)
        **kwargs: Additional parameters for the research (model_provider, model, search_engine, etc.)
                 MUST include 'username' for database access
    """
    from ..routes.globals import (
        is_research_active,
        is_termination_requested,
        update_progress_and_check_active,
    )

    # Extract username - required for database access
    username = kwargs.get("username")
    if not username:
        logger.error("No username provided to research thread")
        raise ValueError("Username is required for research process")
    # Extract user_password early so it's available for all cleanup paths
    user_password = kwargs.get("user_password")

    # Establish thread context FIRST so every subsequent log line in this
    # thread can be attributed to the correct user/research and persisted
    # to the user's encrypted ResearchLog. Otherwise the early INFO logs
    # below ("Research thread started", "Research strategy", "Research
    # parameters") fire before start_research_process gets to its own
    # set_search_context call (~line 417) and the daemon can't open the
    # encrypted DB to write them — silently dropped via the bare-except.
    set_search_context(
        {
            "research_id": research_id,
            "username": username,
            "user_password": user_password,
        }
    )

    logger.info(f"Research thread started with username: {username}")

    try:
        # Check if this research has been terminated before we even start
        if is_termination_requested(research_id):
            logger.info(
                f"Research {research_id} was terminated before starting"
            )
            cleanup_research_resources(
                research_id, username, user_password=user_password
            )
            return

        logger.info(
            f"Starting research process for ID {research_id}, query: {query}"
        )

        # Extract key parameters
        model_provider = kwargs.get("model_provider")
        model = kwargs.get("model")
        custom_endpoint = kwargs.get("custom_endpoint")
        search_engine = kwargs.get("search_engine")
        max_results = kwargs.get("max_results")
        time_period = kwargs.get("time_period")
        iterations = kwargs.get("iterations")
        questions_per_iteration = kwargs.get("questions_per_iteration")
        strategy = kwargs.get(
            "strategy", "source-based"
        )  # Default to source-based
        settings_snapshot = kwargs.get(
            "settings_snapshot", {}
        )  # Complete settings snapshot

        # Log settings snapshot to debug
        from ...settings.logger import log_settings

        log_settings(settings_snapshot, "Settings snapshot received in thread")

        # Strategy should already be saved in the database before thread starts
        logger.info(f"Research strategy: {strategy}")

        # Log all parameters for debugging
        logger.info(
            f"Research parameters: provider={model_provider}, model={model}, "
            f"search_engine={search_engine}, max_results={max_results}, "
            f"time_period={time_period}, iterations={iterations}, "
            f"questions_per_iteration={questions_per_iteration}, "
            f"custom_endpoint={custom_endpoint}, strategy={strategy}"
        )

        # Set up the AI Context Manager
        output_dir = OUTPUT_DIR / f"research_{research_id}"
        output_dir.mkdir(parents=True, exist_ok=True)

        # Create a settings context that uses snapshot - no database access in threads
        settings_context = SnapshotSettingsContext(
            settings_snapshot, username=username
        )

        # Only log settings if explicitly enabled via LDR_LOG_SETTINGS env var
        from ...settings.logger import log_settings

        log_settings(
            settings_context.values, "SettingsContext values extracted"
        )

        # Set the settings context for this thread
        from ...config.thread_settings import (
            set_settings_context,
        )

        set_settings_context(settings_context)

        # user_password already extracted above (before termination check)

        # Create shared research context that can be updated during research
        shared_research_context = {
            "research_id": research_id,
            "research_query": query,
            "research_mode": mode,
            "research_phase": "init",
            "search_iteration": 0,
            "search_engines_planned": None,
            "search_engine_selected": search_engine,
            "username": username,  # Add username for queue operations
            "user_password": user_password,  # Add password for metrics access
        }

        # If this is a follow-up research, include the parent context
        if "research_context" in kwargs and kwargs["research_context"]:
            logger.info(
                f"Adding parent research context with {len(kwargs['research_context'].get('past_findings', ''))} chars of findings"
            )
            shared_research_context.update(kwargs["research_context"])

        # Do not log context keys as they may contain sensitive information
        logger.info(f"Created shared_research_context for user: {username}")

        # Set search context for search tracking
        set_search_context(shared_research_context)

        # Set up progress callback
        def progress_callback(message, progress_percent, metadata):
            # Frequent termination check
            if is_termination_requested(research_id):
                handle_termination(research_id, username)
                raise ResearchTerminatedException(  # noqa: TRY301 — inside nested callback, not caught by enclosing try
                    "Research was terminated by user"
                )

            # Silent phase — no UI logging or socket emission needed
            if metadata.get("phase") == "termination_check":
                return

            # Bind research_id AND username so the database_sink + queue
            # daemon can resolve the per-user encrypted DB. Without username
            # the daemon's _write_log_to_database hits "No authenticated
            # user", silently swallows the error, and ResearchLog ends up
            # with zero milestone rows — leaving /api/research/<id>/status
            # without a log_entry to render and the frontend stuck on the
            # "Performing research..." fallback.
            bound_logger = logger.bind(
                research_id=research_id, username=username
            )
            bound_logger.log("MILESTONE", message)

            if "SEARCH_PLAN:" in message:
                engines = message.split("SEARCH_PLAN:")[1].strip()
                metadata["planned_engines"] = engines
                metadata["phase"] = "search_planning"  # Use existing phase
                # Update shared context for token tracking
                shared_research_context["search_engines_planned"] = engines
                shared_research_context["research_phase"] = "search_planning"

            if "ENGINE_SELECTED:" in message:
                engine = message.split("ENGINE_SELECTED:")[1].strip()
                metadata["selected_engine"] = engine
                metadata["phase"] = "search"  # Use existing 'search' phase
                # Update shared context for token tracking
                shared_research_context["search_engine_selected"] = engine
                shared_research_context["research_phase"] = "search"

            # Capture other research phases for better context tracking
            if metadata.get("phase"):
                shared_research_context["research_phase"] = metadata["phase"]

            # Update search iteration if available
            if "iteration" in metadata:
                shared_research_context["search_iteration"] = metadata[
                    "iteration"
                ]

            # Adjust progress based on research mode
            adjusted_progress = progress_percent
            if (
                mode == "detailed"
                and metadata.get("phase") == "output_generation"
            ):
                # For detailed mode, adjust the progress range for output generation
                adjusted_progress = min(80, progress_percent)
            elif (
                mode == "detailed"
                and metadata.get("phase") == "report_generation"
            ):
                # Scale the progress from 80% to 95% for the report generation phase
                if progress_percent is not None:
                    normalized = progress_percent / 100
                    adjusted_progress = 80 + (normalized * 15)
            elif (
                mode == "quick" and metadata.get("phase") == "output_generation"
            ):
                # For quick mode, ensure we're at least at 85% during output generation
                adjusted_progress = max(85, progress_percent)
                # Map any further progress within output_generation to 85-95% range
                if progress_percent is not None and progress_percent > 0:
                    normalized = progress_percent / 100
                    adjusted_progress = 85 + (normalized * 10)

            # Atomically update progress and check if research is still active
            if adjusted_progress is not None:
                adjusted_progress, still_active = (
                    update_progress_and_check_active(
                        research_id, adjusted_progress
                    )
                )
            else:
                still_active = is_research_active(research_id)

            if still_active:
                # Queue the progress update to be processed in main thread
                if adjusted_progress is not None:
                    from ..queue.processor_v2 import queue_processor

                    if username:
                        queue_processor.queue_progress_update(
                            username, research_id, adjusted_progress
                        )
                    else:
                        logger.warning(
                            f"Cannot queue progress update for research {research_id} - no username available"
                        )

                # Emit a socket event (throttled to avoid event storms)
                try:
                    # Always emit completion/error states immediately;
                    # throttle intermediate progress updates
                    phase = metadata.get("phase", "")
                    is_final = (
                        phase
                        in (
                            "complete",
                            "error",
                            "report_complete",
                        )
                        or adjusted_progress == 100
                    )

                    should_emit = is_final
                    if not is_final:
                        now = time.monotonic()
                        with _last_emit_lock:
                            last = _last_emit_times.get(research_id, 0)
                            if now - last >= _EMIT_THROTTLE_SECONDS:
                                _last_emit_times[research_id] = now
                                should_emit = True
                            # Periodic TTL cleanup for orphaned entries
                            global _emit_cleanup_counter  # noqa: PLW0603
                            _emit_cleanup_counter += 1
                            if _emit_cleanup_counter % 100 == 0:
                                stale = [
                                    rid
                                    for rid, t in _last_emit_times.items()
                                    if now - t > _EMIT_TTL_SECONDS
                                ]
                                for rid in stale:
                                    del _last_emit_times[rid]

                    if should_emit:
                        # Basic event data - include message for display
                        event_data = {
                            "progress": adjusted_progress,
                            "message": message,
                            "phase": phase,
                        }

                        # Include additional metadata for MCP/ReAct strategy display
                        if metadata.get("thought"):
                            event_data["thought"] = metadata["thought"]
                        if metadata.get("tool"):
                            event_data["tool"] = metadata["tool"]
                        if metadata.get("arguments"):
                            event_data["arguments"] = metadata["arguments"]
                        if metadata.get("iteration"):
                            event_data["iteration"] = metadata["iteration"]
                        if metadata.get("error"):
                            event_data["error"] = metadata["error"]
                        if metadata.get("content"):
                            event_data["content"] = metadata["content"]

                        SocketIOService().emit_to_subscribers(
                            "progress", research_id, event_data
                        )
                except Exception:
                    logger.exception("Socket emit error (non-critical)")

        # Function to check termination during long-running operations
        def check_termination():
            if is_termination_requested(research_id):
                handle_termination(research_id, username)
                raise ResearchTerminatedException(  # noqa: TRY301 — inside nested callback, not caught by enclosing try
                    "Research was terminated by user during long-running operation"
                )
            return False  # Not terminated

        # Configure the system with the specified parameters
        use_llm = None
        if model or search_engine or model_provider:
            # Log that we're overriding system settings
            logger.info(
                f"Overriding system settings with: provider={model_provider}, model={model}, search_engine={search_engine}"
            )

        # Override LLM if model or model_provider specified
        if model or model_provider:
            try:
                # Get LLM with the overridden settings
                # Use the shared_research_context which includes username
                use_llm = get_llm(
                    model_name=model,
                    provider=model_provider,
                    openai_endpoint_url=custom_endpoint,
                    research_id=research_id,
                    research_context=shared_research_context,
                )

                logger.info(
                    f"Successfully set LLM to: provider={model_provider}, model={model}"
                )
            except Exception as e:
                logger.exception(
                    f"Error setting LLM provider={model_provider}, model={model}"
                )
                error_msg = str(e)
                # Surface configuration errors to user instead of silently continuing
                config_error_keywords = [
                    "model path",
                    "llamacpp",
                    "cannot connect",
                    "server",
                    "not configured",
                    "not responding",
                    "directory",
                    ".gguf",
                ]
                if any(
                    keyword in error_msg.lower()
                    for keyword in config_error_keywords
                ):
                    # This is a configuration error the user can fix
                    raise ValueError(
                        f"LLM Configuration Error: {error_msg}"
                    ) from e
                # For other errors, re-raise to avoid silent failures
                raise

        # Create search engine first if specified, to avoid default creation without username
        use_search = None
        if search_engine:
            try:
                # Create a new search object with these settings
                use_search = get_search(
                    search_tool=search_engine,
                    llm_instance=use_llm,
                    username=username,
                    settings_snapshot=settings_snapshot,
                )
                logger.info(
                    f"Successfully created search engine: {search_engine}"
                )
            except Exception as e:
                logger.exception(
                    f"Error creating search engine {search_engine}"
                )
                error_msg = str(e)
                # Surface configuration errors to user instead of silently continuing
                config_error_keywords = [
                    "searxng",
                    "instance_url",
                    "api_key",
                    "cannot connect",
                    "connection",
                    "timeout",
                    "not configured",
                ]
                if any(
                    keyword in error_msg.lower()
                    for keyword in config_error_keywords
                ):
                    # This is a configuration error the user can fix
                    raise ValueError(
                        f"Search Engine Configuration Error ({search_engine}): {error_msg}"
                    ) from e
                # For other errors, re-raise to avoid silent failures
                raise

        # Set the progress callback in the system
        system = AdvancedSearchSystem(
            llm=use_llm,  # type: ignore[arg-type]
            search=use_search,  # type: ignore[arg-type]
            strategy_name=strategy,
            max_iterations=iterations,
            questions_per_iteration=questions_per_iteration,
            username=username,
            settings_snapshot=settings_snapshot,
            research_id=research_id,
            research_context=shared_research_context,
        )
        system.set_progress_callback(progress_callback)

        # Run the search
        progress_callback("Starting research process", 5, {"phase": "init"})

        try:
            results = system.analyze_topic(query)
            if mode == "quick":
                progress_callback(
                    "Search complete, preparing to generate summary...",
                    85,
                    {"phase": "output_generation"},
                )
            else:
                progress_callback(
                    "Search complete, generating output",
                    80,
                    {"phase": "output_generation"},
                )
        except Exception as search_error:
            # Better handling of specific search errors
            error_message = str(search_error)
            error_type = "unknown"

            # OpenAI-compatible runtime failures (LM Studio / vLLM / llama.cpp
            # server / OpenRouter / custom endpoint) -- rewrite to a message
            # that names the provider, base URL, and model (#3878).
            if model_provider in {
                "openai_endpoint",
                "lmstudio",
                "llamacpp",
                "openai",
                "openrouter",
            } and is_openai_compat_runtime_error(search_error):
                rewritten = friendly_openai_compatible_error(
                    search_error,
                    provider=model_provider,
                    base_url=custom_endpoint,
                    model=model,
                )
                raise RuntimeError(rewritten) from search_error

            # Extract error details for common issues
            if "status code: 503" in error_message:
                error_message = "Ollama AI service is unavailable (HTTP 503). Please check that Ollama is running properly on your system."
                error_type = "ollama_unavailable"
            elif "status code: 404" in error_message:
                error_message = "Ollama model not found (HTTP 404). Please check that you have pulled the required model."
                error_type = "model_not_found"
            elif "status code:" in error_message:
                # Extract the status code for other HTTP errors
                status_code = error_message.split("status code:")[1].strip()
                error_message = f"API request failed with status code {status_code}. Please check your configuration."
                error_type = "api_error"
            elif "connection" in error_message.lower():
                error_message = "Connection error. Please check that your LLM service (Ollama/API) is running and accessible."
                error_type = "connection_error"

            # Raise with improved error message
            raise RuntimeError(
                f"{error_message} (Error type: {error_type})"
            ) from search_error

        # Generate output based on mode
        if mode == "quick":
            # Quick Summary
            if results.get("findings") or results.get("formatted_findings"):
                raw_formatted_findings = results["formatted_findings"]

                # Check if formatted_findings contains an error message
                if isinstance(
                    raw_formatted_findings, str
                ) and raw_formatted_findings.startswith("Error:"):
                    logger.exception(
                        f"Detected error in formatted findings: {raw_formatted_findings[:100]}..."
                    )

                    # Determine error type for better user feedback
                    error_type = "unknown"
                    error_message = raw_formatted_findings.lower()

                    if (
                        "token limit" in error_message
                        or "context length" in error_message
                    ):
                        error_type = "token_limit"
                        # Log specific error type
                        logger.warning(
                            "Detected token limit error in synthesis"
                        )

                        # Update progress with specific error type
                        progress_callback(
                            "Synthesis hit token limits. Attempting fallback...",
                            87,
                            {
                                "phase": "synthesis_error",
                                "error_type": error_type,
                            },
                        )
                    elif (
                        "timeout" in error_message
                        or "timed out" in error_message
                    ):
                        error_type = "timeout"
                        logger.warning("Detected timeout error in synthesis")
                        progress_callback(
                            "Synthesis timed out. Attempting fallback...",
                            87,
                            {
                                "phase": "synthesis_error",
                                "error_type": error_type,
                            },
                        )
                    elif "rate limit" in error_message:
                        error_type = "rate_limit"
                        logger.warning("Detected rate limit error in synthesis")
                        progress_callback(
                            "LLM rate limit reached. Attempting fallback...",
                            87,
                            {
                                "phase": "synthesis_error",
                                "error_type": error_type,
                            },
                        )
                    elif (
                        "connection" in error_message
                        or "network" in error_message
                    ):
                        error_type = "connection"
                        logger.warning("Detected connection error in synthesis")
                        progress_callback(
                            "Connection issue with LLM. Attempting fallback...",
                            87,
                            {
                                "phase": "synthesis_error",
                                "error_type": error_type,
                            },
                        )
                    elif (
                        "llm error" in error_message
                        or "final answer synthesis fail" in error_message
                    ):
                        error_type = "llm_error"
                        logger.warning(
                            "Detected general LLM error in synthesis"
                        )
                        progress_callback(
                            "LLM error during synthesis. Attempting fallback...",
                            87,
                            {
                                "phase": "synthesis_error",
                                "error_type": error_type,
                            },
                        )
                    else:
                        # Generic error
                        logger.warning("Detected unknown error in synthesis")
                        progress_callback(
                            "Error during synthesis. Attempting fallback...",
                            87,
                            {
                                "phase": "synthesis_error",
                                "error_type": "unknown",
                            },
                        )

                    # Extract synthesized content from findings if available
                    synthesized_content = ""
                    for finding in results.get("findings", []):
                        if finding.get("phase") == "Final synthesis":
                            synthesized_content = finding.get("content", "")
                            break

                    # Use synthesized content as fallback
                    if (
                        synthesized_content
                        and not synthesized_content.startswith("Error:")
                    ):
                        logger.info(
                            "Using existing synthesized content as fallback"
                        )
                        raw_formatted_findings = synthesized_content

                    # Or use current_knowledge as another fallback
                    elif results.get("current_knowledge"):
                        logger.info("Using current_knowledge as fallback")
                        raw_formatted_findings = results["current_knowledge"]

                    # Or combine all finding contents as last resort
                    elif results.get("findings"):
                        logger.info("Combining all findings as fallback")
                        # First try to use any findings that are not errors
                        valid_findings = [
                            f"## {finding.get('phase', 'Finding')}\n\n{finding.get('content', '')}"
                            for finding in results.get("findings", [])
                            if finding.get("content")
                            and not finding.get("content", "").startswith(
                                "Error:"
                            )
                        ]

                        if valid_findings:
                            raw_formatted_findings = (
                                "# Research Results (Fallback Mode)\n\n"
                            )
                            raw_formatted_findings += "\n\n".join(
                                valid_findings
                            )
                            raw_formatted_findings += f"\n\n## Error Information\n{raw_formatted_findings}"
                        else:
                            # Last resort: use everything including errors
                            raw_formatted_findings = (
                                "# Research Results (Emergency Fallback)\n\n"
                            )
                            raw_formatted_findings += "The system encountered errors during final synthesis.\n\n"
                            raw_formatted_findings += "\n\n".join(
                                f"## {finding.get('phase', 'Finding')}\n\n{finding.get('content', '')}"
                                for finding in results.get("findings", [])
                                if finding.get("content")
                            )

                    progress_callback(
                        f"Using fallback synthesis due to {error_type} error",
                        88,
                        {
                            "phase": "synthesis_fallback",
                            "error_type": error_type,
                        },
                    )

                logger.info(
                    "Found formatted_findings of length: {}",
                    len(str(raw_formatted_findings)),
                )

                try:
                    # Check if we have an error in the findings and use enhanced error handling
                    if isinstance(
                        raw_formatted_findings, str
                    ) and raw_formatted_findings.startswith("Error:"):
                        logger.info(
                            "Generating enhanced error report using ErrorReportGenerator"
                        )

                        # Generate comprehensive error report
                        # ErrorReportGenerator does not use LLM (kept for compat)
                        error_generator = ErrorReportGenerator()
                        clean_markdown = error_generator.generate_error_report(
                            error_message=raw_formatted_findings,
                            query=query,
                            partial_results=results,
                            search_iterations=results.get("iterations", 0),
                            research_id=research_id,
                        )

                        logger.info(
                            "Generated enhanced error report with {} characters",
                            len(clean_markdown),
                        )
                    else:
                        # Get the synthesized content from the LLM directly
                        clean_markdown = raw_formatted_findings

                    # Extract all sources from findings to add them to the summary
                    all_links = []
                    for finding in results.get("findings", []):
                        search_results = finding.get("search_results", [])
                        if search_results:
                            try:
                                links = extract_links_from_search_results(
                                    search_results
                                )
                                all_links.extend(links)
                            except Exception:
                                logger.exception(
                                    "Error processing search results/links"
                                )

                    logger.info(
                        "Successfully converted to clean markdown of length: {}",
                        len(clean_markdown),
                    )

                    # First send a progress update for generating the summary
                    progress_callback(
                        "Generating clean summary from research data...",
                        90,
                        {"phase": "output_generation"},
                    )

                    # Send progress update for saving report
                    progress_callback(
                        "Saving research report to database...",
                        95,
                        {"phase": "report_complete"},
                    )

                    # Format citations in the markdown content
                    formatter = get_citation_formatter()
                    formatted_content = formatter.format_document(
                        clean_markdown
                    )

                    # Prepare complete report content
                    full_report_content = f"""{formatted_content}

## Research Metrics
- Search Iterations: {results["iterations"]}
- Generated at: {datetime.now(UTC).isoformat()}
"""

                    # Save sources to database (non-fatal - report should still
                    # be saved even if source saving fails)
                    try:
                        from .research_sources_service import (
                            ResearchSourcesService,
                        )

                        sources_service = ResearchSourcesService()
                        if all_links:
                            logger.info(
                                f"Quick summary: Saving {len(all_links)} sources to database"
                            )
                            sources_saved = (
                                sources_service.save_research_sources(
                                    research_id=research_id,
                                    sources=all_links,
                                    username=username,
                                )
                            )
                            logger.info(
                                f"Quick summary: Saved {sources_saved} sources for research {research_id}"
                            )
                    except Exception:
                        logger.exception(
                            f"Failed to save sources for research {research_id} (continuing with report save)"
                        )

                    # Save report using storage abstraction
                    from ...storage import get_report_storage

                    with get_user_db_session(username) as db_session:
                        storage = get_report_storage(session=db_session)

                        # Prepare metadata
                        metadata = {
                            "iterations": results["iterations"],
                            "generated_at": datetime.now(UTC).isoformat(),
                        }

                        # Save report using storage abstraction
                        success = storage.save_report(
                            research_id=research_id,
                            content=full_report_content,
                            metadata=metadata,
                            username=username,
                        )

                        if not success:
                            raise RuntimeError("Failed to save research report")  # noqa: TRY301 — triggers research failure handling in outer except

                        logger.info(
                            f"Report saved for research_id: {research_id}"
                        )

                    # Skip export to additional formats - we're storing in database only

                    # Update research status in database
                    completed_at = datetime.now(UTC).isoformat()

                    with get_user_db_session(username) as db_session:
                        research = (
                            db_session.query(ResearchHistory)
                            .filter_by(id=research_id)
                            .first()
                        )

                        # Preserve existing metadata and update with new values
                        metadata = _parse_research_metadata(
                            research.research_meta
                        )

                        metadata.update(
                            {
                                "iterations": results["iterations"],
                                "generated_at": datetime.now(UTC).isoformat(),
                            }
                        )

                        # Use the helper function for consistent duration calculation
                        duration_seconds = calculate_duration(
                            research.created_at, completed_at
                        )

                        research.status = ResearchStatus.COMPLETED
                        research.completed_at = completed_at
                        research.duration_seconds = duration_seconds
                        # Note: report_content is saved by CachedResearchService
                        # report_path is not used in encrypted database version

                        # Generate headline and topics only for news searches
                        if (
                            metadata.get("is_news_search")
                            or metadata.get("search_type") == "news_analysis"
                        ):
                            try:
                                from ...news.utils.headline_generator import (
                                    generate_headline,
                                )
                                from ...news.utils.topic_generator import (
                                    generate_topics,
                                )

                                # Get the report content from database for better headline/topic generation
                                report_content = ""
                                try:
                                    research = (
                                        db_session.query(ResearchHistory)
                                        .filter_by(id=research_id)
                                        .first()
                                    )
                                    if research and research.report_content:
                                        report_content = research.report_content
                                        logger.info(
                                            f"Retrieved {len(report_content)} chars from database for headline generation"
                                        )
                                    else:
                                        logger.warning(
                                            f"No report content found in database for research_id: {research_id}"
                                        )
                                except Exception:
                                    logger.warning(
                                        "Could not retrieve report content from database"
                                    )

                                # Generate headline
                                logger.info(
                                    f"Generating headline for query: {query[:100]}"
                                )
                                headline = generate_headline(
                                    query, report_content
                                )
                                metadata["generated_headline"] = headline

                                # Generate topics
                                logger.info(
                                    f"Generating topics with category: {metadata.get('category', 'News')}"
                                )
                                topics = generate_topics(
                                    query=query,
                                    findings=report_content,
                                    category=metadata.get("category", "News"),
                                    max_topics=6,
                                )
                                metadata["generated_topics"] = topics

                                logger.info(f"Generated headline: {headline}")
                                logger.info(f"Generated topics: {topics}")

                            except Exception:
                                logger.warning(
                                    "Could not generate headline/topics"
                                )

                        research.research_meta = metadata

                        db_session.commit()
                        logger.info(
                            f"Database commit completed for research_id: {research_id}"
                        )

                        # Update subscription if this was triggered by a subscription
                        if metadata.get("subscription_id"):
                            try:
                                from ...news.subscription_manager.storage import (
                                    SQLSubscriptionStorage,
                                )
                                from datetime import (
                                    datetime as dt,
                                    timezone,
                                    timedelta,
                                )

                                sub_storage = SQLSubscriptionStorage(db_session)
                                subscription_id = metadata["subscription_id"]

                                # Get subscription to find refresh interval
                                subscription = sub_storage.get(subscription_id)
                                if subscription:
                                    refresh_minutes = subscription.get(
                                        "refresh_minutes", 240
                                    )
                                    now = dt.now(timezone.utc)
                                    next_refresh = now + timedelta(
                                        minutes=refresh_minutes
                                    )

                                    # Update refresh times
                                    sub_storage.update_refresh_time(
                                        subscription_id=subscription_id,
                                        last_refresh=now,
                                        next_refresh=next_refresh,
                                    )

                                    # Increment stats
                                    sub_storage.increment_stats(
                                        subscription_id, 1
                                    )

                                    logger.info(
                                        f"Updated subscription {subscription_id} refresh times"
                                    )
                            except Exception:
                                logger.warning(
                                    "Could not update subscription refresh time"
                                )

                    logger.info(
                        f"Database updated successfully for research_id: {research_id}"
                    )

                    # Send the final completion message
                    progress_callback(
                        "Research completed successfully",
                        100,
                        {"phase": "complete"},
                    )

                    # Clean up resources
                    logger.info(
                        "Cleaning up resources for research_id: {}", research_id
                    )
                    cleanup_research_resources(
                        research_id, username, user_password=user_password
                    )
                    logger.info(
                        "Resources cleaned up for research_id: {}", research_id
                    )

                except Exception as inner_e:
                    logger.exception("Error during quick summary generation")
                    raise RuntimeError(
                        f"Error generating quick summary: {inner_e!s}"
                    )
            else:
                raise RuntimeError(  # noqa: TRY301 — triggers research failure handling in outer except
                    "No research findings were generated. Please try again."
                )
        else:
            # Full Report
            progress_callback(
                "Generating detailed report...",
                85,
                {"phase": "report_generation"},
            )

            # Extract the search system from the results if available
            search_system = results.get("search_system", None)

            # Wrapper that maps report generator's 0-100% to 85-95% range
            # and relays cancellation checks through the outer progress_callback
            def report_progress_callback(message, progress_percent, metadata):
                if progress_percent is not None:
                    adjusted = 85 + (progress_percent / 100) * 10
                else:
                    adjusted = progress_percent
                progress_callback(message, adjusted, metadata)

            # Pass the existing search system to maintain citation indices
            report_generator = IntegratedReportGenerator(
                search_system=search_system,
                settings_snapshot=settings_snapshot,
            )
            final_report = report_generator.generate_report(
                results, query, progress_callback=report_progress_callback
            )

            progress_callback(
                "Report generation complete", 95, {"phase": "report_complete"}
            )

            # Format citations in the report content
            formatter = get_citation_formatter()
            formatted_content = formatter.format_document(
                final_report["content"]
            )

            # Save sources to database (non-fatal - report should still be saved
            # even if source saving fails, e.g. due to expired session password)
            try:
                from .research_sources_service import ResearchSourcesService

                sources_service = ResearchSourcesService()
                all_links = getattr(search_system, "all_links_of_system", None)
                if all_links:
                    logger.info(f"Saving {len(all_links)} sources to database")
                    sources_saved = sources_service.save_research_sources(
                        research_id=research_id,
                        sources=all_links,
                        username=username,
                    )
                    logger.info(
                        f"Saved {sources_saved} sources for research {research_id}"
                    )
            except Exception:
                logger.exception(
                    f"Failed to save sources for research {research_id} (continuing with report save)"
                )

            # Save report to database
            with get_user_db_session(username) as db_session:
                # Update metadata
                metadata = final_report["metadata"]
                metadata["iterations"] = results["iterations"]

                # Save report to database
                try:
                    research = (
                        db_session.query(ResearchHistory)
                        .filter_by(id=research_id)
                        .first()
                    )

                    if not research:
                        logger.error(f"Research {research_id} not found")
                        success = False
                    else:
                        research.report_content = formatted_content
                        if research.research_meta:
                            research.research_meta.update(metadata)
                        else:
                            research.research_meta = metadata
                        db_session.commit()
                        success = True
                        logger.info(
                            f"Saved report for research {research_id} to database"
                        )
                except Exception:
                    logger.exception("Error saving report to database")
                    db_session.rollback()
                    success = False

                if not success:
                    raise RuntimeError("Failed to save research report")  # noqa: TRY301 — triggers research failure handling in outer except

                logger.info(
                    f"Report saved to database for research_id: {research_id}"
                )

            # Update research status in database
            completed_at = datetime.now(UTC).isoformat()

            with get_user_db_session(username) as db_session:
                research = (
                    db_session.query(ResearchHistory)
                    .filter_by(id=research_id)
                    .first()
                )

                # Preserve existing metadata and merge with report metadata
                metadata = _parse_research_metadata(research.research_meta)

                metadata.update(final_report["metadata"])
                metadata["iterations"] = results["iterations"]

                # Use the helper function for consistent duration calculation
                duration_seconds = calculate_duration(
                    research.created_at, completed_at
                )

                research.status = ResearchStatus.COMPLETED
                research.completed_at = completed_at
                research.duration_seconds = duration_seconds
                # Note: report_content is saved by CachedResearchService
                # report_path is not used in encrypted database version

                # Generate headline and topics only for news searches
                if (
                    metadata.get("is_news_search")
                    or metadata.get("search_type") == "news_analysis"
                ):
                    try:
                        from ..news.utils.headline_generator import (
                            generate_headline,  # type: ignore[no-redef]
                        )
                        from ..news.utils.topic_generator import (
                            generate_topics,  # type: ignore[no-redef]
                        )

                        # Get the report content from database for better headline/topic generation
                        report_content = ""
                        try:
                            research = (
                                db_session.query(ResearchHistory)
                                .filter_by(id=research_id)
                                .first()
                            )
                            if research and research.report_content:
                                report_content = research.report_content
                            else:
                                logger.warning(
                                    f"No report content found in database for research_id: {research_id}"
                                )
                        except Exception:
                            logger.warning(
                                "Could not retrieve report content from database"
                            )

                        # Generate headline
                        headline = generate_headline(query, report_content)
                        metadata["generated_headline"] = headline

                        # Generate topics
                        topics = generate_topics(
                            query=query,
                            findings=report_content,
                            category=metadata.get("category", "News"),
                            max_topics=6,
                        )
                        metadata["generated_topics"] = topics

                        logger.info(f"Generated headline: {headline}")
                        logger.info(f"Generated topics: {topics}")

                    except Exception:
                        logger.warning("Could not generate headline/topics")

                research.research_meta = metadata

                db_session.commit()

                # Update subscription if this was triggered by a subscription
                if metadata.get("subscription_id"):
                    try:
                        from ...news.subscription_manager.storage import (
                            SQLSubscriptionStorage,
                        )
                        from datetime import datetime as dt, timezone, timedelta

                        sub_storage = SQLSubscriptionStorage(db_session)
                        subscription_id = metadata["subscription_id"]

                        # Get subscription to find refresh interval
                        subscription = sub_storage.get(subscription_id)
                        if subscription:
                            refresh_minutes = subscription.get(
                                "refresh_minutes", 240
                            )
                            now = dt.now(timezone.utc)
                            next_refresh = now + timedelta(
                                minutes=refresh_minutes
                            )

                            # Update refresh times
                            sub_storage.update_refresh_time(
                                subscription_id=subscription_id,
                                last_refresh=now,
                                next_refresh=next_refresh,
                            )

                            # Increment stats
                            sub_storage.increment_stats(subscription_id, 1)

                            logger.info(
                                f"Updated subscription {subscription_id} refresh times"
                            )
                    except Exception:
                        logger.warning(
                            "Could not update subscription refresh time"
                        )

            progress_callback(
                "Research completed successfully",
                100,
                {"phase": "complete"},
            )

            # Clean up resources
            cleanup_research_resources(
                research_id, username, user_password=user_password
            )

    except ResearchTerminatedException:
        logger.info(f"Research {research_id} terminated by user")
        # handle_termination() was already called by progress_callback
        # before raising, which:
        #   1. Queued SUSPENDED status update via queue_processor
        #   2. Called cleanup_research_resources()
        # No additional cleanup needed here.

    except Exception as e:
        # Handle error
        error_message = f"Research failed: {e!s}"
        logger.exception(error_message)

        try:
            # Check for common Ollama error patterns in the exception and provide more user-friendly errors
            user_friendly_error = str(e)
            error_context = {}

            if "Error type: ollama_unavailable" in user_friendly_error:
                user_friendly_error = "Ollama AI service is unavailable. Please check that Ollama is running properly on your system."
                error_context = {
                    "solution": "Start Ollama with 'ollama serve' or check if it's installed correctly."
                }
            elif "Error type: model_not_found" in user_friendly_error:
                user_friendly_error = "Required Ollama model not found. Please pull the model first."
                error_context = {
                    "solution": "Run 'ollama pull mistral' to download the required model."
                }
            elif "Error type: connection_error" in user_friendly_error:
                user_friendly_error = "Connection error with LLM service. Please check that your AI service is running."
                error_context = {
                    "solution": "Ensure Ollama or your API service is running and accessible."
                }
            elif "Error type: api_error" in user_friendly_error:
                # Keep the original error message as it's already improved
                error_context = {
                    "solution": "Check API configuration and credentials."
                }
            # OpenAI-compatible runtime tokens (#3878). The friendly message
            # built by friendly_openai_compatible_error() already names the
            # provider, base URL, and model -- keep it as-is.
            elif "Error type: openai_connection_refused" in user_friendly_error:
                error_context = {
                    "solution": "Start your LLM server (LM Studio / vLLM / llama.cpp server) and verify the base URL in Settings -> LLM Providers."
                }
            elif "Error type: openai_timeout" in user_friendly_error:
                error_context = {
                    "solution": "The server is reachable but slow -- it may be loading a model. Retry, or increase the request timeout."
                }
            elif "Error type: openai_auth" in user_friendly_error:
                error_context = {
                    "solution": "Set or correct the API key for this provider in Settings -> LLM Providers. Local servers usually accept any non-empty key."
                }
            elif "Error type: openai_permission_denied" in user_friendly_error:
                error_context = {
                    "solution": "Your API key is valid but lacks access to this model. Pick a model your account/server is permitted to use."
                }
            elif "Error type: openai_model_not_found" in user_friendly_error:
                error_context = {
                    "solution": "The model id is not loaded on this server. Pick a currently-loaded model in the provider's UI/config."
                }
            elif "Error type: openai_bad_request" in user_friendly_error:
                error_context = {
                    "solution": "The server rejected the request. Check the model id and any provider-specific parameters."
                }
            elif "Error type: openai_unknown" in user_friendly_error:
                error_context = {
                    "solution": "Check the provider's logs for the full error and verify the base URL / model id."
                }

            # Generate enhanced error report for failed research
            enhanced_report_content = None
            try:
                # Get partial results if they exist
                partial_results = results if "results" in locals() else None
                search_iterations = (
                    results.get("iterations", 0) if partial_results else 0
                )

                # Generate comprehensive error report
                # ErrorReportGenerator does not use LLM (kept for compat)
                error_generator = ErrorReportGenerator()
                enhanced_report_content = error_generator.generate_error_report(
                    error_message=f"Research failed: {e!s}",
                    query=query,
                    partial_results=partial_results,
                    search_iterations=search_iterations,
                    research_id=research_id,
                )

                logger.info(
                    "Generated enhanced error report for failed research (length: {})",
                    len(enhanced_report_content),
                )

                # Save enhanced error report to encrypted database
                try:
                    # username already available from function scope (line 281)
                    if username:
                        from ...storage import get_report_storage

                        with get_user_db_session(username) as db_session:
                            storage = get_report_storage(session=db_session)
                            success = storage.save_report(
                                research_id=research_id,
                                content=enhanced_report_content,
                                metadata={"error_report": True},
                                username=username,
                            )
                            if success:
                                logger.info(
                                    "Saved enhanced error report to encrypted database for research {}",
                                    research_id,
                                )
                            else:
                                logger.warning(
                                    "Failed to save enhanced error report to database for research {}",
                                    research_id,
                                )
                    else:
                        logger.warning(
                            "Cannot save error report: username not available"
                        )

                except Exception as report_error:
                    logger.exception(
                        "Failed to save enhanced error report: {}", report_error
                    )

            except Exception as error_gen_error:
                logger.exception(
                    "Failed to generate enhanced error report: {}",
                    error_gen_error,
                )
                enhanced_report_content = None

            # Get existing metadata from database first
            existing_metadata = {}
            try:
                # username already available from function scope (line 281)
                if username:
                    with get_user_db_session(username) as db_session:
                        research = (
                            db_session.query(ResearchHistory)
                            .filter_by(id=research_id)
                            .first()
                        )
                        if research and research.research_meta:
                            existing_metadata = dict(research.research_meta)
            except Exception:
                logger.exception("Failed to get existing metadata")

            # Update metadata with more context about the error while preserving existing values
            metadata = existing_metadata
            metadata.update({"phase": "error", "error": user_friendly_error})
            if error_context:
                metadata.update(error_context)
            if enhanced_report_content:
                metadata["has_enhanced_report"] = True

            # If we still have an active research record, update its log
            if is_research_active(research_id):
                progress_callback(user_friendly_error, None, metadata)

            # If termination was requested, mark as suspended instead of failed
            status = (
                ResearchStatus.SUSPENDED
                if is_termination_requested(research_id)
                else ResearchStatus.FAILED
            )
            message = (
                "Research was terminated by user"
                if status == ResearchStatus.SUSPENDED
                else user_friendly_error
            )

            # Calculate duration up to termination point - using UTC consistently
            now = datetime.now(UTC)
            completed_at = now.isoformat()

            # NOTE: Database updates from threads are handled by queue processor
            # The queue_processor.queue_error_update() method is already being used below
            # to safely update the database from the main thread

            # Queue the error update to be processed in main thread
            # Using the queue processor v2 system
            from ..queue.processor_v2 import queue_processor

            if username:
                queue_processor.queue_error_update(
                    username=username,
                    research_id=research_id,
                    status=status,
                    error_message=message,
                    metadata=metadata,
                    completed_at=completed_at,
                    report_path=None,
                )
                logger.info(
                    f"Queued error update for research {research_id} with status '{status}'"
                )
            else:
                logger.error(
                    f"Cannot queue error update for research {research_id} - no username provided. "
                    f"Status: '{status}', Message: {message}"
                )

            try:
                SocketIOService().emit_to_subscribers(
                    "progress",
                    research_id,
                    {"status": status, "error": message},
                )
            except Exception:
                logger.exception("Failed to emit error via socket")

        except Exception:
            logger.exception("Error in error handler")

        # Clean up resources
        cleanup_research_resources(
            research_id, username, user_password=user_password
        )

    finally:
        # RESOURCE CLEANUP: Close search engine HTTP sessions.
        #
        # Search engines (created via get_search()) may hold HTTP connection
        # pools. Currently only SemanticScholarSearchEngine creates a
        # persistent SafeSession; other engines use stateless safe_get()/
        # safe_post() utility functions. However, BaseSearchEngine.close()
        # is safe to call on any engine — it checks for a 'session'
        # attribute and is fully idempotent (SemanticScholar sets
        # self.session = None after close).
        #
        # Neither @thread_cleanup nor cleanup_research_resources() close
        # the search engine — @thread_cleanup only handles database sessions
        # and context cleanup, and cleanup_research_resources() only handles
        # status updates, notifications, and tracking dict removal.
        #
        # Without this explicit close, search engine sessions rely on
        # Python's non-deterministic garbage collection (__del__) for
        # cleanup, which can cause file descriptor exhaustion under
        # sustained load.
        from ...utilities.resource_utils import safe_close

        if "use_search" in locals():
            safe_close(use_search, "research search engine")
        # Close search system (cascades to strategy thread pools).
        # See AdvancedSearchSystem.close() for details.
        if "system" in locals():
            safe_close(system, "research system")
        # Close the LLM instance created for model/provider overrides.
        # system.close() does NOT close the LLM passed to it via system.model,
        # so we must close it explicitly here.
        if "use_llm" in locals():
            safe_close(use_llm, "research LLM")


def cleanup_research_resources(research_id, username=None, user_password=None):
    """
    Clean up resources for a completed research.

    Args:
        research_id: The ID of the research
        username: The username for database access (required for thread context)
    """
    from ..routes.globals import cleanup_research

    logger.info("Cleaning up resources for research {}", research_id)

    # For testing: Add a small delay to simulate research taking time
    # This helps test concurrent research limits
    from ...settings.env_registry import is_test_mode

    if is_test_mode():
        import time

        logger.info(
            f"Test mode: Adding 5 second delay before cleanup for {research_id}"
        )
        time.sleep(5)

    # Get the current status from the database to determine the final status message
    current_status = ResearchStatus.COMPLETED  # Default

    # NOTE: Queue processor already handles database updates from the main thread
    # The notify_research_completed() method is called at the end of this function
    # which safely updates the database status

    # Notify queue processor that research completed
    # This uses processor_v2 which handles database updates in the main thread
    # avoiding the Flask request context issues that occur in background threads
    from ..queue.processor_v2 import queue_processor

    if username:
        queue_processor.notify_research_completed(
            username, research_id, user_password=user_password
        )
        logger.info(
            f"Notified queue processor of completion for research {research_id} (user: {username})"
        )
    else:
        logger.warning(
            f"Cannot notify completion for research {research_id} - no username provided"
        )

    # Remove from active research and termination flags atomically
    cleanup_research(research_id)

    # Clean up throttle state for this research
    with _last_emit_lock:
        _last_emit_times.pop(research_id, None)

    # Send a final message to subscribers
    try:
        # Send a final message to any remaining subscribers with explicit status
        # Use the proper status message based on database status
        if current_status in (
            ResearchStatus.SUSPENDED,
            ResearchStatus.FAILED,
        ):
            final_message = {
                "status": current_status,
                "message": f"Research was {current_status}",
                "progress": 0,  # For suspended research, show 0% not 100%
            }
        else:
            final_message = {
                "status": ResearchStatus.COMPLETED,
                "message": "Research process has ended and resources have been cleaned up",
                "progress": 100,
            }

        logger.info(
            "Sending final {} socket message for research {}",
            current_status,
            research_id,
        )

        SocketIOService().emit_to_subscribers(
            "progress", research_id, final_message
        )

        # Clean up socket subscriptions for this research
        SocketIOService().remove_subscriptions_for_research(research_id)

    except Exception:
        logger.exception("Error sending final cleanup message")


def handle_termination(research_id, username=None):
    """
    Handle the termination of a research process.

    Args:
        research_id: The ID of the research
        username: The username for database access (required for thread context)
    """
    logger.info(f"Handling termination for research {research_id}")

    # Queue the status update to be processed in the main thread
    # This avoids Flask request context errors in background threads
    try:
        from ..queue.processor_v2 import queue_processor

        now = datetime.now(UTC)
        completed_at = now.isoformat()

        # Queue the suspension update
        queue_processor.queue_error_update(
            username=username,
            research_id=research_id,
            status=ResearchStatus.SUSPENDED,
            error_message="Research was terminated by user",
            metadata={"terminated_at": completed_at},
            completed_at=completed_at,
            report_path=None,
        )

        logger.info(f"Queued suspension update for research {research_id}")
    except Exception:
        logger.exception(
            f"Error queueing termination update for research {research_id}"
        )

    # Clean up resources (this already handles things properly)
    cleanup_research_resources(research_id, username)


def cancel_research(research_id, username):
    """
    Cancel/terminate a research process using ORM.

    Args:
        research_id: The ID of the research to cancel
        username: The username of the user cancelling the research

    Returns:
        bool: True if the research was found and cancelled, False otherwise
    """
    try:
        from ..routes.globals import is_research_active, set_termination_flag

        # Set termination flag
        set_termination_flag(research_id)

        # Check if the research is active
        if is_research_active(research_id):
            # Call handle_termination to update database
            handle_termination(research_id, username)
            return True
        try:
            with get_user_db_session(username) as db_session:
                research = (
                    db_session.query(ResearchHistory)
                    .filter_by(id=research_id)
                    .first()
                )
                if not research:
                    logger.info(f"Research {research_id} not found in database")
                    return False

                # Check if already in a terminal state
                if research.status in (
                    ResearchStatus.COMPLETED,
                    ResearchStatus.SUSPENDED,
                    ResearchStatus.FAILED,
                    ResearchStatus.ERROR,
                ):
                    logger.info(
                        f"Research {research_id} already in terminal state: {research.status}"
                    )
                    return True  # Consider this a success since it's already stopped

                # If it exists but isn't in active_research, still update status
                research.status = ResearchStatus.SUSPENDED
                db_session.commit()
                logger.info(f"Successfully suspended research {research_id}")
        except Exception:
            logger.exception(
                f"Error accessing database for research {research_id}"
            )
            return False

        return True
    except Exception:
        logger.exception(
            f"Unexpected error in cancel_research for {research_id}"
        )
        return False
