"""Tests for OpenLineage event emitter module.

This module contains unit and integration tests for constructing OpenLineage
events with dataQualityAssertions facets and emitting them to Correlator.

Test Coverage:
    - TestGroupTestsByDataset: Group tests by target dataset
    - TestCreateWrappingEvent: Build START/COMPLETE/FAIL wrapping events
    - TestConstructTestEvents: Build OpenLineage events with test results
    - TestEmitEvents: Send events to OpenLineage backend
    - TestConstructLineageEvent: Build lineage events for single model
    - TestConstructLineageEvents: Build lineage events for multiple models
    - TestEmitEventsOpenLineageConsumerCompatibility: Consumer compatibility
"""

import json
import uuid
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from unittest.mock import MagicMock, patch

import attr
import pytest
import requests
from openlineage.client.event_v2 import RunState

from dbt_correlator.emitter import (
    ParentRunMetadata,
    _build_parent_facet,
    _serialize_event_with_extended_fields,
    construct_lineage_event,
    construct_lineage_events,
    construct_test_events,
    create_wrapping_event,
    emit_events,
    group_tests_by_dataset,
)
from dbt_correlator.parser import (
    DatasetInfo,
    Manifest,
    ModelExecutionResult,
    ModelLineage,
    RunResults,
    RunResultsMetadata,
    TestResult,
    parse_manifest,
    parse_run_results,
)

# Path to test fixtures
FIXTURES_DIR = Path(__file__).parent / "fixtures"
RUN_RESULTS_PATH = FIXTURES_DIR / "dbt_test_results.json"
MANIFEST_PATH = FIXTURES_DIR / "manifest.json"


@pytest.fixture
def sample_run_results():
    """Load sample run_results.json from fixtures."""
    return parse_run_results(str(RUN_RESULTS_PATH))


@pytest.fixture
def sample_manifest():
    """Load sample manifest.json from fixtures."""
    return parse_manifest(str(MANIFEST_PATH))


@pytest.fixture
def minimal_test_data():
    """Create minimal test data for emit_events() tests.

    Returns a tuple of (run_results, manifest) with one passing test.
    Used by emit_events() tests that need minimal valid data to construct events.
    """
    run_results = RunResults(
        metadata=RunResultsMetadata(
            generated_at=datetime.now(),
            invocation_id="test-invocation-id",
            dbt_version="1.10.15",
            elapsed_time=0.0,
        ),
        results=[
            TestResult(
                unique_id="test.pkg.not_null_test.abc123",
                status="pass",
                failures=0,
                message=None,
                execution_time_seconds=0.1,
            )
        ],
    )

    manifest = Manifest(
        metadata={
            "generated_at": datetime.now().isoformat(),
            "dbt_schema_version": "v12",
        },
        nodes={
            "test.pkg.not_null_test.abc123": {
                "unique_id": "test.pkg.not_null_test.abc123",
                "package_name": "pkg",
                "test_metadata": {"name": "not_null", "kwargs": {"column_name": "id"}},
                "refs": [{"name": "test_model"}],
            },
            "model.pkg.test_model": {
                "unique_id": "model.pkg.test_model",
                "database": "test_db",
                "schema": "test_schema",
                "name": "test_model",
            },
        },
        sources={},
    )

    return run_results, manifest


@pytest.fixture
def sample_model_lineage():
    """Create sample ModelLineage for testing lineage event construction."""
    return ModelLineage(
        unique_id="model.jaffle_shop.customers",
        name="customers",
        inputs=[
            DatasetInfo(namespace="duckdb://jaffle_shop", name="main.stg_customers"),
            DatasetInfo(namespace="duckdb://jaffle_shop", name="main.stg_orders"),
        ],
        output=DatasetInfo(namespace="duckdb://jaffle_shop", name="main.customers"),
    )


@pytest.fixture
def sample_model_execution_result():
    """Create sample ModelExecutionResult for testing runtime metrics."""
    return ModelExecutionResult(
        unique_id="model.jaffle_shop.customers",
        status="success",
        execution_time_seconds=1.5,
        rows_affected=1500,
        message="OK",
    )


@pytest.fixture
def sample_model_lineages():
    """Create list of sample ModelLineage objects for batch testing."""
    return [
        ModelLineage(
            unique_id="model.jaffle_shop.stg_customers",
            name="stg_customers",
            inputs=[
                DatasetInfo(
                    namespace="duckdb://jaffle_shop", name="main.raw_customers"
                ),
            ],
            output=DatasetInfo(
                namespace="duckdb://jaffle_shop", name="main.stg_customers"
            ),
        ),
        ModelLineage(
            unique_id="model.jaffle_shop.customers",
            name="customers",
            inputs=[
                DatasetInfo(
                    namespace="duckdb://jaffle_shop", name="main.stg_customers"
                ),
            ],
            output=DatasetInfo(namespace="duckdb://jaffle_shop", name="main.customers"),
        ),
    ]


# ============================================================================
# Tests for group_tests_by_dataset()
# ============================================================================


@pytest.mark.unit
class TestGroupTestsByDataset:
    """Tests for grouping tests by dataset URN."""

    def test_groups_by_dataset_urn(self, sample_run_results, sample_manifest) -> None:
        """Test that tests are grouped by dataset URN.

        Validates that:
            - Tests on same dataset are grouped together
            - Tests on different datasets are separated
            - Dataset URN format is: namespace:name
            - Each group contains list of test results

        Note:
            Uses real fixtures from jaffle shop with 27 tests.
        """
        grouped = group_tests_by_dataset(sample_run_results, sample_manifest)

        # Should have multiple datasets (jaffle shop has tests on multiple models)
        assert isinstance(grouped, dict)
        assert len(grouped) > 0

        # Each key should be dataset URN string
        for dataset_urn, tests in grouped.items():
            assert isinstance(dataset_urn, str)
            assert ":" in dataset_urn, "Dataset URN should be 'namespace:name' format"

            # Each value should be list of test results
            assert isinstance(tests, list)
            assert len(tests) > 0

            # Each test should have required fields
            for test in tests:
                assert "unique_id" in test
                assert "status" in test
                assert test["status"] in {"pass", "fail", "error", "skipped", "warn"}

    def test_multiple_tests_on_same_dataset(
        self, sample_run_results, sample_manifest
    ) -> None:
        """Test that multiple tests on same dataset are grouped together.

        Validates that:
            - Tests referencing same model are in same group
            - Group contains all relevant tests
            - No duplicate datasets created

        Note:
            Jaffle shop has models like 'customers' with multiple tests
            (e.g., unique, not_null tests on different columns).
        """
        grouped = group_tests_by_dataset(sample_run_results, sample_manifest)

        # Find a dataset with multiple tests (common in jaffle shop)
        datasets_with_multiple_tests = {
            urn: tests for urn, tests in grouped.items() if len(tests) > 1
        }

        assert (
            len(datasets_with_multiple_tests) > 0
        ), "Should have datasets with multiple tests"

        # Verify one dataset has multiple tests grouped correctly
        _dataset_urn, tests = next(iter(datasets_with_multiple_tests.items()))
        assert len(tests) >= 2

        # All tests should reference the same dataset URN
        for test in tests:
            assert isinstance(test["unique_id"], str)
            assert test["unique_id"].startswith("test.")

    def test_empty_results(self) -> None:
        """Test grouping with no test results.

        Validates that:
            - Empty results return empty dictionary
            - No errors are raised
            - Handles gracefully
        """
        # Create empty run_results
        empty_run_results = RunResults(
            metadata=RunResultsMetadata(
                generated_at=datetime.now(),
                invocation_id="test-invocation-id",
                dbt_version="1.10.15",
                elapsed_time=0.0,
            ),
            results=[],
        )

        # Create minimal manifest
        empty_manifest = Manifest(
            metadata={
                "generated_at": datetime.now().isoformat(),
                "dbt_schema_version": "v12",
            },
            nodes={},
            sources={},
        )

        grouped = group_tests_by_dataset(empty_run_results, empty_manifest)

        assert isinstance(grouped, dict)
        assert len(grouped) == 0

    def test_includes_test_metadata(self, sample_run_results, sample_manifest) -> None:
        """Test that grouped results include necessary test metadata.

        Validates that each test result contains:
            - unique_id (for looking up in manifest)
            - status (pass/fail)
            - failures count
            - message (if present)
            - execution_time_seconds (for durationMs in assertions)

        This metadata is needed for constructing OpenLineage assertions.
        """
        grouped = group_tests_by_dataset(sample_run_results, sample_manifest)

        # Pick first dataset with tests
        _dataset_urn, tests = next(iter(grouped.items()))

        for test in tests:
            # Required fields for assertion construction
            assert "unique_id" in test, "unique_id needed to lookup test in manifest"
            assert "status" in test, "status needed for assertion.success"
            assert (
                "execution_time_seconds" in test
            ), "execution_time_seconds needed for durationMs"

            # Optional but common fields
            # Note: failures might be 0 or None for passing tests
            # message might be None for passing tests


