"""Integration tests for test_runner.validate.runner (with mocked executor).

The validate runner is YAML-driven: it discovers ``target.steps`` from
test YAML files, renders the Snowflake SQL, executes it, and compares
against a matching baseline.  These tests use the ``tmp_project`` fixture
which sets up real YAML files in ``artifacts/**/test/``.

Since validation now requires a Snowflake connector (stage-first approach),
these tests mock the connector and the comparison path.
"""

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
from test_runner.common.container import TestRunnerContainer
from test_runner.common.database_executor import DatabaseExecutor
from test_runner.common.models import BaselineData, ComparisonResult, TestCaseResult
from test_runner.common.template import build_parameters
from test_runner.validate.runner import _rewrite_database, run_validate


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

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


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


def _write_baselines_for_param_proc(baseline_dir: Path) -> None:
    test_cases = [
        [1001, "active"],
        [1002, "disabled"],
        [1003, None],
    ]
    param_names = ["customerId", "status"]

    for idx, tc in enumerate(test_cases):
        params = build_parameters(param_names, tc)
        bl = BaselineData(
            procedure="master.dbo.GetCustomerOrders",
            parameters=params,
            captured_at=_DT.isoformat(),
            result_sets=[[{"OrderId": idx + 1, "Total": 100.0 * (idx + 1)}]],
            row_counts=[1],
            success=True,
        )
        write_baseline(bl, baseline_dir, case_index=idx, capture_date=_DT)


class _FakeExecutor(DatabaseExecutor):
    """Fake DatabaseExecutor with a mock connector."""

    def __init__(self, error=None):
        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:
        result = TestCaseResult()
        if self._error:
            result.success = False
            result.error = self._error
            return result
        result.result_sets = [[]]
        result.row_counts = [0]
        return result

    def close(self):
        pass


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


def _mock_compare_pass(*args, **kwargs):
    return ComparisonResult(
        match=True,
        match_level="row_validation",
        match_type="data_match",
    )


def _mock_compare_fail(*args, **kwargs):
    return ComparisonResult(
        match=False,
        match_level="row_validation",
        match_type="data_mismatch",
        differences=["mismatch"],
    )


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------

class TestRunValidate:
    @patch("test_runner.validate.runner.compare_results", side_effect=_mock_compare_pass)
    def test_pass_when_results_match(self, mock_compare, tmp_project: Path):
        baseline_dir = tmp_project / ".scai" / "baselines"
        _write_baselines_for_simple_proc(baseline_dir)

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

        assert stats.passed == 1
        assert stats.failed == 0

    @patch("test_runner.validate.runner.compare_results", side_effect=_mock_compare_fail)
    def test_fail_when_results_differ(self, mock_compare, tmp_project: Path):
        baseline_dir = tmp_project / ".scai" / "baselines"
        _write_baselines_for_param_proc(baseline_dir)

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

        assert stats.failed == 3
        assert stats.passed == 0

    def test_error_when_execution_fails(self, tmp_project: Path):
        baseline_dir = tmp_project / ".scai" / "baselines"

        bl = BaselineData(
            procedure="master.dbo.GetProducts",
            parameters={},
            captured_at=_DT.isoformat(),
            result_sets=[],
            row_counts=[],
            success=False,
            error="Source error: timeout",
        )
        write_baseline(bl, baseline_dir, case_index=0, capture_date=_DT)

        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor(error="Procedure not found")),
            project_root=tmp_project,
            baseline_dir=baseline_dir,
        )

        assert stats.failed + stats.errors >= 1

    def test_no_yaml_files_returns_empty(self, tmp_path: Path):
        stats = run_validate(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor()),
            project_root=tmp_path,
            baseline_dir=tmp_path / "empty_baselines",
        )

        assert stats.total == 0

    def test_no_baseline_reports_no_baseline(self, tmp_project: Path):
        empty_baseline_dir = tmp_project / ".scai" / "empty_baselines"
        empty_baseline_dir.mkdir(parents=True)

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

        assert stats.no_baseline >= 1

    @patch("test_runner.validate.runner.compare_results", side_effect=_mock_compare_pass)
    def test_no_where_returns_all_test_files(self, mock_compare, tmp_project: Path):
        """Without --where, all discovered test files with targets are validated."""
        baseline_dir = tmp_project / ".scai" / "baselines"
        _write_baselines_for_simple_proc(baseline_dir)
        _write_baselines_for_param_proc(baseline_dir)

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

        # Both GetProducts (1 case) and GetCustomerOrders (3 cases) should run
        assert stats.total == 4


# ---------------------------------------------------------------------------
# TestOutputDir
# ---------------------------------------------------------------------------

