"""Isolation providers for replay validation.

Ensures that executing a DML procedure does not permanently mutate the
underlying database, so we can capture *before* and *after* snapshots
without lasting side effects.

Three transaction-based strategies are supported:

``SqlServerTransactionIsolation``
    Wraps the procedure CALL inside ``BEGIN TRANSACTION`` / ``ROLLBACK``.
    Toggles pyodbc ``autocommit`` to ``True`` so SQL Server honours the
    explicit transaction boundary.

``RedshiftTransactionIsolation``
    Wraps the procedure CALL inside ``BEGIN`` / ``ROLLBACK``.
    Toggles ``redshift_connector`` ``autocommit`` to ``False`` so the
    driver does not auto-commit individual statements.

``SnowflakeCloneIsolation``
    Creates a zero-copy clone of the target database before execution
    and drops it afterwards.  Used on the *validate* (Snowflake) side.

Callers should not instantiate these directly — use
:meth:`DatabaseExecutorFactory.create_isolation` to obtain the correct
provider for the active dialect.
"""

from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from typing import Any

logger = logging.getLogger(__name__)


class IsolationProvider(ABC):
    """Context-manager interface for execution isolation."""

    @abstractmethod
    def setup(self, connection: Any) -> None:
        """Prepare the isolated environment (e.g. BEGIN)."""

    @abstractmethod
    def teardown(self, connection: Any) -> None:
        """Roll back / clean up the isolated environment."""

    @abstractmethod
    def cleanup(self, connection: Any) -> None:
        """Force-clean any resources left behind (for test/debug recovery).

        Idempotent — safe to call even if setup was never called.
        """

    def backup_tables(
        self, connection: Any, table_configs: list[Any],
    ) -> None:
        """Create backup copies of affected tables before procedure execution.

        No-op by default.  Override in providers where transaction-based
        rollback is unavailable (e.g. Teradata).
        """

    def __enter__(self) -> IsolationProvider:
        return self

    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        pass


class SqlServerTransactionIsolation(IsolationProvider):
    """Isolation via ``BEGIN TRANSACTION`` / ``ROLLBACK``.

    The caller is responsible for passing the *same* connection to
    :meth:`setup`, the procedure CALL, and :meth:`teardown`.

    pyodbc connections default to ``autocommit=False``, which causes the
    ODBC driver to manage transactions at the driver level.  SQL-level
    ``BEGIN TRANSACTION`` / ``ROLLBACK`` are ignored in that mode and
    data persists despite ROLLBACK.  To guarantee proper isolation, we
    temporarily switch to ``autocommit=True`` so that SQL Server itself
    honours the explicit transaction boundary.
    """

    def __init__(self) -> None:
        self._active = False
        self._prev_autocommit: bool | None = None

    def setup(self, connection: Any) -> None:
        self._prev_autocommit = getattr(connection, "autocommit", None)
        if self._prev_autocommit is not None:
            connection.autocommit = True
        cursor = connection.cursor()
        try:
            cursor.execute("BEGIN TRANSACTION")
            self._active = True
            logger.debug("SqlServerTransactionIsolation: BEGIN TRANSACTION issued (autocommit forced True)")
        finally:
            cursor.close()

    def teardown(self, connection: Any) -> None:
        if not self._active:
            return
        cursor = connection.cursor()
        try:
            cursor.execute("ROLLBACK")
            self._active = False
            logger.debug("SqlServerTransactionIsolation: ROLLBACK issued")
        finally:
            cursor.close()
        if self._prev_autocommit is not None:
            connection.autocommit = self._prev_autocommit
            self._prev_autocommit = None

    def cleanup(self, connection: Any) -> None:
        self.teardown(connection)