# ============================================================================
# Tests for create_wrapping_event()
# ============================================================================


@pytest.mark.unit
class TestCreateWrappingEvent:
    """Tests for creating wrapping events (START/COMPLETE/FAIL)."""

    def test_start_event(self) -> None:
        """Test creation of START wrapping event.

        Validates that:
            - eventType is START
            - Event has run.runId
            - Event has job namespace and name
            - Event has no inputs/outputs (wrapping event)
            - Timestamp is properly formatted
        """
        run_id = str(uuid.uuid4())
        timestamp = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)

        event = create_wrapping_event(
            event_type="START",
            run_id=run_id,
            job_name="dbt_test",
            job_namespace="dbt",
            timestamp=timestamp,
        )

        # Verify event structure
        assert event.eventType == RunState.START
        assert event.run.runId == run_id
        assert event.job.namespace == "dbt"
        assert event.job.name == "dbt_test"
        assert len(event.inputs) == 0
        assert len(event.outputs) == 0
        assert "correlator-io/correlator-dbt" in event.producer

    def test_complete_event(self) -> None:
        """Test creation of COMPLETE wrapping event.

        Validates that:
            - eventType is COMPLETE
            - Event structure matches START event
            - Can be used as terminal event
        """
        run_id = str(uuid.uuid4())
        timestamp = datetime(2024, 1, 1, 12, 5, 0, tzinfo=timezone.utc)

        event = create_wrapping_event(
            event_type="COMPLETE",
            run_id=run_id,
            job_name="dbt_test",
            job_namespace="dbt",
            timestamp=timestamp,
        )

        # Verify event structure
        assert event.eventType == RunState.COMPLETE
        assert event.run.runId == run_id
        assert event.job.namespace == "dbt"
        assert event.job.name == "dbt_test"
        assert len(event.inputs) == 0
        assert len(event.outputs) == 0

    def test_fail_event(self) -> None:
        """Test creation of FAIL wrapping event.

        Validates that:
            - eventType is FAIL
            - Can be used when dbt test execution fails
            - Event structure consistent with other wrapping events
        """
        run_id = str(uuid.uuid4())
        timestamp = datetime(2024, 1, 1, 12, 10, 0, tzinfo=timezone.utc)

        event = create_wrapping_event(
            event_type="FAIL",
            run_id=run_id,
            job_name="dbt_test",
            job_namespace="dbt",
            timestamp=timestamp,
        )

        # Verify event structure
        assert event.eventType == RunState.FAIL
        assert event.run.runId == run_id
        assert len(event.inputs) == 0
        assert len(event.outputs) == 0

    def test_wrapping_event_includes_parent_facet(self) -> None:
        """Test wrapping event has ParentRunFacet when parent params provided."""
        run_id = str(uuid.uuid4())
        timestamp = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)

        event = create_wrapping_event(
            event_type="START",
            run_id=run_id,
            job_name="jaffle_shop.test",
            job_namespace="dbt://demo",
            timestamp=timestamp,
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="airflow",
                job_name="demo_pipeline.dbt_test",
            ),
        )

        assert event.run.facets is not None
        assert "parent" in event.run.facets
        parent = event.run.facets["parent"]
        assert parent.run.runId == "550e8400-e29b-41d4-a716-446655440000"
        assert parent.job.namespace == "airflow"
        assert parent.job.name == "demo_pipeline.dbt_test"

    def test_wrapping_event_includes_parent_and_root(self) -> None:
        """Test wrapping event has both parent and root when all params provided."""
        run_id = str(uuid.uuid4())
        timestamp = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)

        event = create_wrapping_event(
            event_type="START",
            run_id=run_id,
            job_name="jaffle_shop.test",
            job_namespace="dbt://demo",
            timestamp=timestamp,
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="airflow",
                job_name="demo_pipeline.dbt_test",
                root_run_id="660f9500-f30c-52e5-b827-557766551111",
                root_job_namespace="airflow",
                root_job_name="demo_pipeline",
            ),
        )

        assert event.run.facets is not None
        parent = event.run.facets["parent"]
        assert parent.run.runId == "550e8400-e29b-41d4-a716-446655440000"
        assert parent.root is not None
        assert parent.root.run.runId == "660f9500-f30c-52e5-b827-557766551111"
        assert parent.root.job.name == "demo_pipeline"

    def test_wrapping_event_no_parent_when_standalone(self) -> None:
        """Test wrapping event has no ParentRunFacet when running standalone."""
        run_id = str(uuid.uuid4())
        timestamp = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)

        event = create_wrapping_event(
            event_type="START",
            run_id=run_id,
            job_name="jaffle_shop.test",
            job_namespace="dbt://demo",
            timestamp=timestamp,
        )

        assert event.run.facets is None or "parent" not in event.run.facets


# ============================================================================
# Tests for construct_test_events()
# ============================================================================


