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

Covers capture steps rendering, stage upload, error handling, and edge
cases not tested in test_runner.py.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from test_runner.common.config import ReplayConfiguration, TestRunnerConfig
from test_runner.common.container import TestRunnerContainer
from test_runner.common.database_dialect import DatabaseDialect
from test_runner.common.database_executor import DatabaseExecutor
from test_runner.common.models import TestCaseResult, ValidationSteps
from test_runner.capture.runner import run_capture, _capture_test_file


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

class _FakeExecutor(DatabaseExecutor):
    """A fake executor that returns canned results."""

    def __init__(self, result: TestCaseResult | None = None) -> None:
        self._result = result or TestCaseResult(
            result_sets=[[{"Id": 1, "Name": "Widget"}]],
            row_counts=[1],
            success=True,
        )
        self.calls: list[tuple[str, ValidationSteps | None]] = []
        self.fetch_stmt_calls: list[list[str] | None] = []

    def close(self) -> None:
        pass

    def execute(self, sql: str, steps=None, fetch_stmts=None) -> TestCaseResult:
        self.calls.append((sql, steps))
        self.fetch_stmt_calls.append(fetch_stmts)
        return self._result


def _make_config(**overrides) -> TestRunnerConfig:
    defaults = dict(
        source_dialect=DatabaseDialect.SQL_SERVER,
        source_connection_raw={
            "host": "localhost",
            "database": "TestDB",
            "username": "sa",
            "password": "secret",
        },
    )
    defaults.update(overrides)
    return TestRunnerConfig(**defaults)


def _make_registry(executor=None, dialect=DatabaseDialect.SQL_SERVER):
    """Build a factory registry, optionally mocking the source executor."""
    container = TestRunnerContainer()
    registry = container.factory_registry()
    if executor is not None:
        factory = registry[dialect]
        factory.build_config = MagicMock(return_value=MagicMock())
        factory.create_executor = MagicMock(return_value=executor)
    return registry


# ---------------------------------------------------------------------------
# Capture steps rendering
# ---------------------------------------------------------------------------

class TestCaptureStepsRendering:
    """Verify that capture steps are rendered and passed to executor."""

    def test_executor_receives_rendered_steps(self, tmp_project: Path):
        fake = _FakeExecutor()

        run_capture(
            config=_make_config(),
            factory_registry=_make_registry(fake),
            project_root=tmp_project,
        )

        # All calls should have steps parameter
        for sql, steps in fake.calls:
            assert steps is not None
            assert isinstance(steps, ValidationSteps)
            assert steps.run  # run template should be present


# ---------------------------------------------------------------------------
# Error scenarios
# ---------------------------------------------------------------------------

class TestCaptureErrorScenarios:
    def test_no_source_connection_raises(self, tmp_project: Path):
        config = _make_config(source_connection_raw={})
        with pytest.raises(ValueError, match="Capture requires"):
            run_capture(
                config=config,
                factory_registry=_make_registry(),
                project_root=tmp_project,
            )

    def test_unknown_dialect_raises(self, tmp_project: Path):
        config = _make_config(source_dialect=DatabaseDialect.BIG_QUERY)
        with pytest.raises(ValueError, match="No executor factory"):
            run_capture(
                config=config,
                factory_registry=_make_registry(),
                project_root=tmp_project,
            )

    def test_factory_mock_skips_real_connection(self, tmp_project: Path):
        """When factory is mocked, source_connection_raw content doesn't matter."""
        config = _make_config()
        stats = run_capture(
            config=config,
            factory_registry=_make_registry(_FakeExecutor()),
            project_root=tmp_project,
        )
        assert stats.total > 0


# ---------------------------------------------------------------------------
# Format literal override
# ---------------------------------------------------------------------------

class TestFormatLiteralOverride:
    def test_factory_literal_formatter_used(self, tmp_project: Path):
        """The factory's create_literal_formatter is used for SQL rendering."""
        fake = _FakeExecutor()
        registry = _make_registry(fake)

        run_capture(
            config=_make_config(),
            factory_registry=registry,
            project_root=tmp_project,
        )

        # Parameterized procedures should have been rendered (calls > 0)
        assert len(fake.calls) > 0


# ---------------------------------------------------------------------------
# Default baseline directory
# ---------------------------------------------------------------------------

