"""Core ActivityStore class for activity store.

Contains the main ActivityStore class with connection management and delegation
to operation modules.
"""

import logging
import sqlite3
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any

from open_agent_kit.features.codebase_intelligence.activity.store import (
    activities,
    agent_runs,
    backup,
    batches,
    delete,
    observations,
    schedules,
    sessions,
    stats,
)
from open_agent_kit.features.codebase_intelligence.activity.store.migrations import (
    apply_migrations,
)
from open_agent_kit.features.codebase_intelligence.activity.store.models import (
    Activity,
    PromptBatch,
    Session,
    StoredObservation,
)
from open_agent_kit.features.codebase_intelligence.activity.store.schema import (
    SCHEMA_SQL,
    SCHEMA_VERSION,
)

logger = logging.getLogger(__name__)


class ActivityStore:
    """SQLite-based store for session activities.

    Thread-safe activity logging with FTS5 full-text search.
    Designed for high-volume append operations during sessions.
    """

    def __init__(self, db_path: Path, machine_id: str):
        """Initialize the activity store.

        Args:
            db_path: Path to SQLite database file.
            machine_id: Deterministic machine identifier for this instance.
                Resolved once at the composition root and injected here.
        """
        self.db_path = db_path
        self.machine_id = machine_id
        self._local = threading.local()
        # Cache for stats queries (low TTL for near real-time debugging)
        # Format: {cache_key: (data, timestamp)}
        self._stats_cache: dict[str, tuple[dict[str, Any], float]] = {}
        self._cache_ttl = 5.0  # 5 seconds TTL for near real-time debugging
        self._cache_lock = threading.Lock()
        # Activity batching buffer for bulk inserts
        self._activity_buffer: list[Activity] = []
        self._buffer_lock = threading.Lock()
        self._buffer_size = 10  # Flush when buffer reaches this size
        self._ensure_schema()

    def _get_connection(self) -> sqlite3.Connection:
        """Get thread-local database connection."""
        if not hasattr(self._local, "conn") or self._local.conn is None:
            self._local.conn = sqlite3.connect(
                str(self.db_path),
                check_same_thread=False,
                timeout=30.0,
            )
            self._local.conn.row_factory = sqlite3.Row
            # Enable WAL mode for better concurrent performance
            self._local.conn.execute("PRAGMA journal_mode=WAL")
            self._local.conn.execute("PRAGMA synchronous=NORMAL")
            # Performance PRAGMAs for better query performance
            # foreign_keys: Enforce referential integrity (data integrity)
            self._local.conn.execute("PRAGMA foreign_keys = ON")
            # cache_size: 64MB cache (default is 2MB) - 10-50x faster for repeated queries
            # Negative value means KB, so -64000 = 64MB
            self._local.conn.execute("PRAGMA cache_size = -64000")
            # temp_store: Use RAM for temporary tables (reduces disk I/O)
            self._local.conn.execute("PRAGMA temp_store = MEMORY")
            # mmap_size: 256MB memory-mapped I/O (2-5x faster reads for large databases)
            self._local.conn.execute("PRAGMA mmap_size = 268435456")
        conn: sqlite3.Connection = self._local.conn
        return conn

    @contextmanager
    def _transaction(self) -> Iterator[sqlite3.Connection]:
        """Context manager for database transactions."""
        conn = self._get_connection()
        try:
            yield conn
            conn.commit()
        except sqlite3.Error as e:
            conn.rollback()
            logger.error(f"Database transaction error: {e}", exc_info=True)
            raise

    def _ensure_schema(self) -> None:
        """Create database schema if needed, applying migrations for existing databases."""
        self.db_path.parent.mkdir(parents=True, exist_ok=True)

        with self._transaction() as conn:
            # Check current schema version
            try:
                cursor = conn.execute("SELECT version FROM schema_version LIMIT 1")
                row = cursor.fetchone()
                current_version = row["version"] if row else 0
            except sqlite3.OperationalError:
                current_version = 0

            if current_version < SCHEMA_VERSION:
                if current_version == 0:
                    # Fresh database - apply full schema
                    conn.executescript(SCHEMA_SQL)
                else:
                    # Existing database - apply migrations
                    apply_migrations(conn, current_version)

                conn.execute(
                    "INSERT OR REPLACE INTO schema_version (version) VALUES (?)",
                    (SCHEMA_VERSION,),
                )
                logger.info(f"Activity store schema initialized (v{SCHEMA_VERSION})")

    def get_schema_version(self) -> int:
        """Get current database schema version.

        Returns:
            Schema version number, or 0 if schema_version table doesn't exist.
        """
        try:
            cursor = self._get_connection().execute(
                "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
            )
            row = cursor.fetchone()
            return row[0] if row else 0
        except sqlite3.OperationalError:
            return 0

    def optimize_database(self) -> None:
        """Run database optimization (VACUUM + ANALYZE + FTS optimize).

        This should be called periodically (weekly/monthly) or after large deletions
        to maintain performance and reclaim space.

        Note: VACUUM can be slow for large databases. Consider running in background.
        """
        logger.info("Starting database optimization (VACUUM + ANALYZE + FTS optimize)...")

        conn = self._get_connection()

        try:
            # Analyze to update query planner statistics
            conn.execute("ANALYZE")
            logger.debug("Database optimization: ANALYZE complete")

            # Optimize FTS index
            conn.execute("INSERT INTO activities_fts(activities_fts) VALUES('optimize')")
            logger.debug("Database optimization: FTS optimize complete")

            # Vacuum to reclaim space and defragment
            # Note: VACUUM requires exclusive lock, can be slow
            conn.execute("VACUUM")
            logger.info("Database optimization complete (VACUUM + ANALYZE + FTS optimize)")
        except sqlite3.Error as e:
            logger.error(f"Database optimization error: {e}", exc_info=True)
            raise

    # Cache helpers (exposed for operation modules)
    def _invalidate_stats_cache(self, session_id: str | None = None) -> None:
        """Invalidate stats cache."""
        stats.invalidate_stats_cache(self, session_id)

    def close(self) -> None:
        """Close database connection."""
        if hasattr(self._local, "conn") and self._local.conn:
            self._local.conn.close()
            self._local.conn = None

    # ==========================================================================
    # Read-only SQL query execution (for analysis agents)
    # ==========================================================================

    def execute_readonly_query(
        self,
        sql: str,
        params: tuple[Any, ...] | None = None,
        limit: int = 100,
    ) -> tuple[list[str], list[tuple[Any, ...]]]:
        """Execute a read-only SQL query and return results.

        Opens a separate read-only connection to prevent any writes.
        The query is executed with a LIMIT clause if not already present.

        Args:
            sql: SQL query string (must be SELECT or WITH).
            params: Optional query parameters.
            limit: Maximum rows to return.

        Returns:
            Tuple of (column_names, rows).

        Raises:
            ValueError: If the SQL is not a read-only statement.
            sqlite3.Error: If the query fails.
        """
        from open_agent_kit.features.codebase_intelligence.constants import (
            CI_QUERY_FORBIDDEN_KEYWORDS,
            CI_QUERY_MAX_ROWS,
        )

        # Validate SQL is read-only
        normalized = sql.strip().upper()
        if not (
            normalized.startswith("SELECT")
            or normalized.startswith("WITH")
            or normalized.startswith("EXPLAIN")
        ):
            raise ValueError(
                "Only SELECT, WITH, and EXPLAIN statements are allowed. "
                "Use MCP tools (oak_remember, etc.) for write operations."
            )

        for keyword in CI_QUERY_FORBIDDEN_KEYWORDS:
            # Check for keyword as a standalone word (not inside a string literal)
            # Simple check: keyword at word boundary in the normalized SQL
            if f" {keyword} " in f" {normalized} ":
                raise ValueError(
                    f"Forbidden keyword '{keyword}' detected. "
                    f"Only read-only queries are allowed."
                )

        # Clamp limit
        effective_limit = min(limit, CI_QUERY_MAX_ROWS)

        # Open a read-only connection (separate from the main connection)
        conn = sqlite3.connect(
            f"file:{self.db_path}?mode=ro",
            uri=True,
            timeout=10.0,
        )
        conn.row_factory = sqlite3.Row
        try:
            # Apply limit if not already in query
            query = sql.strip().rstrip(";")
            if "LIMIT" not in normalized:
                query = f"{query} LIMIT {effective_limit}"

            cursor = conn.execute(query, params or ())
            columns = [desc[0] for desc in cursor.description] if cursor.description else []
            rows = [tuple(row) for row in cursor.fetchmany(effective_limit)]
            return columns, rows
        finally:
            conn.close()

    # ==========================================================================
    # Session operations - delegate to sessions module
    # ==========================================================================

    def create_session(self, session_id: str, agent: str, project_root: str) -> Session:
        """Create a new session record."""
        return sessions.create_session(self, session_id, agent, project_root)

    def get_session(self, session_id: str) -> Session | None:
        """Get session by ID."""
        return sessions.get_session(self, session_id)

    def get_or_create_session(
        self, session_id: str, agent: str, project_root: str
    ) -> tuple[Session, bool]:
        """Get existing session or create new one."""
        return sessions.get_or_create_session(self, session_id, agent, project_root)

    def end_session(self, session_id: str, summary: str | None = None) -> None:
        """Mark session as completed."""
        sessions.end_session(self, session_id, summary)

    def update_session_title(self, session_id: str, title: str) -> None:
        """Update the session title."""
        sessions.update_session_title(self, session_id, title)

    def update_session_summary(self, session_id: str, summary: str) -> None:
        """Update the session summary."""
        sessions.update_session_summary(self, session_id, summary)

    def update_session_transcript_path(self, session_id: str, transcript_path: str) -> None:
        """Store the transcript file path for a session."""
        sessions.update_session_transcript_path(self, session_id, transcript_path)

    def reactivate_session_if_needed(self, session_id: str) -> bool:
        """Reactivate a session if it's currently completed."""
        return sessions.reactivate_session_if_needed(self, session_id)

    def _ensure_session_exists(self, session_id: str, agent: str) -> bool:
        """Create session if it doesn't exist."""
        return sessions.ensure_session_exists(self, session_id, agent)

    def increment_prompt_count(self, session_id: str) -> None:
        """Increment the prompt count for a session."""
        sessions.increment_prompt_count(self, session_id)

    def get_unprocessed_sessions(self, limit: int = 10) -> list[Session]:
        """Get sessions that haven't been processed yet."""
        return sessions.get_unprocessed_sessions(self, limit)

    def mark_session_processed(self, session_id: str) -> None:
        """Mark session as processed by background worker."""
        sessions.mark_session_processed(self, session_id)

    def get_recent_sessions(
        self,
        limit: int = 10,
        offset: int = 0,
        status: str | None = None,
        agent: str | None = None,
        sort: str = "last_activity",
    ) -> list[Session]:
        """Get recent sessions with pagination support."""
        return sessions.get_recent_sessions(self, limit, offset, status, agent, sort)

    def get_sessions_needing_titles(self, limit: int = 10) -> list[Session]:
        """Get sessions that need titles generated."""
        return sessions.get_sessions_needing_titles(self, limit)

    def get_sessions_missing_summaries(
        self, limit: int = 10, min_activities: int | None = None
    ) -> list[Session]:
        """Get completed sessions missing session summaries."""
        return sessions.get_sessions_missing_summaries(self, limit, min_activities)

    def recover_stale_sessions(
        self,
        timeout_seconds: int = 3600,
        min_activities: int | None = None,
        vector_store: Any | None = None,
    ) -> tuple[list[str], list[str]]:
        """Auto-end or delete sessions that have been inactive for too long."""
        return sessions.recover_stale_sessions(
            self, timeout_seconds, min_activities, vector_store=vector_store
        )

    def cleanup_low_quality_sessions(
        self,
        vector_store: Any | None = None,
        min_activities: int | None = None,
    ) -> list[str]:
        """Delete completed sessions that don't meet the quality threshold."""
        return sessions.cleanup_low_quality_sessions(
            self, vector_store=vector_store, min_activities=min_activities
        )

    def is_suggestion_dismissed(self, session_id: str) -> bool:
        """Check whether the suggested-parent suggestion was dismissed."""
        return sessions.is_suggestion_dismissed(self, session_id)

    def count_sessions_with_summaries(self) -> int:
        """Count sessions that have a session_summary observation."""
        return sessions.count_sessions_with_summaries(self)

    # ==========================================================================
    # Prompt batch operations - delegate to batches module
    # ==========================================================================

    def create_prompt_batch(
        self,
        session_id: str,
        user_prompt: str | None = None,
        source_type: str = "user",
        plan_file_path: str | None = None,
        plan_content: str | None = None,
        agent: str | None = None,
    ) -> PromptBatch:
        """Create a new prompt batch."""
        return batches.create_prompt_batch(
            self, session_id, user_prompt, source_type, plan_file_path, plan_content, agent
        )

    def get_prompt_batch(self, batch_id: int) -> PromptBatch | None:
        """Get prompt batch by ID."""
        return batches.get_prompt_batch(self, batch_id)

    def get_active_prompt_batch(self, session_id: str) -> PromptBatch | None:
        """Get the current active prompt batch for a session."""
        return batches.get_active_prompt_batch(self, session_id)

    def get_session_plan_batch(
        self, session_id: str, plan_file_path: str | None = None
    ) -> PromptBatch | None:
        """Get the most recent plan batch in the current session."""
        return batches.get_session_plan_batch(self, session_id, plan_file_path)

    def end_prompt_batch(self, batch_id: int) -> None:
        """Mark a prompt batch as completed."""
        batches.end_prompt_batch(self, batch_id)

    def reactivate_prompt_batch(self, batch_id: int) -> None:
        """Reactivate a completed prompt batch (when tool activity continues)."""
        batches.reactivate_prompt_batch(self, batch_id)

    def get_unprocessed_prompt_batches(self, limit: int = 10) -> list[PromptBatch]:
        """Get prompt batches that haven't been processed yet."""
        return batches.get_unprocessed_prompt_batches(self, limit)

    def mark_prompt_batch_processed(self, batch_id: int, classification: str | None = None) -> None:
        """Mark prompt batch as processed."""
        batches.mark_prompt_batch_processed(self, batch_id, classification)

    def update_prompt_batch_source_type(
        self,
        batch_id: int,
        source_type: str,
        plan_file_path: str | None = None,
        plan_content: str | None = None,
    ) -> None:
        """Update the source type for a prompt batch."""
        batches.update_prompt_batch_source_type(
            self, batch_id, source_type, plan_file_path, plan_content
        )

    def get_session_prompt_batches(
        self, session_id: str, limit: int | None = None
    ) -> list[PromptBatch]:
        """Get all prompt batches for a session."""
        return batches.get_session_prompt_batches(self, session_id, limit)

    def get_plans(
        self,
        limit: int = 50,
        offset: int = 0,
        session_id: str | None = None,
        deduplicate: bool = True,
        sort: str = "created",
    ) -> tuple[list[PromptBatch], int]:
        """Get plan batches from prompt_batches table."""
        return batches.get_plans(self, limit, offset, session_id, deduplicate, sort)

    def recover_stuck_batches(
        self, timeout_seconds: int = 1800, project_root: str | None = None
    ) -> int:
        """Auto-end batches stuck in 'active' status for too long."""
        return batches.recover_stuck_batches(self, timeout_seconds, project_root)

    def recover_orphaned_activities(self) -> int:
        """Associate orphaned activities with appropriate batches."""
        return batches.recover_orphaned_activities(self)

    def queue_batches_for_reprocessing(
        self,
        batch_ids: list[int] | None = None,
        recover_stuck: bool = True,
    ) -> tuple[int, int]:
        """Recover stuck batches and reset processed flag for reprocessing."""
        return batches.queue_batches_for_reprocessing(self, batch_ids, recover_stuck)

    def get_prompt_batch_activities(
        self, batch_id: int, limit: int | None = None
    ) -> list[Activity]:
        """Get all activities for a prompt batch."""
        return batches.get_prompt_batch_activities(self, batch_id, limit)

    def get_prompt_batch_stats(self, batch_id: int) -> dict[str, Any]:
        """Get statistics for a prompt batch."""
        return batches.get_prompt_batch_stats(self, batch_id)

    def get_unembedded_plans(self, limit: int = 50) -> list[PromptBatch]:
        """Get plan batches that haven't been embedded in ChromaDB yet."""
        return batches.get_unembedded_plans(self, limit)

    def mark_plan_embedded(self, batch_id: int) -> None:
        """Mark a plan batch as embedded in ChromaDB."""
        batches.mark_plan_embedded(self, batch_id)

    def mark_plan_unembedded(self, batch_id: int) -> None:
        """Mark a plan batch as not embedded in ChromaDB."""
        batches.mark_plan_unembedded(self, batch_id)

    def count_unembedded_plans(self) -> int:
        """Count plan batches not yet in ChromaDB."""
        return batches.count_unembedded_plans(self)

    def count_embedded_plans(self) -> int:
        """Count plan batches that are in ChromaDB."""
        return batches.count_embedded_plans(self)

    def mark_all_plans_unembedded(self) -> int:
        """Mark all plans as not embedded."""
        return batches.mark_all_plans_unembedded(self)

    def update_prompt_batch_response(self, batch_id: int, response_summary: str) -> None:
        """Update a prompt batch with the agent's response summary."""
        batches.update_prompt_batch_response(self, batch_id, response_summary)

    # ==========================================================================
    # Activity operations - delegate to activities module
    # ==========================================================================

    def add_activity(self, activity: Activity) -> int:
        """Add a tool execution activity."""
        return activities.add_activity(self, activity)

    def flush_activity_buffer(self) -> list[int]:
        """Flush any buffered activities to the database."""
        return activities.flush_activity_buffer(self)

    def add_activity_buffered(self, activity: Activity, force_flush: bool = False) -> int | None:
        """Add an activity with automatic batching."""
        return activities.add_activity_buffered(self, activity, force_flush)

    def add_activities(self, activity_list: list[Activity]) -> list[int]:
        """Add multiple activities in a single transaction."""
        return activities.add_activities(self, activity_list)

    def get_session_activities(
        self, session_id: str, tool_name: str | None = None, limit: int | None = None
    ) -> list[Activity]:
        """Get activities for a session."""
        return activities.get_session_activities(self, session_id, tool_name, limit)

    def get_unprocessed_activities(
        self, session_id: str | None = None, limit: int = 100
    ) -> list[Activity]:
        """Get activities that haven't been processed yet."""
        return activities.get_unprocessed_activities(self, session_id, limit)

    def mark_activities_processed(
        self, activity_ids: list[int], observation_id: str | None = None
    ) -> None:
        """Mark activities as processed."""
        activities.mark_activities_processed(self, activity_ids, observation_id)

    def search_activities(
        self, query: str, session_id: str | None = None, limit: int = 20
    ) -> list[Activity]:
        """Full-text search across activities."""
        return activities.search_activities(self, query, session_id, limit)

    # ==========================================================================
    # Observation operations - delegate to observations module
    # ==========================================================================

    def store_observation(self, observation: StoredObservation) -> str:
        """Store a memory observation in SQLite."""
        return observations.store_observation(self, observation)

    def get_observation(self, observation_id: str) -> StoredObservation | None:
        """Get an observation by ID."""
        return observations.get_observation(self, observation_id)

    def get_latest_session_summary(self, session_id: str) -> StoredObservation | None:
        """Get the most recent session_summary observation for a session."""
        return observations.get_latest_session_summary(self, session_id)

    def get_unembedded_observations(self, limit: int = 100) -> list[StoredObservation]:
        """Get observations that haven't been added to ChromaDB."""
        return observations.get_unembedded_observations(self, limit)

    def mark_observation_embedded(self, observation_id: str) -> None:
        """Mark an observation as embedded in ChromaDB."""
        observations.mark_observation_embedded(self, observation_id)

    def mark_observations_embedded(self, observation_ids: list[str]) -> None:
        """Mark multiple observations as embedded in ChromaDB."""
        observations.mark_observations_embedded(self, observation_ids)

    def mark_all_observations_unembedded(self) -> int:
        """Mark all observations as not embedded."""
        return observations.mark_all_observations_unembedded(self)

    def count_observations(self) -> int:
        """Count total observations in SQLite."""
        return observations.count_observations(self)

    def count_embedded_observations(self) -> int:
        """Count observations that are in ChromaDB."""
        return observations.count_embedded_observations(self)

    def count_unembedded_observations(self) -> int:
        """Count observations not yet in ChromaDB."""
        return observations.count_unembedded_observations(self)

    def list_session_summaries(self, limit: int = 10) -> list[StoredObservation]:
        """List recent session_summary observations."""
        return observations.list_session_summaries(self, limit)

    def count_observations_by_type(self, memory_type: str) -> int:
        """Count observations by memory_type."""
        return observations.count_observations_by_type(self, memory_type)

    # ==========================================================================
    # Statistics operations - delegate to stats module
    # ==========================================================================

    def get_session_stats(self, session_id: str) -> dict[str, Any]:
        """Get statistics for a session."""
        return stats.get_session_stats(self, session_id)

    def get_bulk_session_stats(self, session_ids: list[str]) -> dict[str, dict[str, Any]]:
        """Get statistics for multiple sessions."""
        return stats.get_bulk_session_stats(self, session_ids)

    def get_bulk_first_prompts(
        self, session_ids: list[str], max_length: int = 100
    ) -> dict[str, str | None]:
        """Get the first user prompt preview for multiple sessions."""
        return stats.get_bulk_first_prompts(self, session_ids, max_length)

    # ==========================================================================
    # Backup operations - delegate to backup module
    # ==========================================================================

    def export_to_sql(self, output_path: Path, include_activities: bool = False) -> int:
        """Export valuable tables to SQL dump file."""
        return backup.export_to_sql(self, output_path, include_activities)

    def import_from_sql(self, backup_path: Path) -> int:
        """Import data from SQL backup into existing database."""
        return backup.import_from_sql(self, backup_path)

    def import_from_sql_with_dedup(
        self, backup_path: Path, dry_run: bool = False
    ) -> backup.ImportResult:
        """Import data from SQL backup with deduplication."""
        return backup.import_from_sql_with_dedup(self, backup_path, dry_run)

    def restore_all_backups(
        self, backup_dir: Path, dry_run: bool = False
    ) -> dict[str, backup.ImportResult]:
        """Restore from all backup files in directory."""
        return backup.restore_all_backups(self, backup_dir, dry_run)

    # ==========================================================================
    # Hash retrieval for deduplication
    # ==========================================================================

    def get_all_session_ids(self) -> set[str]:
        """Get all session IDs for dedup checking during import."""
        conn = self._get_connection()
        cursor = conn.execute("SELECT id FROM sessions")
        return {row[0] for row in cursor.fetchall()}

    def get_all_prompt_batch_hashes(self) -> set[str]:
        """Get all prompt_batch content_hash values for dedup checking.

        Falls back to computing hashes if content_hash column is empty.
        """
        conn = self._get_connection()

        # First try to get existing hashes
        cursor = conn.execute(
            "SELECT content_hash FROM prompt_batches WHERE content_hash IS NOT NULL"
        )
        hashes = {row[0] for row in cursor.fetchall()}

        # For records without hashes, compute them
        cursor = conn.execute(
            "SELECT session_id, prompt_number FROM prompt_batches WHERE content_hash IS NULL"
        )
        for row in cursor.fetchall():
            computed_hash = backup.compute_prompt_batch_hash(str(row[0]), int(row[1]))
            hashes.add(computed_hash)

        return hashes

    def get_all_observation_hashes(self) -> set[str]:
        """Get all memory_observation content_hash values for dedup checking.

        Falls back to computing hashes if content_hash column is empty.
        """
        conn = self._get_connection()

        # First try to get existing hashes
        cursor = conn.execute(
            "SELECT content_hash FROM memory_observations WHERE content_hash IS NOT NULL"
        )
        hashes = {row[0] for row in cursor.fetchall()}

        # For records without hashes, compute them
        cursor = conn.execute(
            "SELECT observation, memory_type, context FROM memory_observations "
            "WHERE content_hash IS NULL"
        )
        for row in cursor.fetchall():
            computed_hash = backup.compute_observation_hash(str(row[0]), str(row[1]), row[2])
            hashes.add(computed_hash)

        return hashes

    def get_all_activity_hashes(self) -> set[str]:
        """Get all activity content_hash values for dedup checking.

        Falls back to computing hashes if content_hash column is empty.
        """
        conn = self._get_connection()

        # First try to get existing hashes
        cursor = conn.execute("SELECT content_hash FROM activities WHERE content_hash IS NOT NULL")
        hashes = {row[0] for row in cursor.fetchall()}

        # For records without hashes, compute them
        cursor = conn.execute(
            "SELECT session_id, timestamp_epoch, tool_name FROM activities "
            "WHERE content_hash IS NULL"
        )
        for row in cursor.fetchall():
            computed_hash = backup.compute_activity_hash(str(row[0]), int(row[1]), str(row[2]))
            hashes.add(computed_hash)

        return hashes

    # ==========================================================================
    # Delete operations - delegate to delete module
    # ==========================================================================

    def get_session_observation_ids(self, session_id: str) -> list[str]:
        """Get all observation IDs for a session."""
        return delete.get_session_observation_ids(self, session_id)

    def get_batch_observation_ids(self, batch_id: int) -> list[str]:
        """Get all observation IDs for a prompt batch."""
        return delete.get_batch_observation_ids(self, batch_id)

    def delete_observation(self, observation_id: str) -> bool:
        """Delete an observation from SQLite."""
        return delete.delete_observation(self, observation_id)

    def delete_activity(self, activity_id: int) -> str | None:
        """Delete a single activity."""
        return delete.delete_activity(self, activity_id)

    def delete_prompt_batch(self, batch_id: int) -> dict[str, int]:
        """Delete a prompt batch and all related data."""
        return delete.delete_prompt_batch(self, batch_id)

    def delete_session(self, session_id: str) -> dict[str, int]:
        """Delete a session and all related data."""
        return delete.delete_session(self, session_id)

    # ==========================================================================
    # Agent run operations - delegate to agent_runs module
    # ==========================================================================

    def create_agent_run(
        self,
        run_id: str,
        agent_name: str,
        task: str,
        status: str = "pending",
        project_config: dict[str, Any] | None = None,
        system_prompt_hash: str | None = None,
    ) -> None:
        """Create a new agent run record."""
        agent_runs.create_run(
            self, run_id, agent_name, task, status, project_config, system_prompt_hash
        )

    def get_agent_run(self, run_id: str) -> dict[str, Any] | None:
        """Get an agent run by ID."""
        return agent_runs.get_run(self, run_id)

    def update_agent_run(
        self,
        run_id: str,
        status: str | None = None,
        started_at: Any | None = None,
        completed_at: Any | None = None,
        result: str | None = None,
        error: str | None = None,
        turns_used: int | None = None,
        cost_usd: float | None = None,
        input_tokens: int | None = None,
        output_tokens: int | None = None,
        files_created: list[str] | None = None,
        files_modified: list[str] | None = None,
        files_deleted: list[str] | None = None,
        warnings: list[str] | None = None,
    ) -> None:
        """Update an agent run record."""
        agent_runs.update_run(
            self,
            run_id,
            status,
            started_at,
            completed_at,
            result,
            error,
            turns_used,
            cost_usd,
            input_tokens,
            output_tokens,
            files_created,
            files_modified,
            files_deleted,
            warnings,
        )

    def list_agent_runs(
        self,
        limit: int = 20,
        offset: int = 0,
        agent_name: str | None = None,
        status: str | None = None,
        created_after_epoch: int | None = None,
        created_before_epoch: int | None = None,
        sort_by: str = "created_at",
        sort_order: str = "desc",
    ) -> tuple[list[dict[str, Any]], int]:
        """List agent runs with optional filtering and sorting."""
        return agent_runs.list_runs(
            self,
            limit,
            offset,
            agent_name,
            status,
            created_after_epoch,
            created_before_epoch,
            sort_by,
            sort_order,
        )

    def delete_agent_run(self, run_id: str) -> bool:
        """Delete an agent run."""
        return agent_runs.delete_run(self, run_id)

    def bulk_delete_agent_runs(
        self,
        agent_name: str | None = None,
        status: str | None = None,
        before_epoch: int | None = None,
        keep_recent: int = 10,
    ) -> int:
        """Bulk delete agent runs with retention policy."""
        return agent_runs.bulk_delete_runs(self, agent_name, status, before_epoch, keep_recent)

    def recover_stale_runs(
        self,
        buffer_seconds: int = 300,
        default_timeout_seconds: int = 600,
    ) -> list[str]:
        """Mark runs stuck in RUNNING status as FAILED.

        A run is considered stale if it has been running for longer than
        default_timeout + buffer seconds. This handles daemon crashes
        that leave runs in RUNNING state.

        Args:
            buffer_seconds: Grace period beyond expected timeout (default 5 min).
            default_timeout_seconds: Default timeout if not tracked per-run.

        Returns:
            List of recovered run IDs.
        """
        return agent_runs.recover_stale_runs(self, buffer_seconds, default_timeout_seconds)

    # ==========================================================================
    # Schedule operations - delegate to schedules module
    # ==========================================================================

    def create_schedule(
        self,
        task_name: str,
        cron_expression: str | None = None,
        description: str | None = None,
        trigger_type: str = "cron",
        next_run_at: Any | None = None,
    ) -> None:
        """Create a new schedule record."""
        schedules.create_schedule(
            self, task_name, cron_expression, description, trigger_type, next_run_at
        )

    def get_schedule(self, task_name: str) -> dict[str, Any] | None:
        """Get a schedule by task name."""
        return schedules.get_schedule(self, task_name)

    def update_schedule(
        self,
        task_name: str,
        enabled: bool | None = None,
        cron_expression: str | None = None,
        description: str | None = None,
        trigger_type: str | None = None,
        last_run_at: Any | None = None,
        last_run_id: str | None = None,
        next_run_at: Any | None = None,
    ) -> None:
        """Update a schedule record."""
        schedules.update_schedule(
            self,
            task_name,
            enabled,
            cron_expression,
            description,
            trigger_type,
            last_run_at,
            last_run_id,
            next_run_at,
        )

    def list_schedules(self, enabled_only: bool = False) -> list[dict[str, Any]]:
        """List all schedules."""
        return schedules.list_schedules(self, enabled_only)

    def get_due_schedules(self) -> list[dict[str, Any]]:
        """Get schedules that are due to run."""
        return schedules.get_due_schedules(self)

    def delete_schedule(self, task_name: str) -> bool:
        """Delete a schedule record."""
        return schedules.delete_schedule(self, task_name)

    def upsert_schedule(
        self,
        task_name: str,
        cron_expression: str | None = None,
        description: str | None = None,
        trigger_type: str = "cron",
        next_run_at: Any | None = None,
    ) -> None:
        """Create or update a schedule record."""
        schedules.upsert_schedule(
            self, task_name, cron_expression, description, trigger_type, next_run_at
        )

    def get_all_schedule_task_names(self) -> set[str]:
        """Get all schedule task names for dedup checking during import."""
        return schedules.get_all_schedule_task_names(self)