@pytest.mark.unit
class TestConstructTestEvents:
    """Tests for constructing OpenLineage test events with dataQualityAssertions."""

    def test_creates_valid_openlineage_event(
        self, sample_run_results, sample_manifest
    ) -> None:
        """Test construction of valid OpenLineage RunEvent.

        Validates that:
            - Returns single RunEvent (consolidated pattern)
            - eventType is "RUNNING" (intermediate event, not terminal)
            - run.runId matches provided run_id
            - job namespace and name are set correctly (no dataset suffix)
            - producer is "https://github.com/correlator-io/correlator-dbt/..."
            - schemaURL points to OpenLineage spec
            - inputs array contains multiple datasets with tests
            - outputs array is empty (tests validate inputs, not produce outputs)

        Note:
            Implementation uses consolidated pattern: single event with all
            test results across multiple input datasets.
        """
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt",
            job_name="dbt_test_run",
            run_id=sample_run_results.metadata.invocation_id,
        )

        # Consolidated pattern: single event with multiple inputs
        assert len(events) == 1, "Should return single consolidated event"
        event = events[0]

        # Check event structure (always RunEvent object)
        # Test events use RUNNING state (intermediate), not COMPLETE (terminal)
        assert event.eventType == RunState.RUNNING
        assert event.run.runId == sample_run_results.metadata.invocation_id
        assert event.job.namespace == "dbt"
        # Job name is NOT suffixed with dataset name (consolidated pattern)
        assert event.job.name == "dbt_test_run"
        assert "correlator-io/correlator-dbt" in event.producer
        assert isinstance(event.inputs, list)
        assert len(event.inputs) > 0  # Should have multiple datasets with tests
        assert isinstance(event.outputs, list)
        assert len(event.outputs) == 0  # Tests don't produce outputs

    def test_includes_data_quality_assertions_facet(
        self, sample_run_results, sample_manifest
    ) -> None:
        """Test that datasets include dataQualityAssertions facet.

        Validates that:
            - Single event contains multiple input datasets
            - Each input dataset has inputFacets dictionary
            - inputFacets contains "dataQualityAssertions" key
            - dataQualityAssertions has "assertions" array
            - Each assertion has required fields: assertion, success

        OpenLineage Spec Reference:
            https://openlineage.io/docs/spec/facets/dataset-facets/data-quality-assertions
        """
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt",
            job_name="dbt_test_run",
            run_id="8b09d9b8-6506-438a-89ab-a51da1f0d891",
        )

        # Consolidated pattern: single event
        assert len(events) == 1
        event = events[0]

        # Multiple inputs (one per dataset with tests)
        assert len(event.inputs) > 0

        # Check each dataset has dataQualityAssertions facet
        for dataset in event.inputs:
            # DataQualityAssertions is an InputDatasetFacet, so check inputFacets
            assert (
                "dataQualityAssertions" in dataset.inputFacets
            ), f"Missing dataQualityAssertions facet for {dataset.name}"

            dqa_facet = dataset.inputFacets["dataQualityAssertions"]

            # Check facet structure
            assert isinstance(dqa_facet.assertions, list)
            assert len(dqa_facet.assertions) > 0

            # Check first assertion has required fields
            first_assertion = dqa_facet.assertions[0]
            assert isinstance(first_assertion.assertion, str)
            assert isinstance(first_assertion.success, bool)

    def test_assertion_structure_for_passed_test(
        self, sample_run_results, sample_manifest
    ) -> None:
        """Test assertion structure for passed tests.

        Validates that passed tests have:
            - success: True
            - assertion: test name (e.g., "not_null", "unique")
            - column: column name (if applicable)
        """
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt",
            job_name="dbt_test_run",
            run_id="8b09d9b8-6506-438a-89ab-a51da1f0d891",
        )

        # Consolidated pattern: single event
        assert len(events) == 1
        event = events[0]

        # Find a passing test assertion across all inputs
        passing_assertion = None
        for dataset in event.inputs:
            dqa_facet = dataset.inputFacets.get("dataQualityAssertions")
            if dqa_facet:
                for assertion in dqa_facet.assertions:
                    if assertion.success:
                        passing_assertion = assertion
                        break
            if passing_assertion:
                break

        assert passing_assertion is not None, "Should have at least one passing test"

        # Validate passing assertion structure
        assert passing_assertion.success is True
        assert isinstance(passing_assertion.assertion, str)
        assert len(passing_assertion.assertion) > 0

    def test_uses_correct_event_time(self, sample_run_results, sample_manifest) -> None:
        """Test that eventTime matches run_results generation timestamp.

        Validates that:
            - eventTime is set from run_results.metadata.generated_at
            - Timestamp is properly formatted (ISO 8601)
            - Critical for correlation - events must have accurate timestamps

        Note:
            eventTime represents when test execution completed.
            This enables temporal correlation with other lineage events.
        """
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt",
            job_name="dbt_test_run",
            run_id="8b09d9b8-6506-438a-89ab-a51da1f0d891",
        )

        # Consolidated pattern: single event
        assert len(events) == 1
        event = events[0]

        # Get expected timestamp from run_results
        expected_time = sample_run_results.metadata.generated_at

        # Extract eventTime from event (always string in ISO 8601 format)
        actual_time = event.eventTime
        assert isinstance(actual_time, str), "eventTime should be ISO 8601 string"

        # Should be UTC timezone aware or Z suffix
        assert (
            "Z" in actual_time or "+" in actual_time or actual_time.endswith(":00")
        ), "eventTime should be ISO 8601 with timezone"

        # Convert to datetime for comparison
        actual_dt = datetime.fromisoformat(actual_time.replace("Z", "+00:00"))
        assert (
            actual_dt == expected_time
            or actual_dt.replace(tzinfo=None) == expected_time
        ), f"eventTime {actual_time} should match run_results.generated_at {expected_time}"

    def test_uses_invocation_id_as_run_id(
        self, sample_run_results, sample_manifest
    ) -> None:
        """Test that run.runId uses provided run_id parameter.

        Validates that:
            - run.runId matches the provided run_id parameter
            - Critical for correlation - Correlator creates canonical ID: dbt:{runId}
            - Enables linking test results to job runs in Correlator

        Note:
            Correlator generates canonical job run ID in format: {namespace}:{runId}
            Example: dbt:550e8400-e29b-41d4-a716-446655440000

            This canonical ID is used across:
            - Lineage events (from dbt-ol or this plugin)
            - Test results (from this plugin)
            - Correlation queries

        Reference:
            Plugin Developer Guide, section "Canonical Job Run ID"
        """
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt",
            job_name="dbt_test_run",
            run_id=sample_run_results.metadata.invocation_id,
        )

        # Consolidated pattern: single event
        assert len(events) == 1
        event = events[0]

        # Get expected runId from run_results
        expected_run_id = sample_run_results.metadata.invocation_id

        # Extract run.runId from event (always RunEvent object)
        actual_run_id = event.run.runId

        # Validate runId matches invocation_id
        assert (
            actual_run_id == expected_run_id
        ), "run.runId should match provided run_id for correlation"

        # Validate runId is a valid UUID string or format
        assert isinstance(actual_run_id, str), "runId should be string"
        assert len(actual_run_id) > 0, "runId should not be empty"

    def test_with_custom_namespace_and_job_name(self) -> None:
        """Test event construction with custom namespace and job name.

        Validates that:
            - Namespace parameter is respected
            - Job name parameter is respected (no dataset suffix in consolidated pattern)
        """
        # Create minimal test data
        run_results = RunResults(
            metadata=RunResultsMetadata(
                generated_at=datetime.now(),
                invocation_id="test-invocation-123",
                dbt_version="1.10.15",
                elapsed_time=0.5,
            ),
            results=[
                TestResult(
                    unique_id="test.jaffle_shop.not_null_customers_customer_id.5c9bf9911d",
                    status="pass",
                    failures=0,
                    message=None,
                    execution_time_seconds=0.5,
                )
            ],
        )

        manifest = Manifest(
            metadata={
                "generated_at": datetime.now().isoformat(),
                "dbt_schema_version": "v12",
            },
            nodes={
                "test.jaffle_shop.not_null_customers_customer_id.5c9bf9911d": {
                    "unique_id": "test.jaffle_shop.not_null_customers_customer_id.5c9bf9911d",
                    "package_name": "jaffle_shop",
                    "test_metadata": {
                        "name": "not_null",
                        "kwargs": {"column_name": "customer_id"},
                    },
                    "refs": [{"name": "customers"}],
                },
                "model.jaffle_shop.customers": {
                    "unique_id": "model.jaffle_shop.customers",
                    "database": "jaffle_shop",
                    "schema": "main",
                    "name": "customers",
                },
            },
            sources={},
        )

        # Test custom job_namespace
        events = construct_test_events(
            run_results=run_results,
            manifest=manifest,
            job_namespace="production",
            job_name="nightly_dbt_tests",
            run_id="8b09d9b8-6506-438a-89ab-a51da1f0d891",
        )

        # Consolidated pattern: single event
        assert len(events) == 1
        event = events[0]

        # Verify custom values used (always RunEvent object)
        assert event.job.namespace == "production"
        # Job name is NOT suffixed with dataset name (consolidated pattern)
        assert event.job.name == "nightly_dbt_tests"

    def test_serializes_to_json(self, sample_run_results, sample_manifest) -> None:
        """Test that constructed event can be serialized to JSON.

        Validates that:
            - Event can be converted to JSON string
            - JSON is valid and parseable
            - All required fields present in JSON
            - Dates are ISO 8601 formatted
            - UUIDs are strings

        This is critical for HTTP transmission to Correlator.
        """
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt",
            job_name="dbt_test_run",
            run_id="8b09d9b8-6506-438a-89ab-a51da1f0d891",
        )

        # Consolidated pattern: single event
        assert len(events) == 1
        event = events[0]

        # Try to serialize to JSON
        # v2 events use attrs, so use attr.asdict()
        event_dict = attr.asdict(event)

        json_str = json.dumps(event_dict, default=str)

        # Verify JSON is valid
        parsed = json.loads(json_str)

        assert "eventType" in parsed
        assert "run" in parsed
        assert "job" in parsed
        assert "inputs" in parsed
        assert "outputs" in parsed
        assert "producer" in parsed
        assert "schemaURL" in parsed

    def test_construct_test_events_uses_running_state(
        self, sample_run_results, sample_manifest
    ) -> None:
        """Test that test events use RUNNING state, not COMPLETE.

        RUNNING is the correct state for intermediate events that carry data
        (like dataQualityAssertions) during a job run. COMPLETE/FAIL are
        terminal states that should only be used for the final wrapping event.

        This prevents state transition errors in Correlator:
        "terminal state is immutable: COMPLETE → FAIL"

        Reference:
            OpenLineage Run Cycle: https://openlineage.io/docs/spec/run-cycle
        """
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt",
            job_name="dbt_test_run",
            run_id="550e8400-e29b-41d4-a716-446655440000",
        )

        # Consolidated pattern: single event with RUNNING state
        assert len(events) == 1
        event = events[0]
        assert event.eventType == RunState.RUNNING, (
            f"Test events should use RUNNING state, not {event.eventType}. "
            "COMPLETE/FAIL are terminal states reserved for wrapping events."
        )


# ============================================================================
# Tests for emit_events()
# ============================================================================


