"""Core ActivityProcessor class and orchestration.

Main class that coordinates all processing, scheduling, and recovery.
"""

import asyncio
import json
import logging
import sqlite3
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import TYPE_CHECKING, Any

from open_agent_kit.features.codebase_intelligence.activity.processor.classification import (
    classify_heuristic,
    classify_session,
    compute_session_origin_type,
    select_template_by_classification,
)
from open_agent_kit.features.codebase_intelligence.activity.processor.handlers import (
    get_batch_handler,
    process_user_batch,
)
from open_agent_kit.features.codebase_intelligence.activity.processor.indexing import (
    embed_pending_observations,
    index_pending_plans,
    rebuild_chromadb_from_sqlite,
    rebuild_plan_index,
)
from open_agent_kit.features.codebase_intelligence.activity.processor.llm import (
    call_llm,
    get_oak_ci_context,
)
from open_agent_kit.features.codebase_intelligence.activity.processor.models import (
    ContextBudget,
    ProcessingResult,
)
from open_agent_kit.features.codebase_intelligence.activity.processor.observation import (
    store_observation,
)
from open_agent_kit.features.codebase_intelligence.activity.processor.summaries import (
    process_session_summary,
)
from open_agent_kit.features.codebase_intelligence.activity.processor.titles import (
    generate_pending_titles,
    generate_session_title,
    generate_title_from_summary,
)
from open_agent_kit.features.codebase_intelligence.activity.prompts import (
    PromptTemplateConfig,
    render_prompt,
)
from open_agent_kit.features.codebase_intelligence.constants import (
    AGENT_RUN_RECOVERY_BUFFER_SECONDS,
    DEFAULT_AGENT_TIMEOUT_SECONDS,
    DEFAULT_BACKGROUND_PROCESSING_BATCH_SIZE,
    DEFAULT_BACKGROUND_PROCESSING_WORKERS,
    INJECTION_MAX_SESSION_SUMMARIES,
    POWER_ACTIVE_INTERVAL,
    POWER_DEEP_SLEEP_THRESHOLD,
    POWER_IDLE_THRESHOLD,
    POWER_SLEEP_INTERVAL,
    POWER_SLEEP_THRESHOLD,
    POWER_STATE_ACTIVE,
    POWER_STATE_DEEP_SLEEP,
    POWER_STATE_IDLE,
    POWER_STATE_SLEEP,
    PROMPT_SOURCE_PLAN,
    PROMPT_SOURCE_USER,
    SESSION_STATUS_ACTIVE,
)

if TYPE_CHECKING:
    from collections.abc import Callable

    from open_agent_kit.features.codebase_intelligence.activity.store import (
        ActivityStore,
    )
    from open_agent_kit.features.codebase_intelligence.config import (
        CIConfig,
        SessionQualityConfig,
        SummarizationConfig,
    )
    from open_agent_kit.features.codebase_intelligence.daemon.state import DaemonState
    from open_agent_kit.features.codebase_intelligence.memory.store import VectorStore
    from open_agent_kit.features.codebase_intelligence.summarization.base import (
        BaseSummarizer,
    )

logger = logging.getLogger(__name__)

# Exception types caught by background processing phases.
# Each phase has its own error boundary so failures are isolated.
# Exception types caught by background processing phases.
# Each phase has its own error boundary so failures are isolated —
# a bug in one phase must not crash the entire processor loop.
# TypeError/KeyError/AttributeError are intentionally included:
# while they often indicate programming errors, in this context
# phase isolation is more important than fail-fast behavior.
# All caught exceptions are logged with exc_info=True for debugging.
_BG_EXCEPTIONS = (
    OSError,
    sqlite3.OperationalError,
    ValueError,
    TypeError,
    KeyError,
    AttributeError,
)


