"""Integration tests for Collector self-monitoring via Prometheus metrics endpoint.

Tests verify that the OTel Collector exposes operational metrics on port :8889
in Prometheus format for downstream scraping. This is the collection layer handoff point
for the storage/visualization team to implement dashboards and alerts.

Architecture:
    Collector Internal Telemetry (:8888) →
    Prometheus Receiver (scrapes localhost:8888) →
    metrics/self pipeline →
    Prometheus Exporter →
    Port :8889 (external scraping endpoint) ← TESTS HERE

Requirements:
    - Docker Compose services running (otel-collector)
    - Collector configured with prometheus receiver and exporter
    - metrics/self pipeline enabled

Test Limitations:
    - Error metric simulation is limited to naturally occurring errors during test execution
    - Reliable fault injection would require a dedicated mock backend or fault-injection processor
    - Tests verify metric presence and increment behavior rather than exhaustive error scenarios

Usage:
    cd mca-prototype
    docker-compose up -d otel-collector
    pytest tests/integration/test_collector_self_monitoring.py -v
"""

import time
import re
import requests
import pytest
from opentelemetry import trace
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter

# Configuration
PROMETHEUS_ENDPOINT = "http://localhost:8889/metrics"
OTLP_ENDPOINT = "http://localhost:4318"
HEALTH_CHECK_URL = "http://localhost:13133/"
SCRAPE_INTERVAL = 30  # seconds (configured in collector)
SCRAPE_WAIT = 35  # seconds (allow time for scrape + processing)


def wait_for_collector_ready(timeout=30):
    """Wait for collector to be healthy before running tests."""
    start = time.time()
    while time.time() - start < timeout:
        try:
            response = requests.get(HEALTH_CHECK_URL, timeout=2)
            if response.status_code == 200:
                return True
        except requests.RequestException:
            pass
        time.sleep(1)
    return False


def scrape_prometheus_metrics():
    """Scrape metrics from Prometheus endpoint and return as dict.

    Returns:
        dict: Metrics in format {metric_name: [(labels_dict, value), ...]}
    """
    response = requests.get(PROMETHEUS_ENDPOINT, timeout=5)
    response.raise_for_status()

    metrics = {}
    for line in response.text.split("\n"):
        line = line.strip()
        # Skip comments and empty lines
        if not line or line.startswith("#"):
            continue

        # Parse metric line: metric_name{labels} value [timestamp]
        match = re.match(r"^([a-zA-Z_:][a-zA-Z0-9_:]*)\{([^}]*)\}\s+([\d.e+-]+)", line)
        if not match:
            # Metric without labels
            match = re.match(r"^([a-zA-Z_:][a-zA-Z0-9_:]*)\s+([\d.e+-]+)", line)
            if match:
                metric_name = match.group(1)
                value = float(match.group(2))
                if metric_name not in metrics:
                    metrics[metric_name] = []
                metrics[metric_name].append(({}, value))
            continue

        metric_name = match.group(1)
        labels_str = match.group(2)
        value = float(match.group(3))

        # Parse labels
        labels = {}
        for label_pair in labels_str.split(","):
            if "=" in label_pair:
                key, val = label_pair.split("=", 1)
                labels[key.strip()] = val.strip(' "')

        if metric_name not in metrics:
            metrics[metric_name] = []
        metrics[metric_name].append((labels, value))

    return metrics


def get_metric_value(metrics, metric_name, labels_filter=None):
    """Extract metric value matching label filter.

    Args:
        metrics: Scraped metrics dict
        metric_name: Name of metric to find
        labels_filter: Dict of labels that must match (or None for first match)

    Returns:
        float: Metric value, or None if not found
    """
    if metric_name not in metrics:
        return None

    for labels, value in metrics[metric_name]:
        if labels_filter is None:
            return value

        # Check if all filter labels match
        if all(labels.get(k) == v for k, v in labels_filter.items()):
            return value

    return None


def send_test_spans(count=100):
    """Send test spans to collector via OTLP."""
    resource = Resource.create({"service.name": "test-self-monitoring"})

    tracer_provider = TracerProvider(resource=resource)
    span_exporter = OTLPSpanExporter(endpoint=OTLP_ENDPOINT)
    span_processor = BatchSpanProcessor(span_exporter)
    tracer_provider.add_span_processor(span_processor)

    tracer = tracer_provider.get_tracer(__name__)

    for i in range(count):
        with tracer.start_as_current_span(f"test-span-{i}"):
            pass

    # Force flush
    tracer_provider.force_flush()
    tracer_provider.shutdown()


def send_test_metrics(count=100):
    """Send test metrics to collector via OTLP."""
    resource = Resource.create({"service.name": "test-self-monitoring"})

    metric_exporter = OTLPMetricExporter(endpoint=OTLP_ENDPOINT)
    metric_reader = PeriodicExportingMetricReader(metric_exporter, export_interval_millis=1000)
    meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])

    meter = meter_provider.get_meter(__name__)
    counter = meter.create_counter("test.counter")

    for i in range(count):
        counter.add(1, {"test": "value"})

    # Force flush
    time.sleep(2)
    meter_provider.force_flush()
    meter_provider.shutdown()