@pytest.mark.unit
class TestEmitEvents:
    """Tests for emitting events to Correlator/OpenLineage backend."""

    def test_sends_post_request_to_correlator(self, minimal_test_data) -> None:
        """Test that emit_event sends HTTP POST to correct endpoint.

        Validates that:
            - HTTP POST request is made
            - URL matches correlator_endpoint parameter
            - Content-Type is application/json
            - Request body is serialized event
            - Events are wrapped in array (OpenLineage batch format)

        Note:
            Uses mock to avoid actual HTTP calls in unit tests.
        """
        run_results, manifest = minimal_test_data

        events = construct_test_events(
            run_results,
            manifest,
            "dbt",
            "test_job",
            "eb31681b-641b-4f73-bf93-cc339decae23",
        )

        # Mock HTTP request
        with patch("requests.post") as mock_post:
            mock_response = MagicMock()
            mock_response.status_code = 200
            mock_response.json.return_value = {
                "status": "success",
                "summary": {"received": 1, "successful": 1, "failed": 0},
            }
            mock_post.return_value = mock_response

            # Emit event
            emit_events(events, "http://localhost:8080/api/v1/lineage/events")

            # Verify POST was called
            assert mock_post.called
            assert mock_post.call_count == 1

            # Verify URL
            call_args = mock_post.call_args
            assert call_args[0][0] == "http://localhost:8080/api/v1/lineage/events"

            # Verify headers include Content-Type
            headers = call_args[1].get("headers", {})
            assert "Content-Type" in headers or "content-type" in headers

            # Verify body is JSON array (OpenLineage batch format)
            json_data = call_args[1].get("json")
            assert isinstance(json_data, list), "Events must be wrapped in array"
            assert len(json_data) == 1

    def test_handles_success_response(self, minimal_test_data) -> None:
        """Test successful event emission with 200 OK response.

        Validates that:
            - 200 response is handled correctly
            - No exceptions raised on success
            - Function completes normally
            - Response structure matches OpenLineage spec

        Note:
            This is the happy path - critical to test alongside error cases.
            OpenLineage spec defines success response format.

        Reference:
            Plugin Developer Guide, Response Format section (lines 320-340)
        """
        run_results, manifest = minimal_test_data

        events = construct_test_events(
            run_results,
            manifest,
            "dbt",
            "test_job",
            "eb31681b-641b-4f73-bf93-cc339decae23",
        )

        with patch("requests.post") as mock_post:
            mock_response = MagicMock()
            mock_response.status_code = 200
            mock_response.json.return_value = {
                "status": "success",
                "summary": {
                    "received": 1,
                    "successful": 1,
                    "failed": 0,
                    "retriable": 0,
                    "non_retriable": 0,
                },
                "failed_events": [],
                "correlation_id": "123e4567-e89b-12d3-a456-426614174000",
                "timestamp": "2024-01-01T12:00:00Z",
            }
            mock_post.return_value = mock_response

            # Should not raise any exceptions - this is success case
            emit_events(events, "http://localhost:8080/api/v1/lineage/events")

            # Verify request was made
            assert mock_post.called
            assert mock_post.call_count == 1

    def test_handles_partial_success_response(self, minimal_test_data) -> None:
        """Test handling of 207 Multi-Status (partial success) response.

        Validates that:
            - 207 response indicates some events succeeded, some failed
            - Should not treat as complete failure
            - Handles gracefully (log warning but don't raise exception)
            - Response includes failed_events details

        Note:
            OpenLineage spec defines 207 for partial success in batch operations.
            This is a valid success scenario, not an error.

        Example scenario:
            - Sent batch of 10 events
            - 8 succeeded, 2 failed validation
            - Status code: 207
            - Plugin should acknowledge partial success

        Reference:
            Plugin Developer Guide, Response Format section (lines 342-366)
            OpenLineage spec: https://openlineage.io/apidocs/openapi/
        """
        run_results, manifest = minimal_test_data

        events = construct_test_events(
            run_results,
            manifest,
            "dbt",
            "test_job",
            "eb31681b-641b-4f73-bf93-cc339decae23",
        )

        with patch("requests.post") as mock_post:
            mock_response = MagicMock()
            mock_response.status_code = 207
            mock_response.json.return_value = {
                "status": "partial_success",
                "summary": {
                    "received": 1,
                    "successful": 0,
                    "failed": 1,
                    "retriable": 1,
                    "non_retriable": 0,
                },
                "failed_events": [
                    {
                        "index": 0,
                        "reason": "transient database error",
                        "retriable": True,
                    }
                ],
                "correlation_id": "123e4567-e89b-12d3-a456-426614174000",
                "timestamp": "2024-01-01T12:00:00Z",
            }
            mock_post.return_value = mock_response

            # Should handle partial success gracefully
            # Implementation may log warning but should not raise exception
            # (207 is success status, not error status)
            emit_events(events, "http://localhost:8080/api/v1/lineage/events")

            # Verify request was made
            assert mock_post.called

    def test_with_api_key_includes_header(self, minimal_test_data) -> None:
        """Test that API key is included in request headers.

        Validates that:
            - API key is sent in X-API-Key header
            - Header format matches Correlator expectation
            - Request still works with API key

        Reference:
            Plugin Developer Guide (notes/plugin-developer-guide.md)
            Authentication section.
        """
        run_results, manifest = minimal_test_data

        events = construct_test_events(
            run_results,
            manifest,
            "dbt",
            "test_job",
            "eb31681b-641b-4f73-bf93-cc339decae23",
        )

        with patch("requests.post") as mock_post:
            mock_response = MagicMock()
            mock_response.status_code = 200
            mock_response.json.return_value = {
                "status": "success",
                "summary": {"received": 1, "successful": 1, "failed": 0},
            }
            mock_post.return_value = mock_response

            # Emit with API key
            emit_events(
                events,
                "http://localhost:8080/api/v1/lineage/events",
                api_key="test-api-key-123",
            )

            # Verify API key in headers
            call_args = mock_post.call_args
            headers = call_args[1].get("headers", {})

            # Check for X-API-Key header (case-insensitive)
            api_key_found = False
            for key, value in headers.items():
                if key.lower() == "x-api-key":
                    assert value == "test-api-key-123"
                    api_key_found = True
                    break

            assert api_key_found, "X-API-Key header not found"

    def test_handles_connection_error(self, minimal_test_data) -> None:
        """Test error handling when Correlator is unreachable.

        Validates that:
            - ConnectionError is raised with helpful message
            - Error message suggests checking Correlator status
            - Original error is preserved (exception chaining)
        """
        run_results, manifest = minimal_test_data

        events = construct_test_events(
            run_results,
            manifest,
            "dbt",
            "test_job",
            "eb31681b-641b-4f73-bf93-cc339decae23",
        )

        # Mock connection error
        with patch("requests.post") as mock_post:
            mock_post.side_effect = requests.ConnectionError("Connection refused")

            # Should raise ConnectionError with helpful message
            with pytest.raises(ConnectionError) as exc_info:
                emit_events(events, "http://localhost:8080/api/v1/lineage/events")

            error_message = str(exc_info.value)
            assert "localhost:8080" in error_message or "Correlator" in error_message

    def test_handles_http_error_response(self, minimal_test_data) -> None:
        """Test error handling for HTTP error responses from Correlator.

        Validates that:
            - ValueError raised for 4xx/5xx responses
            - Error includes status code
            - Error includes response body if available
            - Helpful context provided

        Common errors:
            - 400: Invalid JSON
            - 422: Validation error
            - 500: Server error
        """
        run_results, manifest = minimal_test_data

        events = construct_test_events(
            run_results,
            manifest,
            "dbt",
            "test_job",
            "eb31681b-641b-4f73-bf93-cc339decae23",
        )

        # Mock 422 validation error
        with patch("requests.post") as mock_post:
            mock_response = MagicMock()
            mock_response.status_code = 422
            mock_response.json.return_value = {
                "status": "error",
                "summary": {"received": 1, "successful": 0, "failed": 1},
                "failed_events": [
                    {"index": 0, "reason": "eventTime is required", "retriable": False}
                ],
            }
            mock_response.text = json.dumps(mock_response.json.return_value)
            mock_post.return_value = mock_response

            # Should raise ValueError
            with pytest.raises(ValueError, match="422") as exc_info:
                emit_events(events, "http://localhost:8080/api/v1/lineage/events")

            error_message = str(exc_info.value)
            assert "422" in error_message

    def test_handles_timeout(self, minimal_test_data) -> None:
        """Test error handling for request timeout.

        Validates that:
            - TimeoutError raised when request times out
            - Error message is helpful
            - Suggests checking network/Correlator performance
        """
        run_results, manifest = minimal_test_data

        events = construct_test_events(
            run_results,
            manifest,
            "dbt",
            "test_job",
            "eb31681b-641b-4f73-bf93-cc339decae23",
        )

        # Mock timeout
        with patch("requests.post") as mock_post:
            mock_post.side_effect = requests.Timeout("Request timed out")

            # Should raise TimeoutError
            with pytest.raises(TimeoutError) as exc_info:
                emit_events(events, "http://localhost:8080/api/v1/lineage/events")

            error_message = str(exc_info.value)
            # Should mention timeout
            assert (
                "timeout" in error_message.lower()
                or "timed out" in error_message.lower()
            )

    def test_handles_empty_list(self) -> None:
        """Test that empty event list doesn't make HTTP call.

        Validates that:
            - Empty list is handled gracefully
            - No HTTP request made for empty batch
            - Returns without error
            - Debug logging occurs

        This is important for efficiency - no unnecessary HTTP calls.
        """
        with patch("requests.post") as mock_post:
            # Call with empty list
            emit_events([], "http://localhost:8080/api/v1/lineage/events")

            # Verify no HTTP call was made
            assert (
                mock_post.call_count == 0
            ), "Should not make HTTP call for empty batch"