class RedshiftTransactionIsolation(IsolationProvider):
    """Isolation via ``BEGIN`` / ``ROLLBACK`` for Redshift (PostgreSQL wire protocol).

    ``redshift_connector`` defaults to ``autocommit=False``, but the
    ``RedshiftExecutor`` sets ``autocommit=True`` at init time so that
    individual failed statements don't poison the connection.

    For isolation we need the opposite: ``autocommit=False`` so that
    ``BEGIN`` opens a real transaction block and ``ROLLBACK`` undoes all
    changes made within it.
    """

    def __init__(self) -> None:
        self._active = False
        self._prev_autocommit: bool | None = None

    def setup(self, connection: Any) -> None:
        self._prev_autocommit = getattr(connection, "autocommit", None)
        if self._prev_autocommit is None:
            raise RuntimeError(
                "RedshiftTransactionIsolation requires a connection with an "
                "'autocommit' attribute. Cannot guarantee isolation without "
                "explicit autocommit control."
            )
        connection.autocommit = False
        cursor = connection.cursor()
        try:
            cursor.execute("BEGIN")
            self._active = True
            logger.debug("RedshiftTransactionIsolation: BEGIN issued (autocommit forced False)")
        finally:
            cursor.close()

    def teardown(self, connection: Any) -> None:
        if not self._active:
            return
        cursor = connection.cursor()
        try:
            cursor.execute("ROLLBACK")
            self._active = False
            logger.debug("RedshiftTransactionIsolation: ROLLBACK issued")
        finally:
            cursor.close()
        if self._prev_autocommit is not None:
            connection.autocommit = self._prev_autocommit
            self._prev_autocommit = None

    def cleanup(self, connection: Any) -> None:
        self.teardown(connection)


class TeradataTransactionIsolation(IsolationProvider):
    """Backup/restore isolation for Teradata delta capture.

    Teradata stored procedures execute DML in their own internal
    sub-transactions.  When wrapped in ``BT`` / ``ROLLBACK``, the
    outer transaction uses read-repeatable semantics: ``SELECT *``
    snapshots see data as of ``BT`` time, **not** the procedure's
    committed changes.  This makes before/after snapshots identical
    and delta capture silently produces zero changes.

    Instead of ``BT``/``ROLLBACK``, this provider uses a backup/restore
    strategy:

    1. ``setup()``: ensures autocommit is on (no ``BT``).
    2. ``backup_tables()``: creates ``TABLE__SCAI_BKP`` copies of each
       affected table via ``CREATE TABLE ... AS ... WITH DATA``.
    3. The procedure runs and auto-commits its changes.
    4. After-snapshots see the real committed state; deltas are captured.
    5. ``teardown()``: restores each table from its backup and drops the
       backup, undoing the procedure's side-effects.
    """

    def __init__(self) -> None:
        self._backups: list[tuple[str, str, str]] = []

    def setup(self, connection: Any) -> None:
        prev = getattr(connection, "autocommit", None)
        if prev is not None and not prev:
            connection.autocommit = True
            logger.debug(
                "TeradataTransactionIsolation: autocommit was %s, forced True",
                prev,
            )
        logger.debug("TeradataTransactionIsolation: setup (no BT, backup/restore mode)")

    def backup_tables(
        self, connection: Any, table_configs: list[Any],
    ) -> None:
        """Create backup copies of affected tables before procedure execution."""
        for tc in table_configs:
            schema = tc.schema_name
            table = tc.table_name
            backup = f"{table}__SCAI_BKP"
            cursor = connection.cursor()
            try:
                cursor.execute(
                    f'CREATE TABLE "{schema}"."{backup}" AS '
                    f'(SELECT * FROM "{schema}"."{table}") WITH DATA'
                )
                self._backups.append((schema, table, backup))
                logger.debug(
                    "TeradataTransactionIsolation: backed up %s.%s -> %s.%s",
                    schema, table, schema, backup,
                )
            except Exception:
                logger.error(
                    "TeradataTransactionIsolation: failed to backup %s.%s",
                    schema, table,
                )
                raise
            finally:
                cursor.close()

    def teardown(self, connection: Any) -> None:
        if not self._backups:
            logger.debug("TeradataTransactionIsolation: no backups to restore")
            return
        for schema, table, backup in self._backups:
            cursor = connection.cursor()
            try:
                cursor.execute(f'DELETE FROM "{schema}"."{table}"')
                cursor.execute(
                    f'INSERT INTO "{schema}"."{table}" '
                    f'SELECT * FROM "{schema}"."{backup}"'
                )
                cursor.execute(f'DROP TABLE "{schema}"."{backup}"')
                logger.debug(
                    "TeradataTransactionIsolation: restored %s.%s from backup",
                    schema, table,
                )
            except Exception:
                logger.error(
                    "TeradataTransactionIsolation: failed to restore %s.%s "
                    "— backup table %s.%s may still exist",
                    schema, table, schema, backup,
                )
                raise
            finally:
                cursor.close()
        self._backups.clear()

    def cleanup(self, connection: Any) -> None:
        """Drop any leftover backup tables (idempotent)."""
        for schema, _table, backup in self._backups:
            cursor = connection.cursor()
            try:
                cursor.execute(f'DROP TABLE IF EXISTS "{schema}"."{backup}"')
            except Exception:
                pass
            finally:
                cursor.close()
        self._backups.clear()


