"""Extended integration tests for test_runner.validate.runner.

Covers error-baseline scenarios, capture steps, _validate_case internals,
and edge cases not covered in test_runner.py.
"""

from __future__ import annotations

from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from test_runner.common.baseline import write_baseline
from test_runner.common.config import TestRunnerConfig, ValidationConfiguration
from test_runner.common.container import TestRunnerContainer
from test_runner.common.database_executor import DatabaseExecutor
from test_runner.common.models import (
    BaselineData,
    ComparisonResult,
    TestCaseResult,
    ValidationSteps,
)
from test_runner.common.template import build_parameters
from test_runner.validate.runner import _is_implicit_return, _validate_case, run_validate


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_DT = datetime(2026, 2, 20, tzinfo=timezone.utc)


def _make_config(**overrides) -> TestRunnerConfig:
    defaults = dict(
        target_connection_raw={"name": "test", "mode": "name"},
    )
    defaults.update(overrides)
    return TestRunnerConfig(**defaults)


def _make_registry(executor=None):
    """Build a factory registry, optionally mocking the Snowflake executor."""
    container = TestRunnerContainer()
    registry = container.factory_registry()
    if executor is not None:
        from test_runner.common.database_dialect import DatabaseDialect
        factory = registry[DatabaseDialect.SNOWFLAKE]
        factory.build_config = MagicMock(return_value=MagicMock())
        factory.create_executor = MagicMock(return_value=executor)
    return registry


class _FakeExecutor(DatabaseExecutor):
    def __init__(self, results=None, error=None):
        self._results = results or []
        self._call_idx = 0
        self._error = error
        self._mock_connector = MagicMock()

    @property
    def connector(self):
        return self._mock_connector

    def execute(self, sql: str, steps=None, fetch_stmts=None) -> TestCaseResult:
        if self._error:
            return TestCaseResult(success=False, error=self._error)
        if self._results:
            idx = min(self._call_idx, len(self._results) - 1)
            self._call_idx += 1
            return self._results[idx]
        return TestCaseResult(result_sets=[[]], row_counts=[0])

    def close(self):
        pass


def _write_baseline(baseline_dir: Path, **overrides) -> None:
    defaults = dict(
        procedure="master.dbo.GetProducts",
        parameters={},
        captured_at=_DT.isoformat(),
        result_sets=[[{"Id": 1}]],
        row_counts=[1],
        success=True,
    )
    defaults.update(overrides)
    bl = BaselineData(**defaults)
    write_baseline(bl, baseline_dir, case_index=0, capture_date=_DT)


# ---------------------------------------------------------------------------
# _is_implicit_return unit tests
# ---------------------------------------------------------------------------

class TestIsImplicitReturn:
    def test_single_row_matching_proc_name(self):
        assert _is_implicit_return(["INSERTCOLOR"], 1, "master.dbo.InsertColor")

    def test_single_row_fully_qualified(self):
        assert _is_implicit_return(
            ["ACTIVATEWEBSITELOGON"], 1,
            "WideWorldImporters.Website.ActivateWebsiteLogon",
        )

    def test_multiple_rows_is_not_implicit(self):
        assert not _is_implicit_return(["INSERTCOLOR"], 3, "master.dbo.InsertColor")

    def test_multiple_columns_is_not_implicit(self):
        assert not _is_implicit_return(["ID", "NAME"], 1, "master.dbo.InsertColor")

    def test_column_name_mismatch_is_not_implicit(self):
        assert not _is_implicit_return(["COLORID"], 1, "master.dbo.InsertColor")

    def test_zero_rows_is_not_implicit(self):
        assert not _is_implicit_return(["INSERTCOLOR"], 0, "master.dbo.InsertColor")

    def test_anonymous_block_is_implicit(self):
        assert _is_implicit_return(
            ["anonymous block"], 1, "test.get_top_products",
        )


# ---------------------------------------------------------------------------
# Error baseline matching
# ---------------------------------------------------------------------------