@pytest.mark.integration
class TestEmitEventsIntegration:
    """Integration tests for emit_events with real Correlator."""

    def test_integration_with_correlator(self) -> None:
        """Integration test: emit event to real Correlator instance.

        Requirements:
            - Correlator running on localhost:8080
            - Database initialized
            - Migrations applied

        Validates that:
            - Event successfully sent to Correlator
            - Response status is 200
            - Response indicates success
            - Events stored in database (manual verification)

        Note:
            This test requires Correlator to be running.
            Use pytest markers to skip if not available:
                pytest -m "not integration"
        """
        pytest.skip("Integration test - requires Correlator running on localhost:8080")

        # This test will be implemented when we want to test with real Correlator
        # For now, we skip to avoid test failures in CI/CD


# ============================================================================
# Tests for construct_lineage_event()
# ============================================================================


@pytest.mark.unit
class TestConstructLineageEvent:
    """Tests for constructing lineage events for a single model."""

    def test_creates_running_event(self, sample_model_lineage) -> None:
        """Test that construct_lineage_event creates valid OpenLineage RUNNING event.

        Validates that:
            - eventType is RUNNING (intermediate event, not terminal)
            - run.runId matches provided run_id
            - job namespace and name are set correctly
            - producer is set to dbt-correlator
            - Event structure is valid OpenLineage RunEvent

        Note:
            Lineage events use RUNNING state because they are intermediate
            data carriers. COMPLETE/FAIL are terminal states reserved for
            wrapping events.
        """
        run_id = "550e8400-e29b-41d4-a716-446655440000"
        event_time = "2024-01-01T12:00:00Z"

        event = construct_lineage_event(
            model_lineage=sample_model_lineage,
            run_id=run_id,
            job_namespace="dbt",
            producer="https://github.com/correlator-io/correlator-dbt/0.1.0",
            event_time=event_time,
        )

        # Verify event structure
        # Lineage events use RUNNING state (intermediate), not COMPLETE (terminal)
        assert event.eventType == RunState.RUNNING
        assert event.run.runId == run_id
        assert event.job.namespace == "dbt"
        assert event.job.name == "model.jaffle_shop.customers"
        assert event.eventTime == event_time
        assert "correlator-io/correlator-dbt" in event.producer

    def test_includes_inputs(self, sample_model_lineage) -> None:
        """Test that lineage event includes input datasets from ModelLineage.

        Validates that:
            - inputs array is populated from ModelLineage.inputs
            - Each input has correct namespace and name
            - Input count matches ModelLineage.inputs count
        """
        event = construct_lineage_event(
            model_lineage=sample_model_lineage,
            run_id="550e8400-e29b-41d4-a716-446655440001",
            job_namespace="dbt",
            producer="https://test/producer",
            event_time="2024-01-01T12:00:00Z",
        )

        # Verify inputs
        assert len(event.inputs) == 2
        assert event.inputs[0].namespace == "duckdb://jaffle_shop"
        assert event.inputs[0].name == "main.stg_customers"
        assert event.inputs[1].namespace == "duckdb://jaffle_shop"
        assert event.inputs[1].name == "main.stg_orders"

    def test_includes_output(self, sample_model_lineage) -> None:
        """Test that lineage event includes output dataset from ModelLineage.

        Validates that:
            - outputs array has single entry (the model output)
            - Output has correct namespace and name from ModelLineage.output
        """
        event = construct_lineage_event(
            model_lineage=sample_model_lineage,
            run_id="550e8400-e29b-41d4-a716-446655440002",
            job_namespace="dbt",
            producer="https://test/producer",
            event_time="2024-01-01T12:00:00Z",
        )

        # Verify outputs
        assert len(event.outputs) == 1
        assert event.outputs[0].namespace == "duckdb://jaffle_shop"
        assert event.outputs[0].name == "main.customers"

    def test_includes_runtime_metrics(
        self, sample_model_lineage, sample_model_execution_result
    ) -> None:
        """Test that lineage event includes outputStatistics facet with row count.

        Validates that:
            - Output dataset has outputFacets with outputStatistics
            - outputStatistics contains rowCount from ModelExecutionResult
            - Facet structure follows OpenLineage spec

        Reference:
            https://openlineage.io/docs/spec/facets/dataset-facets/output-statistics
        """
        event = construct_lineage_event(
            model_lineage=sample_model_lineage,
            run_id="550e8400-e29b-41d4-a716-446655440002",
            job_namespace="dbt",
            producer="https://test/producer",
            event_time="2024-01-01T12:00:00Z",
            execution_result=sample_model_execution_result,
        )

        # Verify output has outputStatistics facet
        output = event.outputs[0]
        assert output.outputFacets is not None
        assert "outputStatistics" in output.outputFacets

        # Verify facet structure
        stats_facet = output.outputFacets["outputStatistics"]
        assert stats_facet.rowCount == 1500

    def test_without_runtime_metrics(self, sample_model_lineage) -> None:
        """Test that lineage event works without runtime metrics.

        Validates that:
            - Event is valid without execution_result parameter
            - Output dataset has no outputStatistics facet
            - Used for `dbt test` command (no model execution metrics)
        """
        event = construct_lineage_event(
            model_lineage=sample_model_lineage,
            run_id="550e8400-e29b-41d4-a716-446655440002",
            job_namespace="dbt",
            producer="https://test/producer",
            event_time="2024-01-01T12:00:00Z",
            # No execution_result provided
        )

        # Verify output exists but has no outputStatistics
        output = event.outputs[0]
        # outputFacets should be None or empty when no metrics provided
        assert output.outputFacets is None or "outputStatistics" not in (
            output.outputFacets or {}
        )

    def test_with_no_inputs(self) -> None:
        """Test that lineage event handles models with no inputs.

        Validates that:
            - Event is valid when ModelLineage has empty inputs list
            - outputs still populated correctly
            - No errors raised
        """
        # Model with no dependencies (e.g., seed-based or hardcoded)
        lineage_no_inputs = ModelLineage(
            unique_id="model.jaffle_shop.seed_based_model",
            name="seed_based_model",
            inputs=[],  # No upstream dependencies
            output=DatasetInfo(namespace="duckdb://db", name="main.seed_based_model"),
        )

        event = construct_lineage_event(
            model_lineage=lineage_no_inputs,
            run_id="550e8400-e29b-41d4-a716-446655440002",
            job_namespace="dbt",
            producer="https://test/producer",
            event_time="2024-01-01T12:00:00Z",
        )

        # Verify event structure
        assert len(event.inputs) == 0
        assert len(event.outputs) == 1
        assert event.outputs[0].name == "main.seed_based_model"

    def test_serializes_to_json(
        self, sample_model_lineage, sample_model_execution_result
    ) -> None:
        """Test that lineage event can be serialized to JSON for HTTP transmission.

        Validates that:
            - Event with OutputDataset serializes correctly
            - outputStatistics facet serializes correctly
            - JSON is valid and parseable
            - All required OpenLineage fields present

        This is critical because OutputDataset/outputFacets have different
        serialization than InputDataset/inputFacets used in test events.
        """
        event = construct_lineage_event(
            model_lineage=sample_model_lineage,
            run_id="550e8400-e29b-41d4-a716-446655440005",
            job_namespace="dbt",
            producer="https://github.com/correlator-io/correlator-dbt/0.1.0",
            event_time="2024-01-01T12:00:00Z",
            execution_result=sample_model_execution_result,
        )

        # Serialize to dict (same as emit_events does)
        event_dict = attr.asdict(event)

        # Serialize to JSON string
        json_str = json.dumps(event_dict, default=str)

        # Verify JSON is valid and parseable
        parsed = json.loads(json_str)

        # Verify required OpenLineage fields
        assert "eventType" in parsed
        assert "run" in parsed
        assert "job" in parsed
        assert "inputs" in parsed
        assert "outputs" in parsed
        assert "producer" in parsed

        # Verify output structure (different from input)
        assert len(parsed["outputs"]) == 1
        output = parsed["outputs"][0]
        assert "namespace" in output
        assert "name" in output
        assert "outputFacets" in output

        # Verify outputStatistics facet serialized correctly
        assert "outputStatistics" in output["outputFacets"]
        stats = output["outputFacets"]["outputStatistics"]
        assert stats["rowCount"] == 1500

    def test_construct_lineage_event_uses_running_state(
        self, sample_model_lineage
    ) -> None:
        """Test that lineage events use RUNNING state, not COMPLETE.

        Lineage events are intermediate updates during a job run.
        The terminal state (COMPLETE/FAIL) is determined by the wrapping event.

        This prevents state transition errors in Correlator:
        "terminal state is immutable: COMPLETE → FAIL"

        Reference:
            OpenLineage Run Cycle: https://openlineage.io/docs/spec/run-cycle
        """
        event = construct_lineage_event(
            model_lineage=sample_model_lineage,
            run_id="550e8400-e29b-41d4-a716-446655440008",
            job_namespace="dbt",
            producer="https://test/producer",
            event_time="2024-01-01T12:00:00Z",
        )

        assert event.eventType == RunState.RUNNING, (
            f"Lineage events should use RUNNING state, not {event.eventType}. "
            "COMPLETE/FAIL are terminal states reserved for wrapping events."
        )


