"""Tests for the pipeline testing framework."""

from __future__ import annotations

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

import pytest
import yaml

from pycharter.etl_generator.protocols import Extractor, Loader
from pycharter.etl_generator.result import LoadResult, PipelineResult
from pycharter.etl_generator.testing import (
    MockExtractor,
    MockLoader,
    PipelineTestHarness,
    TestFixture,
    assert_field_values,
    assert_fields_present,
    assert_no_field,
    assert_record_count,
    assert_records_match,
    assert_schema_shape,
    load_fixture,
    load_test_fixture,
    validate_pipeline_config,
)

# =============================================================================
# MockExtractor
# =============================================================================


class TestMockExtractor:
    """Tests for MockExtractor."""

    @pytest.mark.asyncio
    async def test_flat_data(self):
        """Flat data is chunked by batch_size."""
        data = [{"id": i} for i in range(5)]
        extractor = MockExtractor(data, batch_size=2)

        batches = [batch async for batch in extractor.extract()]

        assert len(batches) == 3
        assert batches[0] == [{"id": 0}, {"id": 1}]
        assert batches[1] == [{"id": 2}, {"id": 3}]
        assert batches[2] == [{"id": 4}]

    @pytest.mark.asyncio
    async def test_default_batch_size(self):
        """Default batch_size is 1000."""
        data = [{"id": i} for i in range(10)]
        extractor = MockExtractor(data)

        batches = [batch async for batch in extractor.extract()]

        assert len(batches) == 1
        assert len(batches[0]) == 10

    @pytest.mark.asyncio
    async def test_pre_batched_data(self):
        """Pre-batched data is yielded as-is."""
        batched = [[{"id": 1}], [{"id": 2}, {"id": 3}]]
        extractor = MockExtractor(batched)

        batches = [batch async for batch in extractor.extract()]

        assert batches == batched

    @pytest.mark.asyncio
    async def test_empty_data(self):
        """Empty data yields nothing."""
        extractor = MockExtractor([])

        batches = [batch async for batch in extractor.extract()]

        assert batches == []

    def test_protocol_compliance(self):
        """MockExtractor satisfies the Extractor protocol."""
        extractor = MockExtractor([{"id": 1}])
        assert isinstance(extractor, Extractor)


# =============================================================================
# MockLoader
# =============================================================================


class TestMockLoader:
    """Tests for MockLoader."""

    @pytest.mark.asyncio
    async def test_single_batch(self):
        """Single batch is captured."""
        loader = MockLoader()
        data = [{"id": 1}, {"id": 2}]

        result = await loader.load(data)

        assert result.success is True
        assert result.rows_loaded == 2
        assert loader.loaded_records == data
        assert loader.load_calls == [data]

    @pytest.mark.asyncio
    async def test_multiple_batches(self):
        """Multiple batches accumulate records."""
        loader = MockLoader()

        await loader.load([{"id": 1}])
        await loader.load([{"id": 2}])

        assert loader.loaded_records == [{"id": 1}, {"id": 2}]
        assert len(loader.load_calls) == 2

    @pytest.mark.asyncio
    async def test_success_result(self):
        """Returns success LoadResult."""
        loader = MockLoader()
        result = await loader.load([{"id": 1}])

        assert result.success is True
        assert result.rows_loaded == 1

    @pytest.mark.asyncio
    async def test_simulate_failure(self):
        """Simulated failure returns error result."""
        loader = MockLoader(simulate_failure=True, failure_error="DB down")

        result = await loader.load([{"id": 1}])

        assert result.success is False
        assert result.error == "DB down"
        assert result.rows_failed == 1
        assert loader.loaded_records == []  # Not captured on failure

    @pytest.mark.asyncio
    async def test_reset(self):
        """Reset clears all state."""
        loader = MockLoader()
        await loader.load([{"id": 1}])

        loader.reset()

        assert loader.loaded_records == []
        assert loader.load_calls == []

    def test_protocol_compliance(self):
        """MockLoader satisfies the Loader protocol."""
        loader = MockLoader()
        assert isinstance(loader, Loader)


# =============================================================================
# PipelineTestHarness
# =============================================================================