class TestErrorBaselineMatching:
    """Test the error-matching path in _validate_case."""

    def test_error_baseline_actual_error_same_type_passes(self, tmp_project: Path):
        baseline_dir = tmp_project / ".scai" / "baselines"
        _write_baseline(
            baseline_dir,
            success=False,
            error="TIMEOUT: connection lost",
            result_sets=[],
            row_counts=[],
        )

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor(error="TIMEOUT: a different detail")),
            project_root=tmp_project,
            baseline_dir=baseline_dir,
        )

        assert stats.passed >= 1

    def test_error_baseline_actual_error_different_type_fails(self, tmp_project: Path):
        baseline_dir = tmp_project / ".scai" / "baselines"
        _write_baseline(
            baseline_dir,
            success=False,
            error="TIMEOUT: connection lost",
            result_sets=[],
            row_counts=[],
        )

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor(error="SYNTAX: invalid SQL")),
            project_root=tmp_project,
            baseline_dir=baseline_dir,
        )

        assert stats.failed >= 1

    def test_error_baseline_actual_succeeds_fails(self, tmp_project: Path):
        """When baseline errored but actual succeeds, that's a FAIL."""
        baseline_dir = tmp_project / ".scai" / "baselines"
        _write_baseline(
            baseline_dir,
            success=False,
            error="TIMEOUT: lost",
            result_sets=[],
            row_counts=[],
        )

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor()),
            project_root=tmp_project,
            baseline_dir=baseline_dir,
        )

        assert stats.failed == 1


# ---------------------------------------------------------------------------
# _validate_case directly
# ---------------------------------------------------------------------------

class TestValidateCaseDirect:
    """Test _validate_case with minimal mocking."""

    def _make_test_file(self):
        from test_runner.common.models import TestFile
        return TestFile(
            file_path="/test/getproducts.yml",
            source=ValidationSteps(run="EXEC dbo.GetProducts"),
            test_cases=[[]],
            target=ValidationSteps(run="CALL dbo.GetProducts()"),
            code_unit_name="master.dbo.GetProducts",
            parameter_names=[],
        )

    def test_no_baseline_returns_no_baseline_status(self):
        tf = self._make_test_file()
        result = _validate_case(
            test_file=tf,
            test_case=[],
            baseline_index={},
            connector=MagicMock(),
            executor=_FakeExecutor(),
            format_literal=lambda x: str(x),
            config=_make_config(),
        )
        assert result.status == "NO_BASELINE"

    def test_result_contains_required_fields(self):
        tf = self._make_test_file()
        result = _validate_case(
            test_file=tf,
            test_case=[],
            baseline_index={},
            connector=MagicMock(),
            executor=_FakeExecutor(),
            format_literal=lambda x: str(x),
            config=_make_config(),
        )
        assert result.procedure
        assert result.params_hash
        assert result.params is not None
        assert result.status
        assert result.executed_at

    def test_error_baseline_match(self):
        tf = self._make_test_file()
        from test_runner.common.baseline import compute_params_hash
        params = build_parameters([], [])
        phash = compute_params_hash(params)

        baseline = BaselineData(
            procedure="master.dbo.GetProducts",
            parameters={},
            captured_at="2026-01-01",
            result_sets=[],
            row_counts=[],
            success=False,
            error="TIMEOUT: connection lost",
        )

        result = _validate_case(
            test_file=tf,
            test_case=[],
            baseline_index={("master.dbo.GetProducts", phash): baseline},
            connector=MagicMock(),
            executor=_FakeExecutor(error="TIMEOUT: different detail"),
            format_literal=lambda x: str(x),
            config=_make_config(),
        )
        assert result.status == "PASS"
        assert result.match_type == "error_match"

    def test_error_baseline_mismatch(self):
        tf = self._make_test_file()
        from test_runner.common.baseline import compute_params_hash
        params = build_parameters([], [])
        phash = compute_params_hash(params)

        baseline = BaselineData(
            procedure="master.dbo.GetProducts",
            parameters={},
            captured_at="2026-01-01",
            result_sets=[],
            row_counts=[],
            success=False,
            error="TIMEOUT: lost",
        )

        result = _validate_case(
            test_file=tf,
            test_case=[],
            baseline_index={("master.dbo.GetProducts", phash): baseline},
            connector=MagicMock(),
            executor=_FakeExecutor(error="SYNTAX: bad sql"),
            format_literal=lambda x: str(x),
            config=_make_config(),
        )
        assert result.status == "FAIL"
        assert result.match_type == "error_mismatch"


# ---------------------------------------------------------------------------
# run_validate edge cases
# ---------------------------------------------------------------------------