# ============================================================================
# Tests for construct_lineage_events()
# ============================================================================


@pytest.mark.unit
class TestConstructLineageEvents:
    """Tests for constructing lineage events for multiple models."""

    def test_creates_event_per_model(self, sample_model_lineages) -> None:
        """Test that construct_lineage_events creates one event per model.

        Validates that:
            - Returns list of events
            - Event count matches input model count
            - Each event corresponds to a model from input list
        """
        events = construct_lineage_events(
            model_lineages=sample_model_lineages,
            job_namespace="dbt",
            producer="https://test/producer",
            event_time="2024-01-01T12:00:00Z",
        )

        # Verify event count
        assert len(events) == 2

        # Verify each event corresponds to a model
        job_names = {event.job.name for event in events}
        assert "model.jaffle_shop.stg_customers" in job_names
        assert "model.jaffle_shop.customers" in job_names

    def test_each_model_gets_unique_run_id(self, sample_model_lineages) -> None:
        """Test that each model gets a UNIQUE runId (Bug 4 fix).

        Validates that:
            - Each model event has a different runId
            - Prevents Correlator from aggregating events into self-referential loops

        Bug 4 Fix:
            Previously all events shared a single runId. Correlator aggregates
            by runId, creating loops when the same dataset appears as both
            input and output across different models.
        """
        events = construct_lineage_events(
            model_lineages=sample_model_lineages,
            job_namespace="dbt",
            producer="https://test/producer",
            event_time="2024-01-01T12:00:00Z",
        )

        # Collect all runIds from events
        run_ids = {event.run.runId for event in events}

        # Each model should have a unique runId
        assert len(run_ids) == len(events), (
            "Each model must have a unique runId to prevent Correlator aggregation. "
            f"Found {len(run_ids)} unique runIds for {len(events)} events."
        )

    def test_with_execution_results(self, sample_model_lineages) -> None:
        """Test that execution results are matched to correct models.

        Validates that:
            - Execution results are correctly matched by unique_id
            - Models with results get outputStatistics facet
            - Models without results don't get facet
        """
        # Only provide execution result for one model
        execution_results = {
            "model.jaffle_shop.customers": ModelExecutionResult(
                unique_id="model.jaffle_shop.customers",
                status="success",
                execution_time_seconds=2.5,
                rows_affected=2500,
                message="OK",
            )
        }

        events = construct_lineage_events(
            model_lineages=sample_model_lineages,
            job_namespace="dbt",
            producer="https://test/producer",
            event_time="2024-01-01T12:00:00Z",
            execution_results=execution_results,
        )

        # Find the customers event (should have metrics)
        customers_event = next(
            e for e in events if e.job.name == "model.jaffle_shop.customers"
        )
        stg_customers_event = next(
            e for e in events if e.job.name == "model.jaffle_shop.stg_customers"
        )

        # customers should have outputStatistics
        assert customers_event.outputs[0].outputFacets is not None
        assert "outputStatistics" in customers_event.outputs[0].outputFacets
        assert (
            customers_event.outputs[0].outputFacets["outputStatistics"].rowCount == 2500
        )

        # stg_customers should NOT have outputStatistics
        assert stg_customers_event.outputs[0].outputFacets is None or (
            "outputStatistics"
            not in (stg_customers_event.outputs[0].outputFacets or {})
        )

    def test_empty_list(self) -> None:
        """Test that empty model list returns empty event list.

        Validates that:
            - Empty input returns empty output
            - No errors raised
            - Handles gracefully
        """
        events = construct_lineage_events(
            model_lineages=[],
            job_namespace="dbt",
            producer="https://test/producer",
            event_time="2024-01-01T12:00:00Z",
        )

        assert events == []

    def test_construct_lineage_events_all_use_running_state(
        self, sample_model_lineages
    ) -> None:
        """Test that all lineage events use RUNNING state, not COMPLETE.

        Validates that construct_lineage_events() produces RUNNING events
        for all models in the batch. Critical for state machine correctness.

        Reference:
            OpenLineage Run Cycle: https://openlineage.io/docs/spec/run-cycle
        """
        events = construct_lineage_events(
            model_lineages=sample_model_lineages,
            job_namespace="dbt",
            producer="https://test/producer",
            event_time="2024-01-01T12:00:00Z",
        )

        # All lineage events should use RUNNING state
        for event in events:
            assert event.eventType == RunState.RUNNING, (
                f"Lineage events should use RUNNING state, not {event.eventType}. "
                "COMPLETE/FAIL are terminal states reserved for wrapping events."
            )


# ============================================================================
# Tests for emit_events() OpenLineage Consumer Compatibility
# ============================================================================


@pytest.mark.unit
class TestEmitEventsOpenLineageConsumerCompatibility:
    """Tests for emit_events() compatibility with various OpenLineage consumers."""

    def test_handles_204_no_content(self, minimal_test_data) -> None:
        """Test handling of 204 No Content response from OL consumer.

        Validates that:
            - 204 response is treated as success
            - No exceptions raised
            - Compatible with generic OpenLineage consumers

        Note:
            Some OpenLineage consumers return 204 with no body as acknowledgment.
        """
        run_results, manifest = minimal_test_data

        events = construct_test_events(
            run_results,
            manifest,
            "dbt",
            "test_job",
            "eb31681b-641b-4f73-bf93-cc339decae23",
        )

        with patch("requests.post") as mock_post:
            mock_response = MagicMock()
            mock_response.status_code = 204
            mock_response.text = ""  # No content
            mock_post.return_value = mock_response

            # Should succeed without exception
            emit_events(events, "http://localhost:5000/api/v1/lineage")

            assert mock_post.called

    def test_handles_200_with_body(self, minimal_test_data) -> None:
        """Test handling of 200 response with body from OL consumer.

        Validates that:
            - 200 with JSON body is handled correctly
            - Summary is logged if present
            - Compatible with OpenLineage consumers that return summary
        """
        run_results, manifest = minimal_test_data

        events = construct_test_events(
            run_results,
            manifest,
            "dbt",
            "test_job",
            "eb31681b-641b-4f73-bf93-cc339decae23",
        )

        with patch("requests.post") as mock_post:
            mock_response = MagicMock()
            mock_response.status_code = 200
            mock_response.text = '{"summary": {"successful": 1, "failed": 0}}'
            mock_response.json.return_value = {
                "summary": {"successful": 1, "failed": 0}
            }
            mock_post.return_value = mock_response

            # Should succeed without exception
            emit_events(events, "http://localhost:5000/api/v1/lineage")

            assert mock_post.called

    def test_handles_200_without_body(self, minimal_test_data) -> None:
        """Test handling of 200 response without body from Correlator.

        Validates that:
            - 200 with empty body is treated as success
            - Compatible with Correlator backend
            - No JSON parsing errors
        """
        run_results, manifest = minimal_test_data

        events = construct_test_events(
            run_results,
            manifest,
            "dbt",
            "test_job",
            "eb31681b-641b-4f73-bf93-cc339decae23",
        )

        with patch("requests.post") as mock_post:
            mock_response = MagicMock()
            mock_response.status_code = 200
            mock_response.text = ""  # Empty body
            mock_post.return_value = mock_response

            # Should succeed without exception
            emit_events(events, "http://localhost:8080/api/v1/lineage/events")

            assert mock_post.called

    def test_with_lineage_events(self, sample_model_lineage) -> None:
        """Test that lineage events serialize and emit correctly via HTTP.

        Validates that:
            - Lineage events (with OutputDataset) emit correctly
            - outputFacets serialize properly (different from inputFacets)
            - Request body contains valid JSON array
            - No serialization errors during emission

        This test ensures the full path from construct_lineage_event()
        through emit_events() works correctly.
        """
        # Create lineage event with runtime metrics
        execution_result = ModelExecutionResult(
            unique_id="model.jaffle_shop.customers",
            status="success",
            execution_time_seconds=2.0,
            rows_affected=1000,
            message="OK",
        )

        event = construct_lineage_event(
            model_lineage=sample_model_lineage,
            run_id="550e8400-e29b-41d4-a716-446655440006",
            job_namespace="dbt",
            producer="https://github.com/correlator-io/correlator-dbt/0.1.0",
            event_time="2024-01-01T12:00:00Z",
            execution_result=execution_result,
        )

        with patch("requests.post") as mock_post:
            mock_response = MagicMock()
            mock_response.status_code = 200
            mock_response.text = '{"summary": {"successful": 1, "failed": 0}}'
            mock_response.json.return_value = {
                "summary": {"successful": 1, "failed": 0}
            }
            mock_post.return_value = mock_response

            # Emit lineage event
            emit_events([event], "http://localhost:8080/api/v1/lineage/events")

            # Verify request was made
            assert mock_post.called

            # Verify JSON body structure
            call_args = mock_post.call_args
            json_data = call_args[1].get("json")

            assert isinstance(json_data, list)
            assert len(json_data) == 1

            # Verify lineage event structure in request body
            event_data = json_data[0]
            # Lineage events use RUNNING state (intermediate), not COMPLETE (terminal)
            assert event_data["eventType"] == "RUNNING"
            assert len(event_data["outputs"]) == 1
            assert event_data["outputs"][0]["namespace"] == "duckdb://jaffle_shop"
            assert event_data["outputs"][0]["name"] == "main.customers"

            # Verify outputStatistics facet was serialized
            output_facets = event_data["outputs"][0].get("outputFacets", {})
            assert "outputStatistics" in output_facets
            assert output_facets["outputStatistics"]["rowCount"] == 1000