class TestDefaultBaselineDir:
    def test_default_dir_is_scai_baselines(self, tmp_project: Path):
        stats = run_capture(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor()),
            project_root=tmp_project,
        )

        for path_str in stats.baseline_files:
            assert ".scai" in path_str
            assert "baselines" in path_str


# ---------------------------------------------------------------------------
# Executor lifecycle
# ---------------------------------------------------------------------------

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

        run_capture(
            config=_make_config(),
            factory_registry=_make_registry(fake),
            project_root=tmp_project,
        )

        fake.close.assert_called_once()


# ---------------------------------------------------------------------------
# Baseline content verification
# ---------------------------------------------------------------------------

class TestBaselineContent:
    def test_baselines_contain_correct_fields(self, tmp_project: Path):
        stats = run_capture(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor()),
            project_root=tmp_project,
        )

        for path_str in stats.baseline_files:
            data = json.loads(Path(path_str).read_text())
            assert "procedure" in data
            assert "parameters" in data
            assert "captured_at" in data
            assert "result_sets" in data
            assert "row_counts" in data
            assert "success" in data
            assert "params_hash" in data

    def test_error_baselines_contain_error_field(self, tmp_project: Path):
        result = TestCaseResult(
            result_sets=[], row_counts=[], success=False,
            error="Some error",
        )
        stats = run_capture(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor(result)),
            project_root=tmp_project,
        )

        for path_str in stats.baseline_files:
            data = json.loads(Path(path_str).read_text())
            assert data["success"] is False
            assert data["error"] == "Some error"


# ---------------------------------------------------------------------------
# Output parameters in baselines
# ---------------------------------------------------------------------------

class TestOutputParamsInBaseline:
    def test_output_params_and_types_saved_in_baseline(
        self, tmp_project_with_output_params: Path,
    ):
        result = TestCaseResult(
            result_sets=[],
            row_counts=[],
            success=True,
            output_params={"productName": "Gizmo", "unitPrice": "14.75"},
            output_param_types={"PRODUCTNAME": "VARCHAR", "UNITPRICE": "NUMBER"},
        )
        fake = _FakeExecutor(result)

        stats = run_capture(
            config=_make_config(),
            factory_registry=_make_registry(fake),
            project_root=tmp_project_with_output_params,
        )

        details_baselines = [
            p for p in stats.baseline_files if "GetProductDetails" in p
        ]
        assert len(details_baselines) == 1

        data = json.loads(Path(details_baselines[0]).read_text())
        assert data["output_params"] == {
            "productName": "Gizmo",
            "unitPrice": "14.75",
        }
        assert data["output_param_types"] == {
            "PRODUCTNAME": "VARCHAR",
            "UNITPRICE": "NUMBER",
        }


# ---------------------------------------------------------------------------
# Redshift output parameters in baselines
# ---------------------------------------------------------------------------

class TestRedshiftOutputParamsInBaseline:
    """Verify the Redshift scalar OUT param capture path end-to-end.

    Uses the ``redshift_scalar_output.yml`` fixture (fibonacci, OUT param
    ``result``).  The executor is a FakeExecutor so no real DB is needed.
    """

    def test_redshift_output_params_saved_in_baseline(
        self, tmp_project_with_redshift_output_params: Path,
    ):
        """output_params from a Redshift CALL must be written to the baseline JSON."""
        result = TestCaseResult(
            result_sets=[],
            row_counts=[],
            success=True,
            output_params={"result": 21},
            output_param_types={"result": "NUMBER"},
        )
        fake = _FakeExecutor(result)

        stats = run_capture(
            config=_make_config(source_dialect=DatabaseDialect.REDSHIFT),
            factory_registry=_make_registry(fake, dialect=DatabaseDialect.REDSHIFT),
            project_root=tmp_project_with_redshift_output_params,
        )

        fib_baselines = [p for p in stats.baseline_files if "fibonacci" in p]
        assert len(fib_baselines) == 1

        data = json.loads(Path(fib_baselines[0]).read_text())
        assert data["output_params"] == {"result": 21}
        assert "result_sets" in data

    def test_redshift_capture_sql_is_plain_call(
        self, tmp_project_with_redshift_output_params: Path,
    ):
        """Redshift capture SQL must be the bare CALL — no appended SELECT."""
        fake = _FakeExecutor()

        run_capture(
            config=_make_config(source_dialect=DatabaseDialect.REDSHIFT),
            factory_registry=_make_registry(fake, dialect=DatabaseDialect.REDSHIFT),
            project_root=tmp_project_with_redshift_output_params,
        )

        assert len(fake.calls) == 1
        sql, steps = fake.calls[0]
        assert sql == "CALL fibonacci(8)"
        assert "SELECT" not in sql
        assert steps.output_parameters == ["result"]