class TestRunValidateEdgeCases:
    def test_no_snowflake_factory_raises(self, tmp_project: Path):
        with pytest.raises(ValueError, match="No executor factory"):
            run_validate(
                config=_make_config(),
                factory_registry={},
                project_root=tmp_project,
            )

    @patch("test_runner.validate.runner.compare_results")
    @patch("test_runner.validate.runner.write_results_batch")
    def test_write_batch_failure_prints_warning(
        self, mock_batch, mock_compare, tmp_project: Path, capsys
    ):
        mock_compare.return_value = ComparisonResult(
            match=True, match_type="data_match",
        )
        mock_batch.side_effect = RuntimeError("Snowflake unavailable")

        baseline_dir = tmp_project / ".scai" / "baselines"
        _write_baseline(baseline_dir)

        run_validate(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor()),
            project_root=tmp_project,
            baseline_dir=baseline_dir,
        )

        captured = capsys.readouterr()
        assert "Warning" in captured.out or mock_batch.called

    def test_executor_always_closed(self, tmp_project: Path):
        """The runner always closes the executor it creates via factory."""
        executor = _FakeExecutor()
        executor.close = MagicMock()

        run_validate(
            config=_make_config(),
            factory_registry=_make_registry(executor),
            project_root=tmp_project,
            baseline_dir=tmp_project / "empty",
        )

        executor.close.assert_called_once()

    @patch("test_runner.validate.runner.compare_results")
    def test_multiple_test_cases_all_counted(self, mock_compare, tmp_project: Path):
        mock_compare.return_value = ComparisonResult(
            match=True, match_type="data_match",
        )

        baseline_dir = tmp_project / ".scai" / "baselines"
        # Write baselines for the parameterized procedure (3 test cases)
        test_cases = [[1001, "active"], [1002, "disabled"], [1003, None]]
        for idx, tc in enumerate(test_cases):
            params = build_parameters(["customerId", "status"], tc)
            bl = BaselineData(
                procedure="master.dbo.GetCustomerOrders",
                parameters=params,
                captured_at=_DT.isoformat(),
                result_sets=[[{"OrderId": idx + 1}]],
                row_counts=[1],
                success=True,
            )
            write_baseline(bl, baseline_dir, case_index=idx, capture_date=_DT)

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor()),
            project_root=tmp_project,
            baseline_dir=baseline_dir,
        )

        assert stats.passed == 3


# ---------------------------------------------------------------------------
# DML procedures with empty baseline result sets
# ---------------------------------------------------------------------------