@pytest.mark.unit
class TestBuildParentFacet:
    """Tests for _build_parent_facet() helper function.

    Validates ParentRunFacet construction using OpenLineage SDK classes,
    including UUID validation and automatic metadata population.
    """

    def test_valid_uuid_produces_correct_structure(self) -> None:
        """Test that valid UUID produces correct ParentRunFacet structure.

        Validates that:
            - ParentRunFacet is created with correct run.runId
            - Parent job has correct namespace and name
            - Producer is set correctly
        """
        facet = _build_parent_facet(
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="dbt://demo",
                job_name="jaffle_shop.build",
            ),
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
        )

        assert facet.run.runId == "550e8400-e29b-41d4-a716-446655440000"
        assert facet.job.namespace == "dbt://demo"
        assert facet.job.name == "jaffle_shop.build"

    def test_invalid_uuid_raises_value_error(self) -> None:
        """Test that invalid UUID raises ValueError from SDK validation.

        The OpenLineage SDK validates runId is a valid UUID format.
        Invalid UUIDs should fail fast during construction.
        """
        with pytest.raises(ValueError, match="badly formed"):
            _build_parent_facet(
                parent=ParentRunMetadata(
                    run_id="not-a-valid-uuid",
                    job_namespace="dbt://demo",
                    job_name="jaffle_shop.build",
                ),
                producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
            )

    def test_serialization_includes_metadata_fields(self) -> None:
        """Test that serialized facet includes _producer, _schemaURL, root.

        OpenLineage SDK automatically populates facet metadata:
            - _producer: From constructor argument
            - _schemaURL: Auto-set to correct spec version
            - root: Optional field, serializes as null if not set
        """
        facet = _build_parent_facet(
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="dbt://demo",
                job_name="jaffle_shop.build",
            ),
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
        )

        # Serialize to dict
        def serialize(inst, field, value):
            if isinstance(value, Enum):
                return value.value
            return value

        facet_dict = attr.asdict(facet, value_serializer=serialize)  # type: ignore[call-arg]

        # Check metadata fields
        assert (
            facet_dict["_producer"]
            == "https://github.com/correlator-io/correlator-dbt/0.1.2"
        )
        assert "_schemaURL" in facet_dict
        assert "ParentRunFacet" in facet_dict["_schemaURL"]
        # root is optional, serializes as None/null
        assert "root" in facet_dict

    def test_schema_url_matches_openlineage_spec(self) -> None:
        """Test that _schemaURL matches OpenLineage spec version 1-1-0.

        The SDK should auto-set schema URL to:
        https://openlineage.io/spec/facets/1-1-0/ParentRunFacet.json#/$defs/ParentRunFacet
        """
        facet = _build_parent_facet(
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="dbt://demo",
                job_name="jaffle_shop.build",
            ),
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
        )

        def serialize(inst, field, value):
            if isinstance(value, Enum):
                return value.value
            return value

        facet_dict = attr.asdict(facet, value_serializer=serialize)  # type: ignore[call-arg]

        expected_url = "https://openlineage.io/spec/facets/1-1-0/ParentRunFacet.json"
        assert expected_url in facet_dict["_schemaURL"]

    def test_root_included_when_all_root_params_provided(self) -> None:
        """Test that Root is included in facet when all root params are provided."""
        facet = _build_parent_facet(
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="airflow",
                job_name="demo_pipeline.dbt_test",
                root_run_id="660f9500-f30c-52e5-b827-557766551111",
                root_job_namespace="airflow",
                root_job_name="demo_pipeline",
            ),
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
        )

        assert facet.root is not None
        assert facet.root.run.runId == "660f9500-f30c-52e5-b827-557766551111"
        assert facet.root.job.namespace == "airflow"
        assert facet.root.job.name == "demo_pipeline"

    def test_root_not_included_when_root_params_none(self) -> None:
        """Test that Root is None when no root params are provided (default)."""
        facet = _build_parent_facet(
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="dbt://demo",
                job_name="jaffle_shop.build",
            ),
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
        )

        assert facet.root is None

    def test_root_not_included_when_partial_root_params(self) -> None:
        """Test that Root is None when only some root params are provided."""
        facet = _build_parent_facet(
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="dbt://demo",
                job_name="jaffle_shop.build",
                root_run_id="660f9500-f30c-52e5-b827-557766551111",
                # root_job_namespace and root_job_name default to None
            ),
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
        )

        assert facet.root is None

    def test_root_serializes_correctly(self) -> None:
        """Test that root section serializes with correct structure."""
        facet = _build_parent_facet(
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="airflow",
                job_name="demo_pipeline.dbt_test",
                root_run_id="660f9500-f30c-52e5-b827-557766551111",
                root_job_namespace="airflow",
                root_job_name="demo_pipeline",
            ),
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
        )

        def serialize(inst, field, value):
            if isinstance(value, Enum):
                return value.value
            return value

        facet_dict = attr.asdict(facet, value_serializer=serialize)  # type: ignore[call-arg]

        assert facet_dict["root"] is not None
        assert (
            facet_dict["root"]["run"]["runId"] == "660f9500-f30c-52e5-b827-557766551111"
        )
        assert facet_dict["root"]["job"]["namespace"] == "airflow"
        assert facet_dict["root"]["job"]["name"] == "demo_pipeline"

    def test_root_invalid_uuid_raises_value_error(self) -> None:
        """Test that invalid root UUID raises ValueError from SDK validation."""
        with pytest.raises(ValueError, match="badly formed"):
            _build_parent_facet(
                parent=ParentRunMetadata(
                    run_id="550e8400-e29b-41d4-a716-446655440000",
                    job_namespace="airflow",
                    job_name="demo_pipeline.dbt_test",
                    root_run_id="not-a-valid-uuid",
                    root_job_namespace="airflow",
                    root_job_name="demo_pipeline",
                ),
                producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
            )


@pytest.mark.unit
class TestConstructLineageEventWithParent:
    """Tests for construct_lineage_event() with ParentRunFacet.

    Validates that parent facet is correctly added to lineage events
    when parent parameters are provided.
    """

    @pytest.fixture
    def sample_model_lineage(self) -> ModelLineage:
        """Create sample ModelLineage for testing."""
        return ModelLineage(
            unique_id="model.jaffle_shop.customers",
            name="customers",
            inputs=[
                DatasetInfo(
                    namespace="duckdb://jaffle_shop", name="main.stg_customers"
                ),
            ],
            output=DatasetInfo(namespace="duckdb://jaffle_shop", name="main.customers"),
        )

    def test_parent_facet_included_when_all_params_provided(
        self, sample_model_lineage
    ) -> None:
        """Test that ParentRunFacet is included when all parent params provided.

        Validates that:
            - run.facets contains "parent" key
            - Parent facet has correct structure
        """
        event = construct_lineage_event(
            model_lineage=sample_model_lineage,
            run_id="660f9500-f30c-52e5-b827-557766551111",
            job_namespace="dbt://demo",
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
            event_time="2024-01-01T12:00:00Z",
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="dbt://demo",
                job_name="jaffle_shop.build",
            ),
        )

        assert event.run.facets is not None
        assert "parent" in event.run.facets

        parent_facet = event.run.facets["parent"]
        assert parent_facet.run.runId == "550e8400-e29b-41d4-a716-446655440000"
        assert parent_facet.job.namespace == "dbt://demo"
        assert parent_facet.job.name == "jaffle_shop.build"

    def test_parent_facet_not_included_when_params_none(
        self, sample_model_lineage
    ) -> None:
        """Test that ParentRunFacet is NOT included when params are None.

        Validates backward compatibility - events without parent params
        should not have run.facets set.
        """
        event = construct_lineage_event(
            model_lineage=sample_model_lineage,
            run_id="660f9500-f30c-52e5-b827-557766551111",
            job_namespace="dbt://demo",
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
            event_time="2024-01-01T12:00:00Z",
            # No parent params
        )

        # run.facets should be None or empty
        assert event.run.facets is None or len(event.run.facets) == 0

    def test_parent_facet_serializes_correctly(self, sample_model_lineage) -> None:
        """Test that event with parent facet serializes to correct JSON structure.

        Validates the full serialization path for events with ParentRunFacet.
        """
        event = construct_lineage_event(
            model_lineage=sample_model_lineage,
            run_id="660f9500-f30c-52e5-b827-557766551111",
            job_namespace="dbt://demo",
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
            event_time="2024-01-01T12:00:00Z",
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="dbt://demo",
                job_name="jaffle_shop.build",
            ),
        )

        # Serialize event
        def serialize(inst, field, value):
            if isinstance(value, Enum):
                return value.value
            return value

        event_dict = attr.asdict(event, value_serializer=serialize)  # type: ignore[call-arg]

        # Verify JSON structure
        assert "run" in event_dict
        assert "facets" in event_dict["run"]
        assert "parent" in event_dict["run"]["facets"]

        parent = event_dict["run"]["facets"]["parent"]
        assert parent["run"]["runId"] == "550e8400-e29b-41d4-a716-446655440000"
        assert parent["job"]["namespace"] == "dbt://demo"
        assert parent["job"]["name"] == "jaffle_shop.build"
        assert "_producer" in parent
        assert "_schemaURL" in parent