@pytest.fixture(scope="module", autouse=True)
def ensure_collector_running():
    """Ensure collector is running before tests start."""
    if not wait_for_collector_ready():
        pytest.skip("Collector not available at http://localhost:13133/")


# ============================================================================
# METRICS ENDPOINT TESTS (2 tests)
# ============================================================================


def test_prometheus_endpoint_accessible():
    """Verify Prometheus endpoint is accessible on :8889 and returns 200 OK."""
    response = requests.get(PROMETHEUS_ENDPOINT, timeout=5)

    assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}"

    assert response.headers.get("Content-Type", "").startswith(
        "text/plain"
    ), f"Expected text/plain content type, got {response.headers.get('Content-Type')}"


def test_prometheus_format_valid():
    """Verify metrics are in valid Prometheus exposition format."""
    response = requests.get(PROMETHEUS_ENDPOINT, timeout=5)
    response.raise_for_status()

    text = response.text

    # Should contain HELP and TYPE comments
    assert "# HELP" in text or "# TYPE" in text, "Missing Prometheus HELP/TYPE comments"

    # Should contain otelcol namespace metrics
    assert "otelcol_" in text, "Missing otelcol_ namespaced metrics"

    # Verify format with regex (metric_name{labels} value)
    metric_pattern = r"^[a-zA-Z_:][a-zA-Z0-9_:]*(\{[^}]*\})?\s+[\d.e+-]+"
    valid_lines = [line for line in text.split("\n") if line.strip() and not line.startswith("#")]

    # At least some lines should match Prometheus format
    matching_lines = [line for line in valid_lines if re.match(metric_pattern, line.strip())]

    assert len(matching_lines) > 0, f"No lines match Prometheus format. Sample: {valid_lines[:5]}"


# ============================================================================
# RECEIVER METRICS TESTS (2 tests)
# ============================================================================


def test_receiver_accepted_spans_metric():
    """Verify receiver metrics increment when spans are accepted."""
    # Get baseline
    baseline_metrics = scrape_prometheus_metrics()
    baseline_spans = (
        get_metric_value(
            baseline_metrics,
            "otelcol_receiver_accepted_spans",
            {"receiver": "otlp", "transport": "http"},
        )
        or 0
    )

    # Send test spans
    test_span_count = 50
    send_test_spans(test_span_count)

    # Wait for scrape interval
    time.sleep(SCRAPE_WAIT)

    # Get updated metrics
    updated_metrics = scrape_prometheus_metrics()
    updated_spans = get_metric_value(
        updated_metrics,
        "otelcol_receiver_accepted_spans",
        {"receiver": "otlp", "transport": "http"},
    )

    assert updated_spans is not None, "otelcol_receiver_accepted_spans metric not found"

    # Allow for some variance due to other activity
    delta = updated_spans - baseline_spans
    assert (
        delta >= test_span_count * 0.8
    ), f"Expected ~{test_span_count} spans, got delta of {delta}"


def test_receiver_metrics_present():
    """Verify all key receiver metrics are present in output."""
    metrics = scrape_prometheus_metrics()

    # Key receiver metrics that should exist
    expected_metrics = [
        "otelcol_receiver_accepted_spans",
        "otelcol_receiver_accepted_metric_points",
    ]

    missing = []
    for metric in expected_metrics:
        if metric not in metrics:
            missing.append(metric)

    assert not missing, f"Missing receiver metrics: {missing}"


# ============================================================================
# PROCESSOR METRICS TESTS (2 tests)
# ============================================================================


def test_batch_size_trigger_metric():
    """Verify batch processor metrics increment when batches are sent."""
    # Get baseline
    baseline_metrics = scrape_prometheus_metrics()
    baseline_triggers = (
        get_metric_value(
            baseline_metrics,
            "otelcol_processor_batch_batch_size_trigger_send",
            {"processor": "batch"},
        )
        or 0
    )

    # Send large batch to trigger size limit (configured at 512)
    send_test_spans(600)

    # Wait for scrape
    time.sleep(SCRAPE_WAIT)

    # Get updated metrics
    updated_metrics = scrape_prometheus_metrics()
    updated_triggers = get_metric_value(
        updated_metrics,
        "otelcol_processor_batch_batch_size_trigger_send",
        {"processor": "batch"},
    )

    if updated_triggers is not None:
        delta = updated_triggers - baseline_triggers
        assert delta > 0, f"Expected batch_size_trigger to increment, delta={delta}"