class TestOutputDir:
    """Ensure --output-dir causes write_results_local to be called correctly."""

    @patch("test_runner.validate.runner.compare_results", side_effect=_mock_compare_pass)
    @patch("test_runner.validate.runner.write_results_local")
    def test_output_dir_triggers_write_results_local(
        self, mock_write_local, mock_compare, tmp_project: Path, tmp_path: Path
    ):
        baseline_dir = tmp_project / ".scai" / "baselines"
        _write_baselines_for_simple_proc(baseline_dir)

        run_validate(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor()),
            project_root=tmp_project,
            baseline_dir=baseline_dir,
            output_dir=tmp_path / "out",
        )

        mock_write_local.assert_called_once()
        call_args = mock_write_local.call_args
        assert isinstance(call_args[0][0], list)
        assert "out" in str(call_args[0][1])

    @patch("test_runner.validate.runner.write_results_batch")
    @patch("test_runner.validate.runner.write_results_local")
    @patch("test_runner.validate.runner.compare_results")
    def test_dump_rows_are_forwarded_to_local_writer(
        self, mock_compare, mock_write_local, mock_write_batch, tmp_project: Path, tmp_path: Path,
    ):
        baseline_dir = tmp_project / ".scai" / "baselines"
        _write_baselines_for_simple_proc(baseline_dir)
        mock_compare.return_value = ComparisonResult(
            match=True,
            match_level="in_memory",
            match_type="data_match",
            baseline_rows=[{"ID": 1}],
            actual_rows=[{"ID": 1}],
        )

        run_validate(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor()),
            project_root=tmp_project,
            baseline_dir=baseline_dir,
            output_dir=tmp_path / "out",
            dump_baseline_and_actual=True,
        )

        payload = mock_write_local.call_args.args[0]
        # Find the GetProducts result (which has baseline_rows), skipping NO_BASELINE entries
        get_products_results = [r for r in payload if "_baseline_rows" in r]
        assert len(get_products_results) == 1
        assert get_products_results[0]["_baseline_rows"] == [{"ID": 1}]
        assert get_products_results[0]["_actual_rows"] == [{"ID": 1}]

    @patch("test_runner.validate.runner.compare_results", side_effect=_mock_compare_pass)
    @patch("test_runner.validate.runner.write_results_local")
    def test_no_output_dir_does_not_call_write_results_local(
        self, mock_write_local, mock_compare, tmp_project: Path
    ):
        baseline_dir = tmp_project / ".scai" / "baselines"
        _write_baselines_for_simple_proc(baseline_dir)

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

        mock_write_local.assert_not_called()

    @patch("test_runner.validate.runner.compare_results", side_effect=_mock_compare_pass)
    def test_output_dir_files_created_on_disk(
        self, mock_compare, tmp_project: Path, tmp_path: Path
    ):
        baseline_dir = tmp_project / ".scai" / "baselines"
        _write_baselines_for_simple_proc(baseline_dir)
        out_dir = tmp_path / "validate-output"

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

        assert (out_dir / "results.json").exists()


class TestRewriteDatabase:
    """Unit tests for _rewrite_database."""

    def test_different_db_and_schema(self):
        sql = "CALL MY_DB.MY_SCHEMA.MY_PROC(1, 'x')"
        result = _rewrite_database(sql, "MY_DB", "MY_DB_REPLAY")
        assert result == "CALL MY_DB_REPLAY.MY_SCHEMA.MY_PROC(1, 'x')"

    def test_same_db_and_schema_name(self):
        sql = "CALL ECOMMERCE.ECOMMERCE.UPDATE_CUSTOMER_REVIEW(1)"
        result = _rewrite_database(sql, "ECOMMERCE", "ECOMMERCE_REPLAY")
        assert result == "CALL ECOMMERCE_REPLAY.ECOMMERCE.UPDATE_CUSTOMER_REVIEW(1)"

    def test_case_insensitive(self):
        sql = "CALL ecommerce.ecommerce.update_customer_review(1)"
        result = _rewrite_database(sql, "ECOMMERCE", "ECOMMERCE_REPLAY")
        assert result == "CALL ECOMMERCE_REPLAY.ecommerce.update_customer_review(1)"

    def test_quoted_identifiers_unchanged(self):
        # Quoted identifiers are safe because the lookahead (?=\.) fails:
        # each identifier is followed by '"', not '.'.
        sql = 'CALL "ECOMMERCE"."ECOMMERCE"."UPDATE_CUSTOMER_REVIEW"(1)'
        result = _rewrite_database(sql, "ECOMMERCE", "ECOMMERCE_REPLAY")
        assert result == sql

    def test_multiple_references(self):
        sql = (
            "CALL ECOMMERCE.ECOMMERCE.PROC_A(); "
            "SELECT * FROM ECOMMERCE.ECOMMERCE.TABLE_B"
        )
        result = _rewrite_database(sql, "ECOMMERCE", "ECOMMERCE_REPLAY")
        assert result == (
            "CALL ECOMMERCE_REPLAY.ECOMMERCE.PROC_A(); "
            "SELECT * FROM ECOMMERCE_REPLAY.ECOMMERCE.TABLE_B"
        )

    def test_two_part_name_is_rewritten(self):
        sql = "CALL ECOMMERCE.MY_PROC()"
        result = _rewrite_database(sql, "ECOMMERCE", "ECOMMERCE_REPLAY")
        assert result == "CALL ECOMMERCE_REPLAY.MY_PROC()"
