"""Replay capture handler — delta capture for DML procedures.

Integrates with the capture runner to capture table deltas when
``test_file.modifies_data`` is ``True``.  The procedure is executed
exactly once, inside a ``BEGIN`` / ``ROLLBACK`` boundary:

1. Begin transaction (isolation)
2. Snapshot affected tables (before)
3. Execute the procedure
4. Snapshot affected tables (after)
5. Rollback transaction (undo all side effects)
6. Compute deltas (before vs after)
7. Return execution result + deltas

The caller (capture runner) owns the executor and connection lifecycle.
"""

from __future__ import annotations

import logging
from typing import Any, Callable

from ..common.config import ReplayConfiguration
from ..common.database_executor import DatabaseExecutorFactory
from .delta_capture import compute_table_delta, snapshot_table
from .models import TableConfig, delta_total_changes
from .source_introspector import ColumnMetadata, SourceIntrospector

logger = logging.getLogger(__name__)


def capture_deltas(
    connection: Any,
    affected_tables: list[str],
    replay_cfg: ReplayConfiguration,
    execute_fn: Callable[[], Any],
    executor_factory: DatabaseExecutorFactory,
    code_unit_name: str = "",
) -> tuple[Any, dict[str, dict], ColumnMetadata]:
    """Execute a DML procedure inside a transaction and capture deltas.

    The sproc is executed exactly once, inside a ``BEGIN`` / ``ROLLBACK``
    boundary.  Both the execution result (return value of *execute_fn*)
    and the table deltas are captured from the same run.

    Tables exceeding ``replay_cfg.large_table_threshold`` rows are skipped
    to avoid expensive full-table snapshots.

    Args:
        connection: Raw database connection (must support ``.cursor()``).
        affected_tables: ``SCHEMA.TABLE`` names this procedure modifies
            (from the Code Unit Registry).
        replay_cfg: Replay configuration with size thresholds.
        execute_fn: A zero-arg callable that executes the procedure on
            *connection* and **returns** the execution result.
        executor_factory: The dialect's executor factory, used to derive
            the dialect string and create the appropriate isolation provider.
        code_unit_name: Used only for log messages.

    Returns:
        ``(result, table_deltas, column_metadata)`` where *result* is
        whatever *execute_fn* returned, *table_deltas* maps FQN table
        names to delta dicts, and *column_metadata* contains
        platform-managed column info from introspection.
    """
    if not affected_tables:
        return execute_fn(), {}, {}

    introspector = executor_factory.create_introspector()
    table_configs = _resolve_table_configs(affected_tables, introspector)
    col_metadata = introspector.introspect_columns(connection, table_configs)
    isolation = executor_factory.create_isolation()
    deltas: dict[str, dict] = {}
    result: Any = None

    try:
        isolation.setup(connection)

        # Filter out tables that exceed the large-table threshold to
        # avoid expensive SELECT * snapshots.
        # TODO: reuse before-snapshots across test cases for the same
        # procedure to avoid redundant SELECT * on the same table.
        eligible_configs: list[TableConfig] = []
        for tc in table_configs:
            row_count = _get_row_count(connection, tc.schema_name, tc.table_name)
            tc.estimated_row_count = row_count
            if row_count > replay_cfg.large_table_threshold:
                logger.warning(
                    "Skipping delta capture for %s: %d rows exceeds "
                    "large_table_threshold (%d)",
                    tc.fqn, row_count, replay_cfg.large_table_threshold,
                )
                continue
            eligible_configs.append(tc)

        if eligible_configs:
            isolation.backup_tables(connection, eligible_configs)

        before_snapshots: dict[str, list[dict[str, Any]]] = {}
        for tc in eligible_configs:
            before_snapshots[tc.fqn] = snapshot_table(
                connection, tc.schema_name, tc.table_name,
            )
            logger.debug(
                "Before snapshot for %s: %d rows",
                tc.fqn, len(before_snapshots[tc.fqn]),
            )

        result = execute_fn()
        exec_success = getattr(result, "success", None)
        exec_error = getattr(result, "error", None)
        logger.debug(
            "[delta] %s: procedure executed, success=%s, error=%s",
            code_unit_name, exec_success, exec_error,
        )

        for tc in eligible_configs:
            after = snapshot_table(connection, tc.schema_name, tc.table_name)
            before = before_snapshots[tc.fqn]
            logger.debug(
                "After snapshot for %s: %d rows (before: %d rows)",
                tc.fqn, len(after), len(before),
            )

            if before == after:
                logger.info(
                    "[delta] %s: snapshots for %s are IDENTICAL "
                    "(%d rows) — procedure may not have modified "
                    "visible data within the transaction",
                    code_unit_name, tc.fqn, len(before),
                )

            tc.estimated_row_count = max(len(before), len(after))
            delta = compute_table_delta(before, after, tc, replay_cfg)
            changes = delta_total_changes(delta)
            logger.debug(
                "[delta] %s: table %s delta_total_changes=%d "
                "(inserts=%d, deletes=%d, updates=%d)",
                code_unit_name, tc.fqn, changes,
                len(delta.get("inserts", [])),
                len(delta.get("deletes", [])),
                len(delta.get("updates", [])),
            )
            if changes > 0:
                deltas[tc.fqn] = delta

    finally:
        isolation.teardown(connection)

    if deltas:
        logger.info(
            "Captured deltas for %s: %d table(s) with changes",
            code_unit_name, len(deltas),
        )
    else:
        logger.info(
            "No table mutations detected for %s", code_unit_name,
        )

    return result, deltas, col_metadata


def _get_row_count(connection: Any, schema: str, table: str) -> int:
    """Return the row count for a table via ``SELECT COUNT(*)``.

    Falls back to 0 on error so that callers can still proceed.
    """
    cursor = connection.cursor()
    try:
        cursor.execute(f'SELECT COUNT(*) FROM "{schema}"."{table}"')
        row = cursor.fetchone()
        return int(row[0]) if row else 0
    except Exception as exc:
        logger.warning("Could not get row count for %s.%s: %s", schema, table, exc)
        return 0
    finally:
        cursor.close()


def _resolve_table_configs(
    affected_tables: list[str],
    introspector: SourceIntrospector,
) -> list[TableConfig]:
    """Parse ``SCHEMA.TABLE`` strings into :class:`TableConfig` objects.

    Identifier casing is delegated to *introspector.normalize_case* so
    each source platform gets its native convention (SQL Server preserves
    original, Redshift lowercases, Snowflake uppercases).

    Raises :class:`ValueError` for names with 4+ dot-separated parts,
    which likely indicate un-escaped dotted identifiers
    (e.g. ``GoldDb.md.[md.doctors]``).
    """
    configs: list[TableConfig] = []
    for fqn in affected_tables:
        parts = fqn.split(".")
        if len(parts) == 2:
            schema, table = parts
        elif len(parts) == 3:
            _, schema, table = parts
        elif len(parts) >= 4:
            raise ValueError(
                f"Table name '{fqn}' has {len(parts)} dot-separated parts; "
                f"this likely indicates an un-escaped dotted identifier "
                f"(e.g. [schema.with.dots]). Only 2-part (SCHEMA.TABLE) "
                f"and 3-part (DB.SCHEMA.TABLE) names are supported."
            )
        else:
            schema, table = "", fqn
        configs.append(TableConfig(
            schema_name=introspector.normalize_case(schema),
            table_name=introspector.normalize_case(table),
        ))
    return configs