def test_batch_timeout_trigger_metric():
    """Verify batch timeout trigger metric exists (may need wait for timeout)."""
    metrics = scrape_prometheus_metrics()

    # Metric should exist even if value is 0
    timeout_metric = "otelcol_processor_batch_timeout_trigger_send"

    # Send small amount of data
    send_test_spans(5)

    # Wait for batch timeout (configured at 5s) plus scrape interval
    time.sleep(10 + SCRAPE_WAIT)

    updated_metrics = scrape_prometheus_metrics()
    value = get_metric_value(updated_metrics, timeout_metric, {"processor": "batch"})

    assert value is not None, f"{timeout_metric} metric not found after timeout period"


# ============================================================================
# EXPORTER METRICS TESTS (2 tests)
# ============================================================================


def test_exporter_sent_metric_points():
    """Verify exporter metrics increment when telemetry is exported."""
    # Get baseline
    baseline_metrics = scrape_prometheus_metrics()
    baseline_sent = (
        get_metric_value(
            baseline_metrics,
            "otelcol_exporter_sent_metric_points",
        )
        or 0
    )

    # Send test metrics
    test_metric_count = 50
    send_test_metrics(test_metric_count)

    # Wait for scrape
    time.sleep(SCRAPE_WAIT)

    # Get updated metrics
    updated_metrics = scrape_prometheus_metrics()
    updated_sent = get_metric_value(
        updated_metrics,
        "otelcol_exporter_sent_metric_points",
    )

    assert updated_sent is not None, "otelcol_exporter_sent_metric_points metric not found"

    delta = updated_sent - baseline_sent
    # Should see increase (allowing for other pipeline activity)
    assert delta > 0, f"Expected metric points to increment, delta={delta}"


def test_exporter_metrics_present():
    """Verify all key exporter metrics are present in output."""
    metrics = scrape_prometheus_metrics()

    # Key exporter metrics
    expected_metrics = [
        "otelcol_exporter_sent_spans",
        "otelcol_exporter_sent_metric_points",
    ]

    present = []
    for metric in expected_metrics:
        if metric in metrics:
            present.append(metric)

    # At least one exporter metric should be present
    assert (
        len(present) > 0
    ), f"No exporter metrics found. Expected at least one of: {expected_metrics}"


# ============================================================================
# ERROR METRICS TESTS (2 tests)
# ============================================================================


def test_error_metrics_exist():
    """Verify error metrics are exposed even if value is 0."""
    metrics = scrape_prometheus_metrics()

    # Error metrics that should exist
    error_metrics = [
        "otelcol_receiver_refused_spans",
        "otelcol_receiver_refused_metric_points",
    ]

    # At least one error metric should exist
    found = [m for m in error_metrics if m in metrics]

    assert len(found) > 0, f"No error metrics found. Expected at least one of: {error_metrics}"


def test_metrics_updated_over_time():
    """Verify metrics are updated between scrapes (not stale)."""
    # First scrape
    metrics1 = scrape_prometheus_metrics()

    # Send some data
    send_test_spans(10)

    # Wait for scrape interval
    time.sleep(SCRAPE_WAIT)

    # Second scrape
    metrics2 = scrape_prometheus_metrics()

    # At least one counter should have increased
    receiver_1 = (
        get_metric_value(metrics1, "otelcol_receiver_accepted_spans", {"receiver": "otlp"}) or 0
    )

    receiver_2 = (
        get_metric_value(metrics2, "otelcol_receiver_accepted_spans", {"receiver": "otlp"}) or 0
    )

    assert receiver_2 >= receiver_1, "Metrics appear stale (counters did not increase)"


# ============================================================================
# RESOURCE METRICS TESTS (2 tests)
# ============================================================================


def test_process_metrics_present():
    """Verify process resource metrics are exposed."""
    metrics = scrape_prometheus_metrics()

    # Key resource metrics
    expected_metrics = [
        "process_cpu_seconds_total",
        "process_resident_memory_bytes",
        "go_goroutines",
    ]

    present = []
    for metric in expected_metrics:
        if metric in metrics:
            present.append(metric)

    # At least 2 resource metrics should be present
    assert (
        len(present) >= 2
    ), f"Only found {len(present)} resource metrics: {present}. Expected at least 2 of: {expected_metrics}"


def test_memory_metric_reasonable():
    """Verify memory metric has reasonable value (not zero, not excessive)."""
    metrics = scrape_prometheus_metrics()

    memory_bytes = get_metric_value(metrics, "process_resident_memory_bytes")

    assert memory_bytes is not None, "process_resident_memory_bytes metric not found"

    # Memory should be > 10 MiB and < 512 MiB (configured limit)
    min_memory = 10 * 1024 * 1024  # 10 MiB
    max_memory = 512 * 1024 * 1024  # 512 MiB

    assert (
        memory_bytes > min_memory
    ), f"Memory usage suspiciously low: {memory_bytes / 1024 / 1024:.1f} MiB"

    assert (
        memory_bytes < max_memory
    ), f"Memory usage exceeds limit: {memory_bytes / 1024 / 1024:.1f} MiB > 512 MiB"
