"""Database migration runner for CleverAgents.

This module handles running Alembic migrations programmatically
to ensure database schema is up to date.
"""

import logging
from collections.abc import Callable
from pathlib import Path

from alembic import command
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine

from cleveragents.core.exceptions import MigrationNotApprovedError
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES


class MigrationRunner:
    """Handles running database migrations with Alembic."""

    def __init__(self, database_url: str) -> None:
        """Initialize migration runner.

        Args:
            database_url: SQLAlchemy database URL
        """
        self.database_url = database_url
        self._alembic_cfg: Config | None = None

    @staticmethod
    def _find_alembic_ini() -> Path:
        """Locate the ``alembic.ini`` configuration file.

        Searches for ``alembic.ini`` using two strategies:

        1. **Package-relative**: Walk upward from the top-level
           ``cleveragents`` package directory.  This is the most reliable
           anchor because ``cleveragents.__file__`` is always resolvable
           regardless of the current working directory.
        2. **Module-relative**: Walk upward from *this* source file as a
           fallback for non-standard layouts.

        Only the presence of ``alembic.ini`` is checked (not an
        ``alembic/`` sibling directory) because the ini file's own
        ``script_location`` setting already tells Alembic where to find
        the migration scripts.

        Raises:
            FileNotFoundError: If ``alembic.ini`` cannot be located.
        """
        search_roots: list[Path] = []

        # Strategy 1 - start from the cleveragents package root.
        # In an editable install this is e.g. /app/src/cleveragents/;
        # walking up two levels reaches /app/ where alembic.ini lives.
        try:
            import cleveragents as _pkg

            pkg_init = getattr(_pkg, "__file__", None)
            if pkg_init is not None:
                search_roots.append(Path(pkg_init).resolve().parent)
        except Exception:  # pragma: no cover - defensive
            pass

        # Strategy 2 - start from *this* module's directory.
        search_roots.append(Path(__file__).resolve().parent)

        for start in search_roots:
            current = start
            # Walk up at most 10 levels to avoid runaway traversal
            for _ in range(10):
                candidate = current / "alembic.ini"
                if candidate.exists():
                    return candidate
                parent = current.parent
                if parent == current:
                    # Reached filesystem root
                    break
                current = parent

        searched = ", ".join(str(s) for s in search_roots)
        raise FileNotFoundError(
            "Alembic configuration file (alembic.ini) not found. "
            f"Searched upward from: {searched}"
        )

    @property
    def alembic_cfg(self) -> Config:
        """Get Alembic configuration."""
        if self._alembic_cfg is None:
            alembic_ini = self._find_alembic_ini()

            self._alembic_cfg = Config(str(alembic_ini))

            # Override the database URL from our configuration
            self._alembic_cfg.set_main_option("sqlalchemy.url", self.database_url)

        return self._alembic_cfg

    def run_migrations(self, engine: Engine | None = None) -> None:
        """Run all pending database migrations.

        This will bring the database up to the latest migration version.

        Args:
            engine: Optional SQLAlchemy engine to use for migrations.
                   If not provided, Alembic will create its own engine.
                   For in-memory databases, pass the engine to ensure
                   migrations operate on the same database instance.
        """
        import os

        # Set environment variable so env.py reads the correct database URL
        old_db_url = os.environ.get("CLEVERAGENTS_DATABASE_URL")
        conn = None
        try:
            os.environ["CLEVERAGENTS_DATABASE_URL"] = self.database_url

            if engine is not None:
                # For in-memory databases and other cases where we want to
                # reuse an existing engine, pass the connection to alembic
                conn = engine.connect()
                try:
                    self.alembic_cfg.attributes["connection"] = conn
                    # Run the upgrade command to latest revision
                    command.upgrade(self.alembic_cfg, "head")
                finally:
                    self.alembic_cfg.attributes.pop("connection", None)
            else:
                # Let Alembic create its own engine
                # Run the upgrade command to latest revision
                command.upgrade(self.alembic_cfg, "head")
        finally:
            # Close connection if we opened one
            if conn is not None:
                conn.close()
            # Restore previous environment variable state
            if old_db_url is None:
                os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)
            else:
                os.environ["CLEVERAGENTS_DATABASE_URL"] = old_db_url

    def get_current_revision(self) -> str | None:
        """Get the current migration revision of the database.

        Returns:
            Current revision ID or None if no migrations have been applied
        """
        engine = create_engine(self.database_url)
        with engine.connect() as connection:
            context = MigrationContext.configure(connection)
            return context.get_current_revision()

    def get_pending_migrations(self) -> list[str]:
        """Get list of pending migrations.

        Uses Alembic's built-in head-comparison to correctly determine
        which revisions have not yet been applied, handling branches and
        merges properly.

        Returns:
            List of migration revision IDs that haven't been applied yet
        """
        current_rev = self.get_current_revision()
        script_dir = ScriptDirectory.from_config(self.alembic_cfg)
        head = script_dir.get_current_head()

        # If current revision matches head, nothing is pending
        if current_rev == head:
            return []

        # If no migrations applied yet, everything is pending
        if current_rev is None:
            return [rev.revision for rev in reversed(list(script_dir.walk_revisions()))]

        # Walk from head backwards, collecting revisions until we hit current
        pending: list[str] = []
        for rev in script_dir.walk_revisions():
            if rev.revision == current_rev:
                break
            pending.append(rev.revision)

        # Reverse to get chronological order
        return list(reversed(pending))

    def check_migrations_needed(self) -> bool:
        """Check if there are any pending migrations.

        Returns:
            True if migrations are needed, False otherwise
        """
        return len(self.get_pending_migrations()) > 0

    def init_or_upgrade(
        self,
        require_confirmation: bool = False,
        prompt_for_migration: Callable[[str], bool] | None = None,
    ) -> None:
        """Initialize database or upgrade to latest version.

        This is the main method to call during application startup.
        It will create the alembic_version table if needed and run
        all pending migrations.

        Args:
            require_confirmation: When True, prompt before applying migrations.
            prompt_for_migration: Optional callback to request approval.
                If not provided, a default interactive prompt is used
                when running in a TTY; non-interactive environments
                auto-approve to avoid blocking.
        """
        prompt = prompt_for_migration or self._default_prompt_for_migration

        def require_user_approval(reason: str) -> None:
            if not require_confirmation:
                return
            if not prompt(reason):
                raise MigrationNotApprovedError(
                    "Pending database migrations were declined.",
                    details={"reason": reason},
                )

        # For SQLite, ensure the parent directory exists
        # Skip this for in-memory databases (:memory:)
        if (
            self.database_url.startswith("sqlite")
            and ":memory:" not in self.database_url
        ):
            # Parse the database path from sqlite URL
            # Format: sqlite:///path/to/db.db or sqlite:////absolute/path/to/db.db
            db_path = self.database_url.replace("sqlite:///", "")
            # Handle case where URL starts with /// (absolute path on Unix)
            if not db_path.startswith("/"):
                db_path = "/" + db_path
            db_file = Path(db_path)
            db_file.parent.mkdir(parents=True, exist_ok=True)

        # Create or retrieve engine with proper settings for SQLite
        # For in-memory databases, use cached engines to ensure schema persists
        if self.database_url.startswith("sqlite"):
            if self.database_url == "sqlite:///:memory:":
                # For in-memory databases, reuse cached engine to ensure
                # the in-memory database persists across engine instances
                if self.database_url not in MEMORY_ENGINES:
                    MEMORY_ENGINES[self.database_url] = create_engine(
                        self.database_url,
                        connect_args={"check_same_thread": False},
                    )
                engine = MEMORY_ENGINES[self.database_url]
                should_dispose = False
            else:
                # File-based SQLite
                engine = create_engine(
                    self.database_url,
                    connect_args={"check_same_thread": False},
                )
                should_dispose = True
        else:
            engine = create_engine(self.database_url)
            should_dispose = True

        try:
            # Check if alembic_version table exists
            with engine.connect() as connection:
                # Get table names
                from sqlalchemy import inspect

                inspector = inspect(engine)
                tables = inspector.get_table_names()

                if "alembic_version" not in tables:
                    # First time setup
                    # This ensures we don't try to re-run initial migration
                    # on existing databases
                    if len(tables) > 0:
                        # Database has tables but no alembic_version
                        # This is a pre-Alembic legacy database.
                        # Stamp it at the initial migration to record the
                        # existing schema, then run any subsequent migrations
                        # so the database reaches the current head.
                        require_user_approval(
                            "Existing database has tables but no migration history. "
                            "Stamp the initial schema before continuing."
                        )
                        self.alembic_cfg.attributes["connection"] = connection
                        try:
                            # Stamp at head since create_all creates all
                            # tables matching the current ORM models.
                            command.stamp(self.alembic_cfg, "head")
                            connection.commit()
                        finally:
                            self.alembic_cfg.attributes.pop("connection", None)
                    else:
                        # Fresh database - run all migrations from scratch
                        # Pass the engine so migrations operate on the same database
                        require_user_approval(
                            "Database schema is not initialized. "
                            "Apply migrations to create it."
                        )
                        self.run_migrations(engine=engine)
                else:
                    pending_revisions = self.get_pending_migrations()
                    # Database has alembic_version table
                    # Run any pending migrations
                    if pending_revisions:
                        latest = pending_revisions[-1]
                        require_user_approval(
                            "Pending database migrations detected "
                            f"(latest: {latest}). Back up your "
                            ".cleveragents directory before applying."
                        )
                        self.run_migrations(engine=engine)
        finally:
            # Ensure engine is properly disposed to flush all changes
            # This is critical for SQLite to ensure the database file is synced
            # Don't dispose in-memory databases as they're cached globally
            if should_dispose:
                engine.dispose()

    @staticmethod
    def _default_prompt_for_migration(message: str) -> bool:
        """Request user approval before applying migrations.

        Uses an interactive prompt when running in a TTY. In CI or other
        non-interactive contexts, migrations are auto-approved to avoid
        hanging execution. Set CLEVERAGENTS_AUTO_APPLY_MIGRATIONS to
        "true" to auto-approve explicitly.
        """
        import os
        import sys

        auto_apply = os.environ.get("CLEVERAGENTS_AUTO_APPLY_MIGRATIONS", "").lower()
        if auto_apply in ("1", "true", "yes"):
            return True

        # Auto-approve when running in CI, Behave, or Robot test contexts
        # to avoid blocking on interactive prompts.  Subprocesses spawned
        # by Robot Framework's ``Run Process`` keyword inherit the parent's
        # stdin; if that stdin is a TTY, ``typer.confirm`` would block
        # forever because no user is providing input.
        if (
            os.environ.get("CI")
            or os.environ.get("BEHAVE_TESTING")
            or os.environ.get("ROBOT_TESTING")
        ):
            return True

        try:
            if sys.stdin is not None and sys.stdin.isatty():
                import typer

                return typer.confirm(
                    (
                        f"{message}\n\n"
                        "Apply migrations now? It's recommended to back up the "
                        ".cleveragents directory first."
                    ),
                    default=False,
                )
        except Exception:
            # Fall through to auto-approval below if prompting fails
            logging.getLogger(__name__).debug(
                "Interactive prompt failed, auto-approving migrations"
            )

        # Non-interactive or prompt failure: auto-approve to avoid blocking
        return True