class SnowflakeCloneIsolation(IsolationProvider):
    """Isolation via Snowflake zero-copy clone.

    Creates ``<database>_REPLAY_<procedure>_<params_hash>`` from
    *source_database*, runs the procedure there, then drops the clone.

    Pass *procedure* and *params_hash* so parallel validation runs for
    different procs/cases don't collide.
    """

    # TODO: Add a `retain_clone` flag (default False) so that during
    # debugging we can keep the cloned database for manual inspection
    # rather than dropping it immediately.  When retained, log the
    # clone name so the user knows what to clean up.  Only drop on
    # the next run or via an explicit `cleanup()` call.

    def __init__(
        self,
        source_database: str,
        procedure: str = "",
        params_hash: str = "",
    ) -> None:
        safe_proc = procedure.replace(".", "_").replace(" ", "_")[:30].upper()
        suffix = f"{safe_proc}_{params_hash}".strip("_").upper()
        self._source = source_database
        self._clone_name = f"{source_database}_REPLAY_{suffix}" if suffix else f"{source_database}_REPLAY"
        self._created = False

    @property
    def clone_database(self) -> str:
        return self._clone_name

    def setup(self, connection: Any) -> None:
        cursor = connection.cursor()
        try:
            cursor.execute(
                f"CREATE OR REPLACE DATABASE {self._clone_name} "
                f"CLONE {self._source}"
            )
            self._created = True
            # CREATE DATABASE switches the session to the new DB;
            # restore the original so that stage references, baseline
            # queries, and cross-database table clones resolve correctly.
            cursor.execute(f"USE DATABASE {self._source}")
            logger.info(
                "SnowflakeCloneIsolation: cloned %s -> %s",
                self._source, self._clone_name,
            )
        finally:
            cursor.close()

    def teardown(self, connection: Any) -> None:
        if not self._created:
            return
        cursor = connection.cursor()
        try:
            cursor.execute(f"DROP DATABASE IF EXISTS {self._clone_name}")
            self._created = False
            logger.info(
                "SnowflakeCloneIsolation: dropped clone %s",
                self._clone_name,
            )
        finally:
            cursor.close()

    def revert_tables(self, connection: Any, tables: list[str]) -> None:
        """Revert specific tables in the clone from the source database.

        Each entry in *tables* should be ``SCHEMA.TABLE``.  Executes::

            CREATE OR REPLACE TABLE <clone>.SCHEMA.TABLE
                CLONE <source>.SCHEMA.TABLE

        This is dramatically faster than re-cloning the entire database
        (~0.1-0.3 s per table vs ~9-10 s for a full DB clone).
        """
        if not tables or not self._created:
            return
        cursor = connection.cursor()
        try:
            for fqn in tables:
                sql = (
                    f"CREATE OR REPLACE TABLE {self._clone_name}.{fqn} "
                    f"CLONE {self._source}.{fqn}"
                )
                try:
                    cursor.execute(sql)
                except Exception:
                    logger.error(
                        "SnowflakeCloneIsolation: revert failed — %s", sql,
                    )
                    raise
                logger.debug(
                    "SnowflakeCloneIsolation: reverted %s.%s from %s",
                    self._clone_name, fqn, self._source,
                )
            logger.info(
                "SnowflakeCloneIsolation: reverted %d table(s) in %s",
                len(tables), self._clone_name,
            )
        finally:
            cursor.close()

    def cleanup(self, connection: Any) -> None:
        """Force-drop the clone database regardless of creation state."""
        cursor = connection.cursor()
        try:
            cursor.execute(f"DROP DATABASE IF EXISTS {self._clone_name}")
            self._created = False
            logger.info(
                "SnowflakeCloneIsolation: cleanup dropped %s",
                self._clone_name,
            )
        finally:
            cursor.close()