@pytest.mark.unit
class TestConstructLineageEventsWithParent:
    """Tests for construct_lineage_events() with ParentRunFacet.

    Validates that parent params are correctly passed to all model events.
    """

    @pytest.fixture
    def sample_model_lineages(self) -> list[ModelLineage]:
        """Create sample ModelLineage list for testing."""
        return [
            ModelLineage(
                unique_id="model.jaffle_shop.stg_customers",
                name="stg_customers",
                inputs=[
                    DatasetInfo(
                        namespace="duckdb://jaffle_shop", name="main.raw_customers"
                    ),
                ],
                output=DatasetInfo(
                    namespace="duckdb://jaffle_shop", name="main.stg_customers"
                ),
            ),
            ModelLineage(
                unique_id="model.jaffle_shop.customers",
                name="customers",
                inputs=[
                    DatasetInfo(
                        namespace="duckdb://jaffle_shop", name="main.stg_customers"
                    ),
                ],
                output=DatasetInfo(
                    namespace="duckdb://jaffle_shop", name="main.customers"
                ),
            ),
        ]

    def test_all_events_have_parent_facet(self, sample_model_lineages) -> None:
        """Test that all generated events have ParentRunFacet.

        Validates that parent params are propagated to all model events.
        """
        events = construct_lineage_events(
            model_lineages=sample_model_lineages,
            job_namespace="dbt://demo",
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
            event_time="2024-01-01T12:00:00Z",
            parent=ParentRunMetadata(
                run_id="550e8400-e29b-41d4-a716-446655440000",
                job_namespace="dbt://demo",
                job_name="jaffle_shop.build",
            ),
        )

        assert len(events) == 2

        for event in events:
            assert event.run.facets is not None
            assert "parent" in event.run.facets
            assert (
                event.run.facets["parent"].run.runId
                == "550e8400-e29b-41d4-a716-446655440000"
            )
            assert event.run.facets["parent"].job.name == "jaffle_shop.build"

    def test_no_parent_facet_when_params_none(self, sample_model_lineages) -> None:
        """Test that no parent facet when params are None."""
        events = construct_lineage_events(
            model_lineages=sample_model_lineages,
            job_namespace="dbt://demo",
            producer="https://github.com/correlator-io/correlator-dbt/0.1.2",
            event_time="2024-01-01T12:00:00Z",
            # No parent params
        )

        for event in events:
            assert event.run.facets is None or "parent" not in event.run.facets


@pytest.mark.unit
class TestConstructTestEventsConsolidatedPattern:
    """Tests for construct_test_events() consolidated pattern.

    Validates the new consolidated pattern:
    - Single event with multiple inputs
    - No ParentRunFacet (fixes self-referential parent bug)
    - Extended fields (durationMs, message) stored for serialization
    """

    def test_returns_single_event(self, sample_run_results, sample_manifest) -> None:
        """Test that construct_test_events returns exactly one event."""
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt://demo",
            job_name="jaffle_shop.test",
            run_id="550e8400-e29b-41d4-a716-446655440000",
        )

        assert len(events) == 1, "Should return single consolidated event"

    def test_multiple_inputs_per_event(
        self, sample_run_results, sample_manifest
    ) -> None:
        """Test that single event contains all tested datasets as inputs."""
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt://demo",
            job_name="jaffle_shop.test",
            run_id="550e8400-e29b-41d4-a716-446655440000",
        )

        assert len(events) == 1
        event = events[0]

        # Should have multiple inputs (one per dataset with tests)
        assert len(event.inputs) > 0, "Should have at least one input dataset"

    def test_no_parent_when_standalone(
        self, sample_run_results, sample_manifest
    ) -> None:
        """Test that test events have no ParentRunFacet when not orchestrated."""
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt://demo",
            job_name="jaffle_shop.test",
            run_id="550e8400-e29b-41d4-a716-446655440000",
        )

        assert len(events) == 1
        event = events[0]
        assert event.run.facets is None or "parent" not in event.run.facets

    def test_includes_orchestrator_parent(
        self, sample_run_results, sample_manifest
    ) -> None:
        """Test that test events include ParentRunFacet when orchestrated.

        Test events share the wrapping job's identity (same run_id, job_name).
        When orchestrated, they should carry the orchestrator as parent,
        consistent with the wrapping event's parent.
        """
        orchestrator = ParentRunMetadata(
            run_id="019c7c79-b160-7c2f-8ad4-a026c5a82b5a",
            job_namespace="airflow",
            job_name="demo_pipeline.dbt_test",
            root_run_id="019c7c79-aaaa-bbbb-cccc-111122223333",
            root_job_namespace="airflow",
            root_job_name="demo_pipeline",
        )

        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt://demo",
            job_name="jaffle_shop.test",
            run_id="550e8400-e29b-41d4-a716-446655440000",
            parent=orchestrator,
        )

        assert len(events) == 1
        event = events[0]
        assert event.run.facets is not None
        assert "parent" in event.run.facets
        parent = event.run.facets["parent"]
        assert parent.run.runId == "019c7c79-b160-7c2f-8ad4-a026c5a82b5a"
        assert parent.job.namespace == "airflow"
        assert parent.job.name == "demo_pipeline.dbt_test"
        assert parent.root is not None
        assert parent.root.run.runId == "019c7c79-aaaa-bbbb-cccc-111122223333"

    def test_extended_fields_stored_on_facet(
        self, sample_run_results, sample_manifest
    ) -> None:
        """Test that extended fields (durationMs, message) are stored for serialization."""
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt://demo",
            job_name="jaffle_shop.test",
            run_id="550e8400-e29b-41d4-a716-446655440000",
        )

        assert len(events) == 1
        event = events[0]

        # Check that at least one input has extended fields stored
        has_extended_fields = False
        for inp in event.inputs:
            dqa_facet = inp.inputFacets.get("dataQualityAssertions")
            if dqa_facet and hasattr(dqa_facet, "_extended_fields"):
                has_extended_fields = True
                extended = dqa_facet._extended_fields  # type: ignore[attr-defined]
                assert isinstance(extended, list)
                assert len(extended) > 0
                # Check structure of extended fields
                for ext in extended:
                    assert "durationMs" in ext
                    assert "message" in ext
                break

        assert has_extended_fields, "Should have extended fields stored on facet"

    def test_job_name_not_suffixed(self, sample_run_results, sample_manifest) -> None:
        """Test that job name is NOT suffixed with dataset name."""
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt://demo",
            job_name="jaffle_shop.test",
            run_id="550e8400-e29b-41d4-a716-446655440000",
        )

        assert len(events) == 1
        event = events[0]

        # Job name should be exactly as provided (no dataset suffix)
        assert event.job.name == "jaffle_shop.test"

    def test_extended_fields_serialized_correctly(
        self, sample_run_results, sample_manifest
    ) -> None:
        """Test that durationMs and message appear in serialized assertions."""
        events = construct_test_events(
            run_results=sample_run_results,
            manifest=sample_manifest,
            job_namespace="dbt://demo",
            job_name="jaffle_shop.test",
            run_id="550e8400-e29b-41d4-a716-446655440000",
        )

        assert len(events) == 1
        event = events[0]

        # Serialize with extended fields
        event_dict = _serialize_event_with_extended_fields(event)

        # Check that extended fields are merged into assertions
        for input_ds in event_dict["inputs"]:
            dqa = input_ds.get("inputFacets", {}).get("dataQualityAssertions")
            if dqa and "assertions" in dqa:
                for assertion in dqa["assertions"]:
                    assert (
                        "durationMs" in assertion
                    ), "durationMs should be in assertion"
                    assert "message" in assertion, "message should be in assertion"
                    # durationMs should be an integer (milliseconds)
                    assert isinstance(assertion["durationMs"], int)