class ActivityProcessor:
    """Background processor for activity → observation extraction.

    Two-stage approach:
    1. LLM classifies session type
    2. Activity-specific prompt with oak ci context injection
    """

    def __init__(
        self,
        activity_store: "ActivityStore",
        vector_store: "VectorStore",
        summarizer: "BaseSummarizer | None" = None,
        prompt_config: PromptTemplateConfig | None = None,
        project_root: str | None = None,
        context_tokens: int = 4096,
        session_quality_config: "SessionQualityConfig | None" = None,
        config_accessor: "Callable[[], CIConfig | None] | None" = None,
    ):
        """Initialize the processor.

        Args:
            activity_store: SQLite store for activities.
            vector_store: ChromaDB store for observations.
            summarizer: Static summarizer fallback (used when config_accessor
                is not provided, e.g. in tests).
            prompt_config: Prompt template configuration.
            project_root: Project root for oak ci commands.
            context_tokens: Static context_tokens fallback (used when
                config_accessor is not provided).
            session_quality_config: Static session quality fallback.
            config_accessor: Callable returning the current CIConfig. When
                provided, summarizer/context_budget/session_quality are read
                from live config instead of the static init values. This
                ensures config changes via the UI take effect immediately
                without a daemon restart.
        """
        self.activity_store = activity_store
        self.vector_store = vector_store
        self.prompt_config = prompt_config or PromptTemplateConfig.load_from_directory()
        self.project_root = project_root

        # Config accessor for live config reads (production path).
        # When set, properties below read from it instead of static fallbacks.
        self._config_accessor = config_accessor

        # Static fallbacks (used by tests and when no accessor is provided)
        self._fallback_summarizer = summarizer
        self._fallback_context_budget = ContextBudget.from_context_tokens(context_tokens)
        self._fallback_session_quality_config = session_quality_config

        # Summarizer cache: recreated when config fingerprint changes
        self._cached_summarizer: BaseSummarizer | None = None
        self._summarizer_config_key: tuple[str, str, str, str | None, float, bool] | None = None
        self._summarizer_lock = threading.Lock()

        self._processing_lock = threading.Lock()
        self._is_processing = False
        self._last_process_time: datetime | None = None

    @staticmethod
    def _summarizer_fingerprint(
        sc: "SummarizationConfig",
    ) -> "tuple[str, str, str, str | None, float, bool]":
        """Build a cache key from all fields that affect summarizer construction."""
        return (sc.provider, sc.model, sc.base_url, sc.api_key, sc.timeout, sc.enabled)

    @property
    def summarizer(self) -> "BaseSummarizer | None":
        """Get the current summarizer, recreating if config changed."""
        if self._config_accessor is not None:
            config = self._config_accessor()
            if config is not None:
                sc = config.summarization
                key = self._summarizer_fingerprint(sc)
                if key != self._summarizer_config_key:
                    with self._summarizer_lock:
                        # Double-check after acquiring lock
                        if key != self._summarizer_config_key:
                            if sc.enabled:
                                from open_agent_kit.features.codebase_intelligence.summarization import (
                                    create_summarizer_from_config,
                                )

                                self._cached_summarizer = create_summarizer_from_config(sc)
                            else:
                                self._cached_summarizer = None
                            self._summarizer_config_key = key
                return self._cached_summarizer
        return self._fallback_summarizer

    @property
    def context_budget(self) -> ContextBudget:
        """Get context budget from live config or static fallback."""
        if self._config_accessor is not None:
            config = self._config_accessor()
            if config is not None:
                return ContextBudget.from_context_tokens(config.summarization.get_context_tokens())
        return self._fallback_context_budget

    @property
    def session_quality_config(self) -> "SessionQualityConfig | None":
        """Get session quality config from live config or static fallback."""
        if self._config_accessor is not None:
            config = self._config_accessor()
            if config is not None:
                return config.session_quality
        return self._fallback_session_quality_config

    @property
    def processing_workers(self) -> int:
        """Get number of parallel processing workers from live config or constant."""
        if self._config_accessor is not None:
            config = self._config_accessor()
            if config is not None:
                return config.agents.background_processing_workers
        return DEFAULT_BACKGROUND_PROCESSING_WORKERS

    @property
    def min_session_activities(self) -> int:
        """Get minimum activities threshold from config or constant."""
        from open_agent_kit.features.codebase_intelligence.constants import (
            MIN_SESSION_ACTIVITIES,
        )

        sqc = self.session_quality_config
        if sqc:
            return sqc.min_activities
        return MIN_SESSION_ACTIVITIES

    @property
    def stale_timeout_seconds(self) -> int:
        """Get stale timeout from config or constant."""
        from open_agent_kit.features.codebase_intelligence.constants import (
            SESSION_INACTIVE_TIMEOUT_SECONDS,
        )

        sqc = self.session_quality_config
        if sqc:
            return sqc.stale_timeout_seconds
        return SESSION_INACTIVE_TIMEOUT_SECONDS

    def _call_llm(self, prompt: str) -> dict[str, Any]:
        """Call LLM for observation extraction.

        Args:
            prompt: Rendered prompt.

        Returns:
            Dictionary with success, observations, summary, and raw_response.
        """
        if not self.summarizer:
            return {"success": False, "error": "No summarizer configured"}
        return call_llm(prompt, self.summarizer, self.context_budget)

    def _classify_session(
        self,
        activities: list[dict[str, Any]],
        tool_names: list[str],
        files_read: list[str],
        files_modified: list[str],
        files_created: list[str],
        has_errors: bool,
        duration_minutes: float,
    ) -> str:
        """Classify session type using LLM."""
        return classify_session(
            activities=activities,
            tool_names=tool_names,
            files_read=files_read,
            files_modified=files_modified,
            files_created=files_created,
            has_errors=has_errors,
            duration_minutes=duration_minutes,
            prompt_config=self.prompt_config,
            call_llm=self._call_llm,
        )

    def _classify_heuristic(
        self,
        tool_names: list[str],
        has_errors: bool,
        files_modified: list[str],
        files_created: list[str],
    ) -> str:
        """Fallback heuristic classification."""
        return classify_heuristic(tool_names, has_errors, files_modified, files_created)

    def _select_template_by_classification(self, classification: str) -> Any:
        """Select extraction template based on LLM classification."""
        return select_template_by_classification(classification, self.prompt_config)

    def _get_oak_ci_context(
        self,
        files_read: list[str],
        files_modified: list[str],
        files_created: list[str],
        classification: str,
    ) -> str:
        """Get relevant context from oak ci for the prompt."""
        return get_oak_ci_context(
            files_read=files_read,
            files_modified=files_modified,
            files_created=files_created,
            classification=classification,
            project_root=self.project_root,
        )

    def _store_observation(
        self,
        session_id: str,
        observation: dict[str, Any],
        classification: str | None = None,
        prompt_batch_id: int | None = None,
        session_origin_type: str | None = None,
    ) -> str | None:
        """Store an observation using dual-write: SQLite + ChromaDB."""
        # Read auto-resolve config from live config if available
        auto_resolve_config = None
        if self._config_accessor is not None:
            config = self._config_accessor()
            if config is not None:
                auto_resolve_config = config.auto_resolve

        return store_observation(
            session_id=session_id,
            observation=observation,
            activity_store=self.activity_store,
            vector_store=self.vector_store,
            classification=classification,
            prompt_batch_id=prompt_batch_id,
            project_root=self.project_root,
            session_origin_type=session_origin_type,
            auto_resolve_config=auto_resolve_config,
        )

    def process_session(self, session_id: str) -> ProcessingResult:
        """Process all unprocessed activities for a session.

        Two-stage approach:
        1. Classify session type via LLM
        2. Extract observations with activity-specific prompt + oak ci context

        Args:
            session_id: Session to process.

        Returns:
            ProcessingResult with extraction statistics.
        """
        start_time = datetime.now()

        if not self.summarizer:
            logger.warning("No summarizer configured, skipping activity processing")
            return ProcessingResult(
                session_id=session_id,
                activities_processed=0,
                observations_extracted=0,
                success=False,
                error="No summarizer configured",
            )

        # Get unprocessed activities
        activities = self.activity_store.get_unprocessed_activities(session_id=session_id)

        if not activities:
            logger.debug(f"No unprocessed activities for session {session_id}")
            self.activity_store.mark_session_processed(session_id)
            return ProcessingResult(
                session_id=session_id,
                activities_processed=0,
                observations_extracted=0,
                success=True,
            )

        logger.info(f"Processing {len(activities)} activities for session {session_id}")

        try:
            # Extract session statistics
            tool_names = [a.tool_name for a in activities]
            files_read = list(
                {a.file_path for a in activities if a.tool_name == "Read" and a.file_path}
            )
            files_modified = list(
                {a.file_path for a in activities if a.tool_name == "Edit" and a.file_path}
            )
            files_created = list(
                {a.file_path for a in activities if a.tool_name == "Write" and a.file_path}
            )
            errors = [a.error_message for a in activities if a.error_message]

            # Calculate session duration
            if activities:
                first_ts = activities[0].timestamp
                last_ts = activities[-1].timestamp
                duration_minutes = (last_ts - first_ts).total_seconds() / 60
            else:
                duration_minutes = 0

            # Build activity dicts for prompts
            activity_dicts = [
                {
                    "tool_name": a.tool_name,
                    "file_path": a.file_path,
                    "tool_output_summary": a.tool_output_summary,
                    "error_message": a.error_message,
                }
                for a in activities
            ]

            # Stage 1: Classify session type via LLM
            classification = self._classify_session(
                activities=activity_dicts,
                tool_names=tool_names,
                files_read=files_read,
                files_modified=files_modified,
                files_created=files_created,
                has_errors=bool(errors),
                duration_minutes=duration_minutes,
            )
            logger.info(f"Session classified as: {classification}")

            # Stage 2: Select prompt based on classification
            template = self._select_template_by_classification(classification)
            logger.debug(f"Using prompt template: {template.name}")

            # Inject oak ci context for relevant files
            oak_ci_context = self._get_oak_ci_context(
                files_read=files_read,
                files_modified=files_modified,
                files_created=files_created,
                classification=classification,
            )

            # Render extraction prompt with dynamic context budget
            budget = self.context_budget
            prompt = render_prompt(
                template=template,
                activities=activity_dicts,
                session_duration=duration_minutes,
                files_read=files_read,
                files_modified=files_modified,
                files_created=files_created,
                errors=errors,
                max_activities=budget.max_activities,
            )

            # Inject oak ci context into prompt (trimmed to budget)
            if oak_ci_context:
                oak_context_trimmed = oak_ci_context[: budget.max_oak_context_chars]
                prompt = f"{prompt}\n\n## Related Code Context\n\n{oak_context_trimmed}"

            # Call LLM for extraction
            result = self._call_llm(prompt)

            if not result.get("success"):
                logger.warning(f"LLM extraction failed: {result.get('error')}")
                return ProcessingResult(
                    session_id=session_id,
                    activities_processed=len(activities),
                    observations_extracted=0,
                    success=False,
                    error=result.get("error"),
                    duration_ms=int((datetime.now() - start_time).total_seconds() * 1000),
                    classification=classification,
                )

            # Store observations
            observations = result.get("observations", [])
            stored_count = 0

            for obs in observations:
                try:
                    obs_id = self._store_observation(
                        session_id=session_id,
                        observation=obs,
                        classification=classification,
                    )
                    if obs_id:
                        stored_count += 1
                except (ValueError, KeyError, AttributeError, TypeError) as e:
                    logger.warning(f"Failed to store observation: {e}")

            # Mark activities as processed
            activity_ids = [a.id for a in activities if a.id is not None]
            if activity_ids:
                self.activity_store.mark_activities_processed(activity_ids)

            # Mark session as processed
            self.activity_store.mark_session_processed(session_id)

            duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)

            logger.info(
                f"Processed session {session_id}: {len(activities)} activities → "
                f"{stored_count} observations ({duration_ms}ms, type={classification})"
            )

            return ProcessingResult(
                session_id=session_id,
                activities_processed=len(activities),
                observations_extracted=stored_count,
                success=True,
                duration_ms=duration_ms,
                classification=classification,
            )

        except (
            OSError,
            json.JSONDecodeError,
            ValueError,
            TypeError,
            KeyError,
            AttributeError,
        ) as e:
            logger.error(f"Error processing session {session_id}: {e}", exc_info=True)
            return ProcessingResult(
                session_id=session_id,
                activities_processed=len(activities),
                observations_extracted=0,
                success=False,
                error=str(e),
                duration_ms=int((datetime.now() - start_time).total_seconds() * 1000),
            )

    def process_prompt_batch(self, batch_id: int) -> ProcessingResult:
        """Process activities for a single prompt batch.

        This is the preferred processing unit - activities from one user prompt.
        Dispatches to appropriate handler based on batch source_type.

        Args:
            batch_id: Prompt batch ID to process.

        Returns:
            ProcessingResult with extraction statistics.
        """
        start_time = datetime.now()

        batch = self.activity_store.get_prompt_batch(batch_id)
        if not batch:
            return ProcessingResult(
                session_id="",
                activities_processed=0,
                observations_extracted=0,
                success=False,
                error=f"Prompt batch {batch_id} not found",
                prompt_batch_id=batch_id,
            )

        # Get activities for this batch
        activities = self.activity_store.get_prompt_batch_activities(batch_id)

        if not activities:
            logger.debug(f"No activities for prompt batch {batch_id}")
            # Mark as processed even if empty
            self.activity_store.mark_prompt_batch_processed(batch_id)
            return ProcessingResult(
                session_id=batch.session_id,
                activities_processed=0,
                observations_extracted=0,
                success=True,
                prompt_batch_id=batch_id,
            )

        # Log batch info with source type
        source_type = batch.source_type or PROMPT_SOURCE_USER
        logger.info(
            f"Processing prompt batch {batch_id} (prompt #{batch.prompt_number}, "
            f"source={source_type}): {len(activities)} activities"
        )

        # For user batches, require a summarizer for LLM extraction
        if source_type == PROMPT_SOURCE_USER and not self.summarizer:
            logger.warning("No summarizer configured, skipping prompt batch processing")
            return ProcessingResult(
                session_id=batch.session_id,
                activities_processed=0,
                observations_extracted=0,
                success=False,
                error="No summarizer configured",
                prompt_batch_id=batch_id,
            )

        try:
            # Clean up any existing observations from a previous processing run.
            # Without this, reprocessing a batch creates NEW observations (new UUIDs)
            # while leaving the old ones in place, causing unbounded duplication.
            old_obs_ids = self.activity_store.delete_batch_observations(batch_id)
            if old_obs_ids:
                try:
                    self.vector_store.delete_memories(old_obs_ids)
                    logger.info(
                        f"Cleaned up {len(old_obs_ids)} old observations for batch {batch_id} "
                        f"before reprocessing"
                    )
                except (ValueError, RuntimeError, KeyError, AttributeError) as e:
                    # SQLite cleanup succeeded; ChromaDB will be stale but not duplicated
                    logger.warning(f"ChromaDB cleanup failed for batch {batch_id}: {e}")

            # Compute session origin type from activity stats
            session_stats = self.activity_store.get_session_stats(batch.session_id)
            all_batches = self.activity_store.get_session_prompt_batches(
                batch.session_id, limit=100
            )
            has_plan_batches = any(
                b.source_type in (PROMPT_SOURCE_PLAN, "derived_plan") for b in all_batches
            )
            session_origin_type = compute_session_origin_type(
                stats=session_stats, has_plan_batches=has_plan_batches
            )

            # Dispatch to appropriate handler based on source type
            handler = get_batch_handler(source_type)

            # Build handler kwargs
            handler_kwargs = {
                "batch_id": batch_id,
                "batch": batch,
                "activities": activities,
                "start_time": start_time,
                "activity_store": self.activity_store,
                "vector_store": self.vector_store,
                "prompt_config": self.prompt_config,
                "context_budget": self.context_budget,
                "project_root": self.project_root,
                "call_llm": self._call_llm,
                "store_observation": store_observation,
                "classify_session": classify_session,
                "select_template_by_classification": select_template_by_classification,
                "get_oak_ci_context": get_oak_ci_context,
                "session_origin_type": session_origin_type,
            }

            return handler(**handler_kwargs)

        except (
            OSError,
            json.JSONDecodeError,
            ValueError,
            TypeError,
            KeyError,
            AttributeError,
        ) as e:
            logger.error(f"Error processing prompt batch {batch_id}: {e}", exc_info=True)
            return ProcessingResult(
                session_id=batch.session_id,
                activities_processed=len(activities),
                observations_extracted=0,
                success=False,
                error=str(e),
                duration_ms=int((datetime.now() - start_time).total_seconds() * 1000),
                prompt_batch_id=batch_id,
            )

    def process_pending_batches(
        self, max_batches: int = DEFAULT_BACKGROUND_PROCESSING_BATCH_SIZE
    ) -> list[ProcessingResult]:
        """Process all pending prompt batches.

        Args:
            max_batches: Maximum batches to process in one run.

        Returns:
            List of ProcessingResult for each batch.
        """
        with self._processing_lock:
            if self._is_processing:
                logger.debug("Processing already in progress, skipping")
                return []

            self._is_processing = True

        try:
            batches = self.activity_store.get_unprocessed_prompt_batches(limit=max_batches)

            if not batches:
                # Debug: check why no batches - query all recent batches
                try:
                    conn = self.activity_store._get_connection()
                    cursor = conn.execute(
                        "SELECT id, status, processed, classification FROM prompt_batches "
                        "ORDER BY created_at_epoch DESC LIMIT 5"
                    )
                    recent = cursor.fetchall()
                    if recent:
                        batch_info = [
                            f"id={r[0]} status={r[1]} processed={r[2]} class={r[3]}" for r in recent
                        ]
                        logger.debug(f"Recent batches: {batch_info}")
                except sqlite3.OperationalError as e:
                    logger.debug(f"Could not query batch state: {e}")

                logger.debug("No pending prompt batches to process")
                return []

            logger.info(f"Processing {len(batches)} pending prompt batches")

            batch_ids = [b.id for b in batches if b.id is not None]
            workers = min(self.processing_workers, len(batch_ids))

            results = []
            if workers <= 1:
                # Single batch — no thread pool overhead
                for bid in batch_ids:
                    results.append(self.process_prompt_batch(bid))
            else:
                with ThreadPoolExecutor(max_workers=workers) as pool:
                    futures = {
                        pool.submit(self.process_prompt_batch, bid): bid for bid in batch_ids
                    }
                    for future in as_completed(futures):
                        try:
                            results.append(future.result())
                        except Exception as e:
                            bid = futures[future]
                            logger.error(f"Batch {bid} failed in thread pool: {e}")
                            results.append(
                                ProcessingResult(
                                    session_id="",
                                    activities_processed=0,
                                    observations_extracted=0,
                                    success=False,
                                    error=str(e),
                                    prompt_batch_id=bid,
                                )
                            )

            self._last_process_time = datetime.now()
            return results

        finally:
            with self._processing_lock:
                self._is_processing = False

    def _process_user_batch(
        self,
        batch_id: int,
        batch: Any,
        activities: list[Any],
        start_time: datetime,
    ) -> ProcessingResult:
        """Process a user-initiated batch with full LLM extraction.

        This is a wrapper for the handlers.process_user_batch function.
        Used by promote_agent_batch.
        """
        # Compute session origin type for the batch
        session_stats = self.activity_store.get_session_stats(batch.session_id)
        all_batches = self.activity_store.get_session_prompt_batches(batch.session_id, limit=100)
        has_plan_batches = any(
            b.source_type in (PROMPT_SOURCE_PLAN, "derived_plan") for b in all_batches
        )
        session_origin_type = compute_session_origin_type(
            stats=session_stats, has_plan_batches=has_plan_batches
        )

        return process_user_batch(
            batch_id=batch_id,
            batch=batch,
            activities=activities,
            start_time=start_time,
            activity_store=self.activity_store,
            vector_store=self.vector_store,
            prompt_config=self.prompt_config,
            context_budget=self.context_budget,
            project_root=self.project_root,
            call_llm=self._call_llm,
            store_observation=store_observation,
            classify_session=classify_session,
            select_template_by_classification=select_template_by_classification,
            get_oak_ci_context=get_oak_ci_context,
            session_origin_type=session_origin_type,
        )

    def generate_session_title(self, session_id: str) -> str | None:
        """Generate a short title for a session based on its prompts."""
        return generate_session_title(
            session_id=session_id,
            activity_store=self.activity_store,
            prompt_config=self.prompt_config,
            call_llm=self._call_llm,
            min_activities=self.min_session_activities,
        )

    def generate_pending_titles(self, limit: int = 5) -> int:
        """Generate titles for sessions that don't have them."""
        return generate_pending_titles(
            activity_store=self.activity_store,
            prompt_config=self.prompt_config,
            call_llm=self._call_llm,
            limit=limit,
            min_activities=self.min_session_activities,
        )

    def process_session_summary_with_title(
        self, session_id: str, regenerate_title: bool = False
    ) -> tuple[str | None, str | None]:
        """Generate and store a session summary and title.

        Args:
            session_id: Session to summarize.
            regenerate_title: If True, force regenerate title even if one exists.

        Returns:
            Tuple of (summary text, title text) if generated, (None, None) otherwise.
        """
        if not self.summarizer:
            logger.info("Session summary skipped: summarizer not configured")
            return None, None

        # Create a wrapper for generate_title_from_summary that binds store and config
        def _generate_title_from_summary(sid: str, summary: str) -> str | None:
            return generate_title_from_summary(
                session_id=sid,
                summary=summary,
                activity_store=self.activity_store,
                prompt_config=self.prompt_config,
                call_llm=self._call_llm,
            )

        return process_session_summary(
            session_id=session_id,
            activity_store=self.activity_store,
            vector_store=self.vector_store,
            prompt_config=self.prompt_config,
            call_llm=self._call_llm,
            generate_title=self.generate_session_title,
            regenerate_title=regenerate_title,
            generate_title_from_summary=_generate_title_from_summary,
        )

    def complete_session(self, session_id: str) -> tuple[str | None, str | None]:
        """Mark an active session as completed and run post-completion processing.

        This is the same chain the background job runs for stale sessions:
        1. Mark session status as 'completed' (via end_session)
        2. Generate summary (if summarizer configured)
        3. Generate title (if missing)

        Args:
            session_id: Session to complete.

        Returns:
            Tuple of (summary text, title text) if generated.

        Raises:
            ValueError: If session not found or not active.
        """
        session = self.activity_store.get_session(session_id)
        if not session:
            raise ValueError(f"Session {session_id} not found")
        if session.status != SESSION_STATUS_ACTIVE:
            raise ValueError(
                f"Session {session_id} is already '{session.status}', only active sessions can be completed"
            )

        # Step 1: Mark as completed — same SQL path as recover_stale_sessions
        self.activity_store.end_session(session_id)
        logger.info(f"Manually completed session {session_id[:8]}")

        # Step 2: Generate summary — same path as background job
        summary, title = self.process_session_summary_with_title(session_id, regenerate_title=True)

        # Step 3: Generate title if summary didn't produce one
        if not title:
            count = self.generate_pending_titles()
            if count > 0:
                logger.info(f"Generated {count} pending titles after completing session")
            # Re-fetch to get the title that was generated
            updated = self.activity_store.get_session(session_id)
            if updated and updated.title:
                title = updated.title

        return summary, title

    def process_pending(self, max_sessions: int = 5) -> list[ProcessingResult]:
        """Process all pending sessions.

        Args:
            max_sessions: Maximum sessions to process in one batch.

        Returns:
            List of ProcessingResult for each session.
        """
        with self._processing_lock:
            if self._is_processing:
                logger.debug("Processing already in progress, skipping")
                return []

            self._is_processing = True

        try:
            sessions = self.activity_store.get_unprocessed_sessions(limit=max_sessions)

            if not sessions:
                logger.debug("No pending sessions to process")
                return []

            logger.info(f"Processing {len(sessions)} pending sessions")

            results = []
            for session in sessions:
                result = self.process_session(session.id)
                results.append(result)

            self._last_process_time = datetime.now()
            return results

        finally:
            with self._processing_lock:
                self._is_processing = False

    # ------------------------------------------------------------------
    # Background processing phases
    #
    # Each phase has its own error boundary so a failure in one phase
    # does not skip subsequent phases.  This decomposition also makes
    # each phase independently testable and sets the groundwork for
    # future parallelization (W5.6).
    # ------------------------------------------------------------------

    def _bg_recover_stuck_data(self) -> None:
        """Phase 1: Recover stuck batches, stale runs, and orphaned activities."""
        try:
            from open_agent_kit.features.codebase_intelligence.constants import (
                BATCH_ACTIVE_TIMEOUT_SECONDS,
            )

            # Auto-end batches stuck in 'active' too long
            stuck_count = self.activity_store.recover_stuck_batches(
                timeout_seconds=BATCH_ACTIVE_TIMEOUT_SECONDS,
                project_root=self.project_root,
            )
            if stuck_count:
                logger.info(f"Recovered {stuck_count} stuck batches")

            # Mark stale agent runs as failed
            stale_run_ids = self.activity_store.recover_stale_runs(
                buffer_seconds=AGENT_RUN_RECOVERY_BUFFER_SECONDS,
                default_timeout_seconds=DEFAULT_AGENT_TIMEOUT_SECONDS,
            )
            if stale_run_ids:
                logger.info(
                    f"Recovered {len(stale_run_ids)} stale agent runs: "
                    f"{[r[:8] for r in stale_run_ids]}"
                )

            # Associate orphaned activities with batches
            orphan_count = self.activity_store.recover_orphaned_activities()
            if orphan_count:
                logger.info(f"Recovered {orphan_count} orphaned activities")
        except _BG_EXCEPTIONS as e:
            logger.error(f"Background recovery error: {e}", exc_info=True)

    def _bg_recover_stale_sessions(self) -> None:
        """Phase 2: End/delete stale sessions and summarize recovered ones."""
        try:
            recovered_ids, deleted_ids = self.activity_store.recover_stale_sessions(
                timeout_seconds=self.stale_timeout_seconds,
                min_activities=self.min_session_activities,
                vector_store=self.vector_store,
            )
            if deleted_ids:
                logger.info(
                    f"Deleted {len(deleted_ids)} empty stale sessions: "
                    f"{[s[:8] for s in deleted_ids]}"
                )
            if recovered_ids:
                logger.info(f"Recovered {len(recovered_ids)} stale sessions")
                for session_id in recovered_ids:
                    try:
                        summary, _title = self.process_session_summary_with_title(session_id)
                        if summary:
                            logger.info(
                                f"Generated summary for recovered session "
                                f"{session_id[:8]}: {summary[:50]}..."
                            )
                    except (OSError, ValueError, TypeError, RuntimeError) as e:
                        logger.warning(
                            f"Failed to summarize recovered session {session_id[:8]}: {e}"
                        )
        except _BG_EXCEPTIONS as e:
            logger.error(f"Background stale-session recovery error: {e}", exc_info=True)

    def _bg_cleanup_and_summarize(self) -> None:
        """Phase 3: Clean up low-quality sessions and generate missing summaries.

        Cleanup runs first to avoid wasting LLM calls on sessions that
        will be deleted.
        """
        try:
            cleanup_ids = self.activity_store.cleanup_low_quality_sessions(
                vector_store=self.vector_store,
                min_activities=self.min_session_activities,
            )
            if cleanup_ids:
                logger.info(
                    f"Cleaned up {len(cleanup_ids)} low-quality completed sessions: "
                    f"{[s[:8] for s in cleanup_ids]}"
                )

            if self.summarizer:
                missing = self.activity_store.get_sessions_missing_summaries(
                    limit=INJECTION_MAX_SESSION_SUMMARIES,
                    min_activities=self.min_session_activities,
                )
                for session in missing:
                    try:
                        summary, _title = self.process_session_summary_with_title(session.id)
                        if summary:
                            logger.info(
                                f"Generated summary for session {session.id[:8]}: "
                                f"{summary[:50]}..."
                            )
                    except (OSError, ValueError, TypeError, RuntimeError) as e:
                        logger.warning(f"Failed to summarize session {session.id[:8]}: {e}")
        except _BG_EXCEPTIONS as e:
            logger.error(f"Background cleanup/summarize error: {e}", exc_info=True)

    def _bg_process_pending(self) -> None:
        """Phase 4: Process pending batches and fallback sessions."""
        try:
            batch_results = self.process_pending_batches()
            if batch_results:
                logger.info(f"Background processed {len(batch_results)} prompt batches")

            self.process_pending()
        except _BG_EXCEPTIONS as e:
            logger.error(f"Background batch processing error: {e}", exc_info=True)

    def _bg_index_and_title(self) -> None:
        """Phase 5: Index pending plans and generate missing titles."""
        try:
            plan_stats = self.index_pending_plans()
            if plan_stats.get("indexed", 0) > 0:
                logger.info(f"Background indexed {plan_stats['indexed']} plans")

            title_count = self.generate_pending_titles()
            if title_count > 0:
                logger.info(f"Background generated {title_count} session titles")
        except _BG_EXCEPTIONS as e:
            logger.error(f"Background indexing/title error: {e}", exc_info=True)

    def run_background_cycle(self) -> None:
        """Execute one full background processing cycle.

        Runs all phases sequentially.  Each phase has its own error
        boundary so a failure in one does not skip the rest.

        Phase ordering:
        1. Recover stuck data (batches, runs, orphans)
        2. Recover stale sessions
        3. Cleanup low-quality sessions + generate summaries
        4. Process pending batches/sessions
        5. Index plans + generate titles
        """
        self._bg_recover_stuck_data()
        self._bg_recover_stale_sessions()
        self._bg_cleanup_and_summarize()
        self._bg_process_pending()
        self._bg_index_and_title()

    def schedule_background_processing(
        self,
        interval_seconds: int = POWER_ACTIVE_INTERVAL,
        state_accessor: "Callable[[], DaemonState] | None" = None,
    ) -> threading.Timer:
        """Schedule periodic background processing with power-state awareness.

        When *state_accessor* is provided the timer callback evaluates idle
        duration and adjusts which phases run and how long to sleep before
        the next cycle.  When the daemon reaches deep sleep the timer is
        **not** rescheduled -- the wake-from-deep-sleep path in
        ``DaemonState.record_hook_activity`` restarts it.

        Args:
            interval_seconds: Base interval between processing runs.
            state_accessor: Optional callable returning the current DaemonState.

        Returns:
            Timer object (can be cancelled).
        """

        def run_and_reschedule() -> None:
            new_state, next_interval = self._evaluate_and_run_cycle(
                state_accessor, interval_seconds
            )
            if next_interval > 0:
                timer = threading.Timer(next_interval, run_and_reschedule)
                timer.daemon = True
                timer.start()

        timer = threading.Timer(interval_seconds, run_and_reschedule)
        timer.daemon = True
        timer.start()

        logger.info(f"Scheduled background activity processing every {interval_seconds}s")
        return timer

    def _evaluate_and_run_cycle(
        self,
        state_accessor: "Callable[[], DaemonState] | None",
        base_interval: int,
    ) -> tuple[str, int]:
        """Evaluate power state, run appropriate phases, return (state, interval).

        Logic:
        - ACTIVE: full ``run_background_cycle()`` (phases 1-5)
        - IDLE: maintenance + indexing (phases 1-2, 5), same interval
        - SLEEP: recovery + indexing (phases 1, 5), longer interval
        - DEEP_SLEEP: no work, interval 0 (timer stops)

        Phase 5 (plan indexing + title generation) runs in IDLE/SLEEP because
        it is lightweight (embedding + ChromaDB upsert, no LLM calls) and plans
        are often created at the end of a session just before idle begins.

        Args:
            state_accessor: Optional callable returning the current DaemonState.
            base_interval: The base interval in seconds.

        Returns:
            Tuple of (power_state_name, next_interval_seconds).
            An interval of 0 means the timer should NOT be rescheduled.
        """
        import time

        daemon_state: DaemonState | None = None
        if state_accessor is not None:
            daemon_state = state_accessor()

        # Determine idle duration — use last hook activity if available,
        # otherwise fall back to daemon start time so the idle clock ticks
        # even when no hooks have ever fired (e.g. daemon starts, user walks away).
        idle_seconds: float | None = None
        if daemon_state is not None:
            last_activity = daemon_state.last_hook_activity or daemon_state.start_time
            if last_activity is not None:
                idle_seconds = time.time() - last_activity

        # Determine target power state
        if idle_seconds is None or idle_seconds < POWER_IDLE_THRESHOLD:
            target_state = POWER_STATE_ACTIVE
        elif idle_seconds < POWER_SLEEP_THRESHOLD:
            target_state = POWER_STATE_IDLE
        elif idle_seconds < POWER_DEEP_SLEEP_THRESHOLD:
            target_state = POWER_STATE_SLEEP
        else:
            target_state = POWER_STATE_DEEP_SLEEP

        # Handle state transition
        if daemon_state is not None:
            old_state = daemon_state.power_state
            if old_state != target_state:
                self._on_power_transition(daemon_state, old_state, target_state)

        # Run phases based on target state
        if target_state == POWER_STATE_ACTIVE:
            self.run_background_cycle()
            return target_state, base_interval

        if target_state == POWER_STATE_IDLE:
            self._bg_recover_stuck_data()  # Phase 1
            self._bg_recover_stale_sessions()  # Phase 2
            self._bg_index_and_title()  # Phase 5 (lightweight, no LLM)
            return target_state, base_interval

        if target_state == POWER_STATE_SLEEP:
            self._bg_recover_stuck_data()  # Phase 1
            self._bg_index_and_title()  # Phase 5 (lightweight, no LLM)
            return target_state, POWER_SLEEP_INTERVAL

        # DEEP_SLEEP: run nothing, do not reschedule
        return target_state, 0

    def _on_power_transition(
        self,
        daemon_state: "DaemonState",
        old_state: str,
        new_state: str,
    ) -> None:
        """Handle power state transitions with logging and side effects.

        Side effects on entry:
        - SLEEP / DEEP_SLEEP: trigger a backup.
        - DEEP_SLEEP: stop file watcher.
        - ACTIVE (from DEEP_SLEEP): start file watcher.

        Args:
            daemon_state: The current daemon state instance.
            old_state: The power state being left.
            new_state: The power state being entered.
        """
        import time

        last_activity = daemon_state.last_hook_activity or daemon_state.start_time
        idle_seconds = (time.time() - last_activity) if last_activity else 0.0

        logger.info(f"Power state: {old_state} -> {new_state} (idle {idle_seconds:.0f}s)")
        daemon_state.power_state = new_state

        # Trigger backup when entering sleep states
        if new_state in (POWER_STATE_SLEEP, POWER_STATE_DEEP_SLEEP):
            self._trigger_transition_backup(daemon_state)

        # Stop file watcher on entry to deep sleep
        if new_state == POWER_STATE_DEEP_SLEEP and daemon_state.file_watcher:
            daemon_state.file_watcher.stop()

        # Restart file watcher when waking from deep sleep
        if (
            new_state == POWER_STATE_ACTIVE
            and old_state == POWER_STATE_DEEP_SLEEP
            and daemon_state.file_watcher
        ):
            daemon_state.file_watcher.start()

    def _trigger_transition_backup(self, daemon_state: "DaemonState") -> None:
        """Trigger a backup on power state transition (entering sleep/deep_sleep).

        Reuses the existing activity_store when available to avoid opening
        a second connection to the same SQLite database.

        Args:
            daemon_state: The current daemon state instance.
        """
        from ..store.backup import create_backup

        try:
            config = daemon_state.ci_config
            if not config or not config.backup.auto_enabled:
                return

            if not daemon_state.project_root:
                return

            from open_agent_kit.config.paths import OAK_DIR
            from open_agent_kit.features.codebase_intelligence.constants import (
                CI_ACTIVITIES_DB_FILENAME,
                CI_DATA_DIR,
            )

            db_path = daemon_state.project_root / OAK_DIR / CI_DATA_DIR / CI_ACTIVITIES_DB_FILENAME
            if not db_path.exists():
                return

            result = create_backup(
                project_root=daemon_state.project_root,
                db_path=db_path,
                activity_store=self.activity_store,
            )
            if result and result.success:
                logger.info(f"Transition backup created: {result.record_count} records")
        except Exception:
            logger.exception("Failed to create transition backup")

    def rebuild_chromadb_from_sqlite(
        self,
        batch_size: int = 50,
        reset_embedded_flags: bool = True,
        clear_chromadb_first: bool = False,
    ) -> dict[str, int]:
        """Rebuild ChromaDB memory index from SQLite source of truth.

        Args:
            batch_size: Number of observations to process per batch.
            reset_embedded_flags: If True, marks ALL observations as unembedded first.
            clear_chromadb_first: If True, clears ChromaDB memory collection first
                to remove orphaned entries before rebuilding.
        """
        return rebuild_chromadb_from_sqlite(
            activity_store=self.activity_store,
            vector_store=self.vector_store,
            batch_size=batch_size,
            reset_embedded_flags=reset_embedded_flags,
            clear_chromadb_first=clear_chromadb_first,
        )

    def embed_pending_observations(self, batch_size: int = 50) -> dict[str, int]:
        """Embed observations that are in SQLite but not yet in ChromaDB."""
        return embed_pending_observations(
            activity_store=self.activity_store,
            vector_store=self.vector_store,
            batch_size=batch_size,
        )

    def index_pending_plans(self, batch_size: int = 10) -> dict[str, int]:
        """Index plans that haven't been embedded in ChromaDB yet."""
        return index_pending_plans(
            activity_store=self.activity_store,
            vector_store=self.vector_store,
            batch_size=batch_size,
        )

    def _extract_plan_title(self, batch: Any) -> str:
        """Extract plan title from filename or first heading."""
        from open_agent_kit.features.codebase_intelligence.activity.processor.indexing import (
            extract_plan_title,
        )

        return extract_plan_title(batch)

    def rebuild_plan_index(self, batch_size: int = 50) -> dict[str, int]:
        """Rebuild ChromaDB plan index from SQLite source of truth."""
        return rebuild_plan_index(
            activity_store=self.activity_store,
            vector_store=self.vector_store,
            batch_size=batch_size,
        )

    def promote_agent_batch(self, batch_id: int) -> ProcessingResult:
        """Promote an agent batch to extract memories using user-style processing.

        This forces full LLM extraction on batches that were previously skipped
        (agent_notification, system). Useful for manually promoting valuable
        findings from background agent work to the memory store.

        Args:
            batch_id: Prompt batch ID to promote.

        Returns:
            ProcessingResult with extracted observations.
        """
        start_time = datetime.now()

        # Get the batch
        batch = self.activity_store.get_prompt_batch(batch_id)
        if not batch:
            return ProcessingResult(
                session_id="",
                activities_processed=0,
                observations_extracted=0,
                success=False,
                error=f"Prompt batch {batch_id} not found",
                prompt_batch_id=batch_id,
            )

        # Check if batch is promotable (agent_notification or system)
        source_type = batch.source_type or PROMPT_SOURCE_USER
        if source_type == PROMPT_SOURCE_USER:
            return ProcessingResult(
                session_id=batch.session_id,
                activities_processed=0,
                observations_extracted=0,
                success=False,
                error="User batches don't need promotion - already processed with LLM extraction",
                prompt_batch_id=batch_id,
            )

        if source_type == PROMPT_SOURCE_PLAN:
            return ProcessingResult(
                session_id=batch.session_id,
                activities_processed=0,
                observations_extracted=0,
                success=False,
                error="Plan batches have specialized processing - use reprocess instead",
                prompt_batch_id=batch_id,
            )

        # Check if summarizer is available
        if not self.summarizer:
            return ProcessingResult(
                session_id=batch.session_id,
                activities_processed=0,
                observations_extracted=0,
                success=False,
                error="No summarizer configured for memory extraction",
                prompt_batch_id=batch_id,
            )

        # Get activities for this batch
        activities = self.activity_store.get_prompt_batch_activities(batch_id)
        if not activities:
            return ProcessingResult(
                session_id=batch.session_id,
                activities_processed=0,
                observations_extracted=0,
                success=True,
                error="No activities to process",
                prompt_batch_id=batch_id,
            )

        logger.info(
            f"Promoting agent batch {batch_id} (source={source_type}): "
            f"{len(activities)} activities for LLM extraction"
        )

        try:
            # Force user-style processing (full LLM extraction)
            result = self._process_user_batch(batch_id, batch, activities, start_time)

            # Update classification to indicate promotion
            if result.success:
                promoted_classification = f"promoted_{result.classification or 'unknown'}"
                self.activity_store.mark_prompt_batch_processed(
                    batch_id, classification=promoted_classification
                )
                result.classification = promoted_classification

                logger.info(
                    f"Promoted agent batch {batch_id}: {result.observations_extracted} "
                    f"observations extracted ({result.duration_ms}ms)"
                )

            return result

        except (
            OSError,
            json.JSONDecodeError,
            ValueError,
            TypeError,
            KeyError,
            AttributeError,
        ) as e:
            logger.error(f"Error promoting agent batch {batch_id}: {e}", exc_info=True)
            return ProcessingResult(
                session_id=batch.session_id,
                activities_processed=len(activities),
                observations_extracted=0,
                success=False,
                error=str(e),
                duration_ms=int((datetime.now() - start_time).total_seconds() * 1000),
                prompt_batch_id=batch_id,
            )