# ---------------------------------------------------------------------------
# Redshift REFCURSOR capture
# ---------------------------------------------------------------------------

class TestRedshiftCursorCapture:
    """Verify the Redshift INOUT REFCURSOR capture path end-to-end.

    Uses the ``redshift_cursor_output.yml`` fixture (get_sales_by_region,
    INOUT REFCURSOR ``rs_out``).  The executor is a FakeExecutor so no real
    DB is needed.

    Key assertions:
    - The capture SQL is the plain CALL (no BEGIN/END wrapper).
    - ``fetch_stmts`` contains ``FETCH ALL FROM <cursor_name>``.
    - ``steps.output_cursors`` is set so the executor knows this is a cursor.
    - The baseline JSON contains ``result_sets`` with the cursor rows.
    - ``output_params`` is absent (cursors produce result sets, not scalars).
    """

    def test_cursor_fetch_stmt_generated(
        self, tmp_project_with_redshift_cursor_output: Path,
    ):
        """The runner must generate a FETCH ALL statement for each output cursor."""
        fake = _FakeExecutor()

        run_capture(
            config=_make_config(source_dialect=DatabaseDialect.REDSHIFT),
            factory_registry=_make_registry(fake, dialect=DatabaseDialect.REDSHIFT),
            project_root=tmp_project_with_redshift_cursor_output,
        )

        assert len(fake.calls) == 1
        sql, steps = fake.calls[0]
        assert sql == "CALL get_sales_by_region('North', 'mycursor')"
        assert "SELECT" not in sql
        # output_cursors stores the raw template token; resolution to the actual
        # cursor name happens inside render_output_fetch_statements.
        assert steps.output_cursors == ["{1}"]
        assert fake.fetch_stmt_calls[0] == ["FETCH ALL FROM mycursor"]

    def test_cursor_result_saved_in_baseline(
        self, tmp_project_with_redshift_cursor_output: Path,
    ):
        """Rows from the FETCH must be written to result_sets in the baseline JSON."""
        result = TestCaseResult(
            result_sets=[[{"sellerid": 1, "amount": 100}]],
            row_counts=[1],
            success=True,
        )
        fake = _FakeExecutor(result)

        stats = run_capture(
            config=_make_config(source_dialect=DatabaseDialect.REDSHIFT),
            factory_registry=_make_registry(fake, dialect=DatabaseDialect.REDSHIFT),
            project_root=tmp_project_with_redshift_cursor_output,
        )

        assert len(stats.baseline_files) == 1
        data = json.loads(Path(stats.baseline_files[0]).read_text())
        assert data["result_sets"] == [[{"sellerid": 1, "amount": 100}]]
        assert data.get("output_params") is None


# ---------------------------------------------------------------------------
# DML-aware fake executor (with mock connection for capture_deltas)
# ---------------------------------------------------------------------------

class _MockCursor:
    """Minimal cursor that records executions."""

    def __init__(self) -> None:
        self.executed: list[str] = []
        self.description = None

    def execute(self, sql: str, *args: Any) -> None:
        self.executed.append(sql)

    def fetchall(self) -> list:
        return []

    def close(self) -> None:
        pass

    def __enter__(self) -> "_MockCursor":
        return self

    def __exit__(self, *args: Any) -> None:
        self.close()


class _MockConnection:
    """Minimal connection that tracks autocommit and cursors."""

    def __init__(self) -> None:
        self.autocommit = False
        self.cursors: list[_MockCursor] = []

    def cursor(self) -> _MockCursor:
        c = _MockCursor()
        self.cursors.append(c)
        return c