class TestNoBaselineResultSet:
    """When baseline has no result_sets and no row_counts, the validator
    should execute the CALL and check if the result is Snowflake's
    implicit procedure return value (1 row, column = procedure name).
    Only that case is treated as PASS; real data triggers a mismatch."""

    @staticmethod
    def _make_cursor_mock(col_names, rows):
        """Build a mock cursor that returns *col_names* and *rows*."""
        cursor = MagicMock()
        cursor.description = [(n,) for n in col_names] if col_names else None
        cursor.fetchall.return_value = rows
        return cursor

    @patch("test_runner.validate.runner.compare_results")
    def test_implicit_return_passes_and_skips_comparison(
        self, mock_compare, tmp_project_with_dml: Path,
    ):
        baseline_dir = tmp_project_with_dml / ".scai" / "baselines"
        for idx, tc in enumerate([["Crimson"], ["Emerald"]]):
            params = build_parameters(["ColorName"], tc)
            bl = BaselineData(
                procedure="master.dbo.InsertColor",
                parameters=params,
                captured_at=_DT.isoformat(),
                result_sets=[],
                row_counts=[],
                success=True,
            )
            write_baseline(bl, baseline_dir, case_index=idx, capture_date=_DT)

        executor = _FakeExecutor()
        cursor = self._make_cursor_mock(["INSERTCOLOR"], [(None,)])
        executor.connector.connection.cursor.return_value = cursor

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(executor),
            project_root=tmp_project_with_dml,
            baseline_dir=baseline_dir,
        )

        assert stats.passed == 2
        mock_compare.assert_not_called()

    @patch("test_runner.validate.runner.compare_results")
    def test_unexpected_resultset_fails(
        self, mock_compare, tmp_project_with_dml: Path,
    ):
        baseline_dir = tmp_project_with_dml / ".scai" / "baselines"
        for idx, tc in enumerate([["Crimson"], ["Emerald"]]):
            params = build_parameters(["ColorName"], tc)
            bl = BaselineData(
                procedure="master.dbo.InsertColor",
                parameters=params,
                captured_at=_DT.isoformat(),
                result_sets=[],
                row_counts=[],
                success=True,
            )
            write_baseline(bl, baseline_dir, case_index=idx, capture_date=_DT)

        executor = _FakeExecutor()
        cursor = self._make_cursor_mock(
            ["ColorId", "ColorName"],
            [(1, "Crimson"), (2, "Emerald")],
        )
        executor.connector.connection.cursor.return_value = cursor

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(executor),
            project_root=tmp_project_with_dml,
            baseline_dir=baseline_dir,
        )

        assert stats.failed == 2
        mock_compare.assert_not_called()

    @patch("test_runner.validate.runner.compare_results")
    def test_execution_error_reported_as_error(
        self, mock_compare, tmp_project_with_dml: Path,
    ):
        baseline_dir = tmp_project_with_dml / ".scai" / "baselines"
        for idx, tc in enumerate([["Crimson"], ["Emerald"]]):
            params = build_parameters(["ColorName"], tc)
            bl = BaselineData(
                procedure="master.dbo.InsertColor",
                parameters=params,
                captured_at=_DT.isoformat(),
                result_sets=[],
                row_counts=[],
                success=True,
            )
            write_baseline(bl, baseline_dir, case_index=idx, capture_date=_DT)

        executor = _FakeExecutor()
        cursor = MagicMock()

        def _selective_execute(sql, *args, **kwargs):
            upper = sql.upper()
            if "DATABASE" in upper and ("CREATE" in upper or "DROP" in upper or "USE" in upper):
                return None
            raise Exception("Invalid PersonID")

        cursor.execute.side_effect = _selective_execute
        executor.connector.connection.cursor.return_value = cursor

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(executor),
            project_root=tmp_project_with_dml,
            baseline_dir=baseline_dir,
        )

        assert stats.failed == 2
        mock_compare.assert_not_called()

    @patch("test_runner.validate.runner.validate_deltas")
    @patch("test_runner.validate.runner.compare_results")
    def test_delta_validation_error_marks_fail(
        self, mock_compare, mock_validate_deltas, tmp_project_with_dml: Path,
    ):
        """When baseline has table_deltas and delta validation throws,
        the test must FAIL with match_type='delta_error', not PASS."""
        from test_runner.replay.validate_handler import DeltaComparisonResult

        mock_validate_deltas.side_effect = RuntimeError("'WAREHOUSE.COLORS'")

        baseline_dir = tmp_project_with_dml / ".scai" / "baselines"
        for idx, tc in enumerate([["Crimson"], ["Emerald"]]):
            params = build_parameters(["ColorName"], tc)
            bl = BaselineData(
                procedure="master.dbo.InsertColor",
                parameters=params,
                captured_at=_DT.isoformat(),
                result_sets=[],
                row_counts=[],
                success=True,
                table_deltas={
                    "WAREHOUSE.COLORS": {
                        "table": "WAREHOUSE.COLORS",
                        "strategy_used": "full_snapshot",
                        "inserts": [{"COLORID": 1, "COLORNAME": tc[0]}],
                        "deletes": [],
                        "updates": [],
                    }
                },
            )
            write_baseline(bl, baseline_dir, case_index=idx, capture_date=_DT)

        executor = _FakeExecutor()
        cursor = self._make_cursor_mock(["INSERTCOLOR"], [(None,)])
        executor.connector.connection.cursor.return_value = cursor

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(executor),
            project_root=tmp_project_with_dml,
            baseline_dir=baseline_dir,
        )

        assert stats.failed == 2
        mock_compare.assert_not_called()

    @patch("test_runner.validate.runner.validate_deltas")
    @patch("test_runner.validate.runner.compare_results")
    def test_delta_validation_success_marks_delta_match(
        self, mock_compare, mock_validate_deltas, tmp_project_with_dml: Path,
    ):
        """When baseline has table_deltas and delta validation succeeds,
        the test should PASS with match_type='delta_match'.

        Uses mixed-case keys (as produced by SQL Server capture) to verify
        the validate path normalises keys correctly."""
        from test_runner.replay.validate_handler import DeltaComparisonResult

        mock_validate_deltas.return_value = DeltaComparisonResult(
            match=True, match_type="delta_match", details={},
        )

        baseline_dir = tmp_project_with_dml / ".scai" / "baselines"
        for idx, tc in enumerate([["Crimson"], ["Emerald"]]):
            params = build_parameters(["ColorName"], tc)
            bl = BaselineData(
                procedure="master.dbo.InsertColor",
                parameters=params,
                captured_at=_DT.isoformat(),
                result_sets=[],
                row_counts=[],
                success=True,
                table_deltas={
                    "Warehouse.Colors": {
                        "table": "Warehouse.Colors",
                        "strategy_used": "full_snapshot",
                        "inserts": [{"COLORID": 1, "COLORNAME": tc[0]}],
                        "deletes": [],
                        "updates": [],
                    }
                },
            )
            write_baseline(bl, baseline_dir, case_index=idx, capture_date=_DT)

        executor = _FakeExecutor()
        cursor = self._make_cursor_mock(["INSERTCOLOR"], [(None,)])
        executor.connector.connection.cursor.return_value = cursor

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(executor),
            project_root=tmp_project_with_dml,
            baseline_dir=baseline_dir,
        )

        assert stats.passed == 2
        mock_compare.assert_not_called()

    @patch("test_runner.validate.runner.validate_deltas")
    @patch("test_runner.validate.runner.compare_results")
    def test_mixed_case_delta_keys_do_not_cause_key_error(
        self, mock_compare, mock_validate_deltas, tmp_project_with_dml: Path,
    ):
        """Baseline table_deltas keys from SQL Server use mixed case
        (e.g. 'Warehouse.Colors') while Snowflake normalizes to upper.
        Verify the lookup doesn't raise KeyError."""
        from test_runner.replay.validate_handler import DeltaComparisonResult

        mock_validate_deltas.return_value = DeltaComparisonResult(
            match=True, match_type="delta_match", details={},
        )

        baseline_dir = tmp_project_with_dml / ".scai" / "baselines"
        for idx, tc in enumerate([["Crimson"], ["Emerald"]]):
            params = build_parameters(["ColorName"], tc)
            bl = BaselineData(
                procedure="master.dbo.InsertColor",
                parameters=params,
                captured_at=_DT.isoformat(),
                result_sets=[],
                row_counts=[],
                success=True,
                table_deltas={
                    "Warehouse.Colors": {
                        "table": "Warehouse.Colors",
                        "strategy_used": "full_snapshot",
                        "inserts": [{"ColorID": 1, "ColorName": tc[0]}],
                        "deletes": [],
                        "updates": [],
                    }
                },
            )
            write_baseline(bl, baseline_dir, case_index=idx, capture_date=_DT)

        executor = _FakeExecutor()
        cursor = self._make_cursor_mock(["INSERTCOLOR"], [(None,)])
        executor.connector.connection.cursor.return_value = cursor

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(executor),
            project_root=tmp_project_with_dml,
            baseline_dir=baseline_dir,
        )

        assert stats.passed == 2
        assert stats.failed == 0

    @patch("test_runner.validate.runner.validate_deltas")
    @patch("test_runner.validate.runner.compare_results")
    def test_three_part_delta_keys_normalized_to_schema_table(
        self, mock_compare, mock_validate_deltas, tmp_project_with_dml: Path,
    ):
        """Baselines uploaded to Snowflake stages may have 3-part keys
        (e.g. 'WideWorldImporters.Warehouse.Colors'). The validate path
        must strip the database prefix so the lookup matches tc.fqn."""
        from test_runner.replay.validate_handler import DeltaComparisonResult

        mock_validate_deltas.return_value = DeltaComparisonResult(
            match=True, match_type="delta_match", details={},
        )

        baseline_dir = tmp_project_with_dml / ".scai" / "baselines"
        for idx, tc in enumerate([["Crimson"], ["Emerald"]]):
            params = build_parameters(["ColorName"], tc)
            bl = BaselineData(
                procedure="master.dbo.InsertColor",
                parameters=params,
                captured_at=_DT.isoformat(),
                result_sets=[],
                row_counts=[],
                success=True,
                table_deltas={
                    "WideWorldImporters.Warehouse.Colors": {
                        "table": "WideWorldImporters.Warehouse.Colors",
                        "strategy_used": "full_snapshot",
                        "inserts": [{"ColorID": 1, "ColorName": tc[0]}],
                        "deletes": [],
                        "updates": [],
                    }
                },
            )
            write_baseline(bl, baseline_dir, case_index=idx, capture_date=_DT)

        executor = _FakeExecutor()
        cursor = self._make_cursor_mock(["INSERTCOLOR"], [(None,)])
        executor.connector.connection.cursor.return_value = cursor

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(executor),
            project_root=tmp_project_with_dml,
            baseline_dir=baseline_dir,
        )

        assert stats.passed == 2
        assert stats.failed == 0

    @patch("test_runner.validate.runner.compare_results")
    def test_still_compares_when_baseline_has_row_counts(
        self, mock_compare, tmp_project_with_dml: Path,
    ):
        mock_compare.return_value = ComparisonResult(
            match=True, match_type="data_match",
        )
        baseline_dir = tmp_project_with_dml / ".scai" / "baselines"
        for idx, tc in enumerate([["Crimson"], ["Emerald"]]):
            params = build_parameters(["ColorName"], tc)
            bl = BaselineData(
                procedure="master.dbo.InsertColor",
                parameters=params,
                captured_at=_DT.isoformat(),
                result_sets=[[{"ColorId": idx + 1}]],
                row_counts=[1],
                success=True,
            )
            write_baseline(bl, baseline_dir, case_index=idx, capture_date=_DT)

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor()),
            project_root=tmp_project_with_dml,
            baseline_dir=baseline_dir,
        )

        assert stats.passed == 2
        assert mock_compare.call_count == 2