# Async wrappers for use with FastAPI


async def process_session_async(
    processor: ActivityProcessor,
    session_id: str,
) -> ProcessingResult:
    """Process a session asynchronously.

    Args:
        processor: Activity processor instance.
        session_id: Session to process.

    Returns:
        ProcessingResult.
    """
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(None, processor.process_session, session_id)


async def process_prompt_batch_async(
    processor: ActivityProcessor,
    batch_id: int,
) -> ProcessingResult:
    """Process a prompt batch asynchronously.

    This is the preferred processing method - processes activities from a
    single user prompt as one coherent unit.

    Args:
        processor: Activity processor instance.
        batch_id: Prompt batch ID to process.

    Returns:
        ProcessingResult.
    """
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(None, processor.process_prompt_batch, batch_id)


async def promote_agent_batch_async(
    processor: ActivityProcessor,
    batch_id: int,
) -> ProcessingResult:
    """Promote an agent batch to extract memories asynchronously.

    This forces user-style LLM extraction on batches that were previously
    skipped (agent_notification, system). Useful for promoting valuable
    findings from background agent work.

    Args:
        processor: Activity processor instance.
        batch_id: Prompt batch ID to promote.

    Returns:
        ProcessingResult with extracted observations.
    """
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(None, processor.promote_agent_batch, batch_id)