class _FakeDmlExecutor(DatabaseExecutor):
    """Executor with a mock connection so the DML capture path can be exercised."""

    def __init__(self, result: TestCaseResult | None = None) -> None:
        self._result = result or TestCaseResult(
            result_sets=[], row_counts=[], success=True,
        )
        self._connection = _MockConnection()
        self._connector_obj = MagicMock()
        self._connector_obj.connection = self._connection

    @property
    def connector(self) -> Any:
        return self._connector_obj

    def close(self) -> None:
        pass

    def execute(self, sql: str, steps=None, fetch_stmts=None) -> TestCaseResult:
        return self._result


# ---------------------------------------------------------------------------
# Read-only vs read-write (DML) capture paths
# ---------------------------------------------------------------------------

class TestReadOnlyCapture:
    """Read-only sprocs must produce baselines without table_deltas."""

    def test_readonly_baseline_has_no_table_deltas(self, tmp_project: Path):
        stats = run_capture(
            config=_make_config(),
            factory_registry=_make_registry(_FakeExecutor()),
            project_root=tmp_project,
        )

        for path_str in stats.baseline_files:
            data = json.loads(Path(path_str).read_text())
            assert "table_deltas" not in data, (
                f"Read-only baseline should not have table_deltas: {path_str}"
            )


class TestDmlCapture:
    """DML (read-write) sprocs must produce baselines with table_deltas."""

    _MOCK_DELTAS = {
        "WAREHOUSE.COLORS": {
            "table": "WAREHOUSE.COLORS",
            "strategy_used": "full_snapshot",
            "inserts": [{"COLORID": 99, "COLORNAME": "Crimson"}],
            "deletes": [],
            "updates": [],
        }
    }

    def test_dml_baseline_has_table_deltas(self, tmp_project_with_dml: Path):
        fake = _FakeDmlExecutor()

        with patch(
            "test_runner.capture.runner.capture_deltas",
            return_value=(fake.execute(""), self._MOCK_DELTAS, {}),
        ):
            stats = run_capture(
                config=_make_config(),
                factory_registry=_make_registry(fake),
                project_root=tmp_project_with_dml,
            )

        dml_baselines = [p for p in stats.baseline_files if "InsertColor" in p]
        assert len(dml_baselines) == 2

        for path_str in dml_baselines:
            data = json.loads(Path(path_str).read_text())
            assert "table_deltas" in data, (
                f"DML baseline should have table_deltas: {path_str}"
            )
            assert "WAREHOUSE.COLORS" in data["table_deltas"]
            assert data["table_deltas"]["WAREHOUSE.COLORS"]["inserts"][0]["COLORNAME"] == "Crimson"

    def test_dml_capture_calls_capture_deltas(self, tmp_project_with_dml: Path):
        """capture_deltas must be invoked for DML-classified test files."""
        fake = _FakeDmlExecutor()

        with patch(
            "test_runner.capture.runner.capture_deltas",
            return_value=(fake.execute(""), self._MOCK_DELTAS, {}),
        ) as mock_cd:
            run_capture(
                config=_make_config(),
                factory_registry=_make_registry(fake),
                project_root=tmp_project_with_dml,
            )

        assert mock_cd.call_count == 2, "capture_deltas should be called once per DML test case"

    def test_readonly_sprocs_skip_capture_deltas(self, tmp_project: Path):
        """capture_deltas must NOT be invoked for read-only test files."""
        with patch(
            "test_runner.capture.runner.capture_deltas",
        ) as mock_cd:
            run_capture(
                config=_make_config(),
                factory_registry=_make_registry(_FakeExecutor()),
                project_root=tmp_project,
            )

        mock_cd.assert_not_called()

    def test_dml_capture_failure_skips_case(self, tmp_project_with_dml: Path):
        """If capture_deltas throws, the case is skipped (no naked execution)
        to preserve source database integrity."""
        fake = _FakeDmlExecutor(TestCaseResult(
            result_sets=[[{"Id": 1}]], row_counts=[1], success=True,
        ))

        with patch(
            "test_runner.capture.runner.capture_deltas",
            side_effect=RuntimeError("snapshot failed"),
        ):
            stats = run_capture(
                config=_make_config(),
                factory_registry=_make_registry(fake),
                project_root=tmp_project_with_dml,
            )

        dml_baselines = [p for p in stats.baseline_files if "InsertColor" in p]
        assert len(dml_baselines) == 0, "Failed delta capture should not produce baselines"
        assert stats.failed == 2, "Both DML cases should be counted as failed"