class TestPipelineTestHarness:
    """Tests for PipelineTestHarness."""

    @pytest.mark.asyncio
    async def test_programmatic_pipeline(self):
        """Runs a programmatic pipeline with mock I/O."""
        from pycharter.etl_generator.pipeline import Pipeline

        pipeline = Pipeline(name="test")
        fixture = [{"id": 1, "name": "Alice"}]

        harness = PipelineTestHarness(pipeline, fixture_data=fixture)
        result = await harness.run()

        assert result.success is True
        assert result.rows_extracted == 1
        assert result.rows_loaded == 1
        assert harness.loaded_records == fixture

    @pytest.mark.asyncio
    async def test_transforms_applied(self):
        """Transforms are applied to fixture data."""
        from pycharter.etl_generator.pipeline import Pipeline
        from pycharter.etl_generator.transformers import Rename

        pipeline = Pipeline(name="test") | Rename({"name": "full_name"})
        fixture = [{"id": 1, "name": "Alice"}]

        harness = PipelineTestHarness(pipeline, fixture_data=fixture)
        result = await harness.run()

        assert result.success is True
        assert harness.loaded_records == [{"id": 1, "full_name": "Alice"}]

    @pytest.mark.asyncio
    async def test_empty_data(self):
        """Empty fixture data produces zero counts."""
        from pycharter.etl_generator.pipeline import Pipeline

        pipeline = Pipeline(name="test")
        harness = PipelineTestHarness(pipeline, fixture_data=[])
        result = await harness.run()

        assert result.success is True
        assert result.rows_extracted == 0
        assert result.rows_loaded == 0

    @pytest.mark.asyncio
    async def test_result_counts(self):
        """Result counts reflect processed data."""
        from pycharter.etl_generator.pipeline import Pipeline

        pipeline = Pipeline(name="test")
        fixture = [{"id": i} for i in range(5)]

        harness = PipelineTestHarness(pipeline, fixture_data=fixture)
        result = await harness.run()

        assert result.rows_extracted == 5
        assert result.rows_loaded == 5
        assert result.batches_processed == 1

    @pytest.mark.asyncio
    async def test_load_calls_tracked(self):
        """Individual load calls are tracked."""
        from pycharter.etl_generator.pipeline import Pipeline

        pipeline = Pipeline(name="test")
        fixture = [{"id": i} for i in range(5)]

        harness = PipelineTestHarness(pipeline, fixture_data=fixture, batch_size=2)
        result = await harness.run()

        assert result.batches_processed == 3
        assert len(harness.load_calls) == 3


# =============================================================================
# Assertion Helpers
# =============================================================================


class TestAssertRecordsMatch:
    """Tests for assert_records_match."""

    def test_matching(self):
        """Matching records pass."""
        records = [{"id": 1}, {"id": 2}]
        assert_records_match(records, [{"id": 1}, {"id": 2}])

    def test_mismatched(self):
        """Mismatched records raise AssertionError."""
        with pytest.raises(AssertionError, match="Record mismatch at index 0"):
            assert_records_match([{"id": 1}], [{"id": 2}])

    def test_count_mismatch(self):
        """Different counts raise AssertionError."""
        with pytest.raises(AssertionError, match="Record count mismatch"):
            assert_records_match([{"id": 1}], [{"id": 1}, {"id": 2}])

    def test_order_insensitive(self):
        """Order-insensitive comparison."""
        actual = [{"id": 2}, {"id": 1}]
        expected = [{"id": 1}, {"id": 2}]
        assert_records_match(actual, expected, order_matters=False)

    def test_subset_mode(self):
        """Subset mode checks containment."""
        actual = [{"id": 1}, {"id": 2}, {"id": 3}]
        expected = [{"id": 1}, {"id": 3}]
        assert_records_match(actual, expected, subset=True)

    def test_subset_missing(self):
        """Subset mode fails on missing record."""
        with pytest.raises(AssertionError, match="not found in actual"):
            assert_records_match([{"id": 1}], [{"id": 99}], subset=True)


class TestAssertRecordCount:
    """Tests for assert_record_count."""

    def test_correct_count(self):
        """Matching count passes."""
        result = PipelineResult(rows_loaded=42)
        assert_record_count(result, 42)

    def test_wrong_count(self):
        """Wrong count raises AssertionError."""
        result = PipelineResult(rows_loaded=10)
        with pytest.raises(AssertionError, match="Row count mismatch"):
            assert_record_count(result, 20)


class TestAssertFieldsPresent:
    """Tests for assert_fields_present."""

    def test_all_present(self):
        """All fields present passes."""
        records = [{"id": 1, "name": "Alice"}]
        assert_fields_present(records, ["id", "name"])

    def test_missing_field(self):
        """Missing field raises AssertionError."""
        records = [{"id": 1}]
        with pytest.raises(AssertionError, match="Field 'name' missing"):
            assert_fields_present(records, ["id", "name"])


class TestAssertNoField:
    """Tests for assert_no_field."""

    def test_absent(self):
        """Field absent passes."""
        records = [{"id": 1}]
        assert_no_field(records, "deleted_field")

    def test_present(self):
        """Field present raises AssertionError."""
        records = [{"id": 1, "secret": "x"}]
        with pytest.raises(AssertionError, match="Field 'secret' found"):
            assert_no_field(records, "secret")


class TestAssertFieldValues:
    """Tests for assert_field_values."""

    def test_matching(self):
        """Matching values pass."""
        records = [{"id": 1}, {"id": 2}]
        assert_field_values(records, "id", [1, 2])

    def test_mismatched(self):
        """Mismatched values raise AssertionError."""
        records = [{"id": 1}, {"id": 3}]
        with pytest.raises(AssertionError, match="values mismatch"):
            assert_field_values(records, "id", [1, 2])


class TestAssertSchemaShape:
    """Tests for assert_schema_shape."""

    def test_matching(self):
        """Matching types pass."""
        records = [{"id": 1, "name": "Alice"}]
        assert_schema_shape(records, {"id": int, "name": str})

    def test_wrong_type(self):
        """Wrong type raises AssertionError."""
        records = [{"id": "not_an_int"}]
        with pytest.raises(AssertionError, match="expected int, got str"):
            assert_schema_shape(records, {"id": int})

    def test_none_allowed(self):
        """None values are allowed for any type."""
        records = [{"id": None}]
        assert_schema_shape(records, {"id": int})

    def test_missing_field(self):
        """Missing field in schema raises AssertionError."""
        records = [{"name": "Alice"}]
        with pytest.raises(AssertionError, match="Field 'id' missing"):
            assert_schema_shape(records, {"id": int})


# =============================================================================
# Fixture Loading
# =============================================================================


class TestLoadFixture:
    """Tests for load_fixture and load_test_fixture."""

    def test_yaml_list(self, tmp_path: Path):
        """Load a YAML file with a top-level list."""
        fixture_file = tmp_path / "data.yaml"
        fixture_file.write_text(yaml.dump([{"id": 1}, {"id": 2}]))

        records = load_fixture(fixture_file)

        assert records == [{"id": 1}, {"id": 2}]

    def test_records_key(self, tmp_path: Path):
        """Load a YAML file with 'records' key."""
        data = {"name": "users", "records": [{"id": 1}]}
        fixture_file = tmp_path / "data.yaml"
        fixture_file.write_text(yaml.dump(data))

        fixture = load_test_fixture(fixture_file)

        assert fixture.name == "users"
        assert list(fixture.records) == [{"id": 1}]

    def test_batches_key(self, tmp_path: Path):
        """Load a YAML file with 'batches' key."""
        data = {"batches": [[{"id": 1}], [{"id": 2}]]}
        fixture_file = tmp_path / "data.yaml"
        fixture_file.write_text(yaml.dump(data))

        fixture = load_test_fixture(fixture_file)

        assert list(fixture.records) == [{"id": 1}, {"id": 2}]
        assert len(fixture.batches) == 2

    def test_json(self, tmp_path: Path):
        """Load a JSON fixture file."""
        fixture_file = tmp_path / "data.json"
        fixture_file.write_text(json.dumps([{"id": 1}]))

        records = load_fixture(fixture_file)

        assert records == [{"id": 1}]

    def test_missing_file(self):
        """Missing file raises FileNotFoundError."""
        with pytest.raises(FileNotFoundError):
            load_fixture("/nonexistent/data.yaml")

    def test_metadata(self, tmp_path: Path):
        """Extra keys are captured as metadata."""
        data = {"name": "test", "records": [{"id": 1}], "version": "1.0"}
        fixture_file = tmp_path / "data.yaml"
        fixture_file.write_text(yaml.dump(data))

        fixture = load_test_fixture(fixture_file)

        assert fixture.metadata == {"version": "1.0"}


# =============================================================================
# Config Validation Helper
# =============================================================================


class TestValidatePipelineConfig:
    """Tests for validate_pipeline_config."""

    def test_valid_dict(self):
        """Valid config dict passes validation."""
        config = {
            "extract": {"type": "http", "url": "https://api.example.com"},
            "load": {"type": "file", "path": "output.json"},
        }

        is_valid, errors = validate_pipeline_config(config)

        assert is_valid is True
        assert errors == []

    def test_invalid_dict(self):
        """Invalid config dict reports errors."""
        config = {
            "extract": {"type": "unknown_type"},
            "load": {"type": "file", "path": "output.json"},
        }

        is_valid, errors = validate_pipeline_config(config)

        assert is_valid is False
        assert len(errors) > 0
        assert errors[0]["section"] == "extract"

    def test_file_path(self, tmp_path: Path):
        """Validates a config from a file path."""
        config = {
            "extract": {"type": "http", "url": "https://api.example.com"},
            "load": {"type": "file", "path": "output.json"},
        }
        config_file = tmp_path / "pipeline.yaml"
        config_file.write_text(yaml.dump(config))

        is_valid, errors = validate_pipeline_config(str(config_file))

        assert is_valid is True

    def test_dir_path(self, tmp_path: Path):
        """Validates a config directory."""
        extract_file = tmp_path / "extract.yaml"
        extract_file.write_text(
            yaml.dump({"type": "http", "url": "https://api.example.com"})
        )
        load_file = tmp_path / "load.yaml"
        load_file.write_text(yaml.dump({"type": "file", "path": "output.json"}))

        is_valid, errors = validate_pipeline_config(tmp_path)

        assert is_valid is True

    def test_missing_file(self):
        """Missing file returns error."""
        is_valid, errors = validate_pipeline_config("/nonexistent/config.yaml")

        assert is_valid is False
        assert errors[0]["section"] == "file"
