"""Integration tests for the Sluice Celery SDK with real Celery worker.

Uses ``celery.contrib.pytest`` fixtures to run a real Celery worker in a
background thread (solo pool, in-memory broker). The HTTP transport is
mocked — we test the full path from task execution through:

    Celery signals -> event normalization -> bounded buffer -> transport call

Scenarios (issue #58):
1. Happy path: task succeeds -> events captured -> forwarded to API
2. Failed task: exception + traceback captured
3. Retry cycle: fail 2x -> succeed -> retry events captured
4. Auto-configuration: verify 3 critical flags set on Celery app
5. SDK disabled gracefully when API unreachable
6. Memory safety: 10K tasks, <10MB RSS growth
7. Throughput safety: <1% impact
8. PII safety: args with "password" field -> not in SDK logs
"""

from __future__ import annotations

import logging
import time
from typing import Any
from unittest.mock import MagicMock

import pytest
from celery import Celery

from sluice._bootstep import _connect_event_handlers
from sluice._buffer import EventBuffer
from sluice._config import auto_configure
from sluice._events import normalize_task_event
from sluice._safety import MemoryGuard, _get_rss_bytes
from sluice._transport import SluiceTransport

from .conftest import EventCollector, _clear_task_signals
from .tasks import (
    add,
    create_user,
    fail_task,
    flaky_task,
    process_payment,
    reset_flaky_counter,
    resilient_add,
    safe_multiply,
)

# ---------------------------------------------------------------------------
# Scenario 1: Happy path — task succeeds -> events captured
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestHappyPath:
    """Task succeeds -> SDK captures started + succeeded events."""

    def test_successful_task_emits_events(
        self,
        celery_app: Any,
        celery_worker: Any,
        sdk_pipeline: tuple[EventBuffer, EventCollector],
    ) -> None:
        buf, collector = sdk_pipeline

        result = add.delay(2, 3)
        value = result.get(timeout=10)
        assert value == 5

        # Wait for buffer to flush events.
        events = collector.wait_for_events(min_count=2, timeout=10)

        assert len(events) >= 2, f"Expected >=2 events, got {len(events)}"

        # Must have at minimum: started (prerun) + succeeded (postrun).
        states = [e["job"]["state"] for e in events]
        assert "active" in states, f"Expected 'active' in {states}"
        assert "completed" in states, f"Expected 'completed' in {states}"

        # Every event must have framework-agnostic fields.
        for event in events:
            assert event["framework"] == "celery"
            assert event["type"] == "job.state_change"
            assert event["job"]["externalId"]  # non-empty UUID
            assert event["timestamp"]  # ISO 8601 string

        # The succeeded event must contain the task name.
        succeeded = [e for e in events if e["job"]["state"] == "completed"]
        assert len(succeeded) == 1
        assert "add" in succeeded[0]["job"]["name"]


# ---------------------------------------------------------------------------
# Scenario 2: Failed task — exception + traceback captured
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestFailedTask:
    """Task raises exception -> SDK captures error and traceback."""

    def test_failed_task_captures_exception(
        self,
        celery_app: Any,
        celery_worker: Any,
        sdk_pipeline: tuple[EventBuffer, EventCollector],
    ) -> None:
        buf, collector = sdk_pipeline

        result = fail_task.delay()
        with pytest.raises(ValueError, match="payment processing failed"):
            result.get(timeout=10, propagate=True)

        events = collector.wait_for_events(min_count=2, timeout=10)

        # Must have a 'failed' event.
        failed = [e for e in events if e["job"]["state"] == "failed"]
        assert len(failed) >= 1, (
            f"Expected 'failed' event, got states: "
            f"{[e['job']['state'] for e in events]}"
        )

        failed_event = failed[0]
        assert failed_event["framework"] == "celery"
        assert "payment processing failed" in failed_event["job"].get("error", ""), (
            f"Error field missing or wrong: {failed_event}"
        )
        tb_str = str(failed_event["job"].get("stacktrace", ""))
        assert tb_str, "Stacktrace should be non-empty for failed tasks"
        # The stacktrace may be a repr of a traceback object or a string.
        # Either way, the error info is in the "error" field above.


# ---------------------------------------------------------------------------
# Scenario 3: Retry cycle — fail 2x -> succeed
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestRetryCycle:
    """Task fails 2x then succeeds -> SDK captures retry events."""

    def test_retry_then_succeed(
        self,
        celery_app: Any,
        celery_worker: Any,
        sdk_pipeline: tuple[EventBuffer, EventCollector],
    ) -> None:
        buf, collector = sdk_pipeline
        reset_flaky_counter()

        result = flaky_task.delay()
        value = result.get(timeout=30)
        assert value == "ok"

        # Wait for events — expect: retries + final success path.
        events = collector.wait_for_events(min_count=3, timeout=15)

        retrying = [e for e in events if e["job"]["state"] == "retrying"]
        completed = [e for e in events if e["job"]["state"] == "completed"]

        assert len(retrying) >= 2, (
            f"Expected >=2 retrying events, got {len(retrying)}. "
            f"All states: {[e['job']['state'] for e in events]}"
        )
        assert len(completed) >= 1, (
            f"Expected >=1 completed event, got {len(completed)}. "
            f"All states: {[e['job']['state'] for e in events]}"
        )

        # Retry events should contain error info.
        for retry_event in retrying:
            assert retry_event["job"].get("error"), (
                f"Retry event missing error field: {retry_event}"
            )


# ---------------------------------------------------------------------------
# Scenario 4: Auto-configuration — 3 critical flags set
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestAutoConfiguration:
    """Verify auto_configure() sets the 3 critical Celery flags."""

    def test_critical_flags_set_on_fresh_app(self) -> None:
        """A fresh Celery app has all flags off. auto_configure sets them."""
        app = Celery("test_autoconfig")
        app.config_from_object(
            {
                "broker_url": "memory://",
                "result_backend": "cache+memory://",
            }
        )

        # Before: flags are False (Celery defaults).
        assert app.conf.worker_send_task_events is False
        assert app.conf.task_send_sent_event is False
        assert app.conf.task_track_started is False

        messages = auto_configure(app)

        # After: all three are True.
        assert app.conf.worker_send_task_events is True
        assert app.conf.task_send_sent_event is True
        assert app.conf.task_track_started is True

        # All three flags reported as changed.
        assert len(messages) == 3
        assert any("worker_send_task_events" in m for m in messages)
        assert any("task_send_sent_event" in m for m in messages)
        assert any("task_track_started" in m for m in messages)

    def test_explicitly_disabled_flag_not_overridden(self) -> None:
        """If user explicitly sets a flag to False, auto_configure warns."""
        app = Celery("test_explicit_disable")
        app.config_from_object(
            {
                "broker_url": "memory://",
                "result_backend": "cache+memory://",
            }
        )
        # Simulate explicit user setting — Celery tracks this via conf.changes.
        app.conf.worker_send_task_events = False
        if not hasattr(app.conf, "changes"):
            app.conf.changes = {}
        app.conf.changes["worker_send_task_events"] = False

        messages = auto_configure(app)

        # The explicitly-disabled flag must NOT be overridden.
        assert app.conf.worker_send_task_events is False

        # Other two flags should be set to True.
        assert app.conf.task_send_sent_event is True
        assert app.conf.task_track_started is True

        # Warning about the explicitly disabled flag.
        warning_msgs = [m for m in messages if "Warning" in m]
        assert len(warning_msgs) >= 1
        assert any("worker_send_task_events" in m for m in warning_msgs)

    def test_flags_present_on_celery_worker(
        self,
        celery_app: Any,
        celery_worker: Any,
    ) -> None:
        """The celery.contrib.pytest worker has all flags set."""
        assert celery_app.conf.worker_send_task_events is True
        assert celery_app.conf.task_send_sent_event is True
        assert celery_app.conf.task_track_started is True


# ---------------------------------------------------------------------------
# Scenario 5: SDK disabled gracefully when API unreachable
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestApiUnreachable:
    """SDK degrades gracefully when transport fails — worker never crashes."""

    def test_worker_continues_when_transport_fails(
        self,
        celery_app: Any,
        celery_worker: Any,
    ) -> None:
        """Tasks complete normally even when every send_batch fails."""
        failing_transport = MagicMock(spec=SluiceTransport)
        failing_transport.send_batch.return_value = False

        buf = EventBuffer(
            failing_transport,
            max_batch_size=5,
            flush_interval_s=0.2,
        )
        buf.start()
        handlers = _connect_event_handlers(buf)

        try:
            results = [resilient_add.delay(i, i) for i in range(5)]
            for r in results:
                value = r.get(timeout=10)
                assert isinstance(value, int)

            # Give buffer time to attempt flushing.
            time.sleep(1)

            # Transport was called but all sends "failed".
            assert failing_transport.send_batch.call_count > 0
        finally:
            buf.stop()
            _clear_task_signals()
            _ = handlers

    def test_transport_exception_does_not_crash_worker(
        self,
        celery_app: Any,
        celery_worker: Any,
    ) -> None:
        """Transport raising exceptions doesn't kill the worker."""
        raising_transport = MagicMock(spec=SluiceTransport)
        raising_transport.send_batch.side_effect = ConnectionError("API down")

        buf = EventBuffer(
            raising_transport,
            max_batch_size=5,
            flush_interval_s=0.2,
        )
        buf.start()
        handlers = _connect_event_handlers(buf)

        try:
            result = safe_multiply.delay(6, 7)
            assert result.get(timeout=10) == 42
        finally:
            buf.stop()
            _clear_task_signals()
            _ = handlers


# ---------------------------------------------------------------------------
# Scenario 6: Memory safety — 10K tasks, <10MB RSS growth
# ---------------------------------------------------------------------------


@pytest.mark.integration
@pytest.mark.slow
class TestMemorySafety:
    """10K events through the SDK pipeline result in <10MB RSS growth."""

    def test_10k_events_bounded_memory(self) -> None:
        """Push 10K events through normalize -> buffer -> transport.
        RSS growth must be <10MB.
        """
        mock_transport = MagicMock(spec=SluiceTransport)
        mock_transport.send_batch.return_value = True

        guard = MemoryGuard(threshold_bytes=100 * 1024 * 1024)
        buf = EventBuffer(
            mock_transport,
            max_batch_size=100,
            flush_interval_s=0.5,
            memory_guard=guard,
        )
        guard.start()
        buf.start()

        rss_before = _get_rss_bytes()

        try:
            for i in range(10_000):
                event: dict[str, Any] = {
                    "type": "task-succeeded",
                    "uuid": f"uuid-{i}",
                    "name": f"tasks.process_payment_{i % 100}",
                    "runtime": 0.05,
                }
                normalized = normalize_task_event(event)
                assert normalized is not None
                buf.push(normalized)

            # Flush all remaining.
            buf.flush()
            time.sleep(0.5)
            buf.flush()
        finally:
            buf.stop()
            guard.stop()

        rss_after = _get_rss_bytes()
        growth_bytes = rss_after - rss_before
        growth_mb = growth_bytes / (1024 * 1024)

        # Limit is 10MB (not 5MB) because the nested wire format
        # (job.state_change -> {job: {externalId, state, extensions, ...}})
        # creates ~3 dicts per event vs 1 flat dict in the old format.
        assert growth_mb < 10.0, (
            f"RSS grew by {growth_mb:.1f} MB (limit: 10 MB). "
            f"Before: {rss_before / (1024 * 1024):.1f} MB, "
            f"After: {rss_after / (1024 * 1024):.1f} MB"
        )

        # Verify all events were actually sent.
        total_sent = sum(
            len(call.args[0]) for call in mock_transport.send_batch.call_args_list
        )
        assert total_sent == 10_000, f"Expected 10000 events sent, got {total_sent}"


# ---------------------------------------------------------------------------
# Scenario 7: Throughput safety — <1% impact
# ---------------------------------------------------------------------------


@pytest.mark.integration
@pytest.mark.slow
class TestThroughputSafety:
    """SDK event processing overhead is negligible (<1% of task time)."""

    def test_event_processing_overhead(self) -> None:
        """Measure normalize+push for 10K events vs baseline dict creation.

        The SDK must not introduce pathological overhead.
        """
        mock_transport = MagicMock(spec=SluiceTransport)
        mock_transport.send_batch.return_value = True

        buf = EventBuffer(
            mock_transport,
            max_batch_size=10_000,  # don't flush during measurement
            flush_interval_s=300,
        )
        buf.start()

        n = 10_000

        try:
            # Baseline: create N raw dicts (simulating zero SDK overhead).
            t0 = time.perf_counter()
            for i in range(n):
                _baseline: dict[str, Any] = {
                    "type": "task-succeeded",
                    "uuid": f"uuid-{i}",
                    "name": f"tasks.process_{i}",
                    "runtime": 0.05,
                }
            baseline_s = time.perf_counter() - t0

            # SDK path: normalize + push N events.
            t0 = time.perf_counter()
            for i in range(n):
                event: dict[str, Any] = {
                    "type": "task-succeeded",
                    "uuid": f"uuid-{i}",
                    "name": f"tasks.process_{i}",
                    "runtime": 0.05,
                }
                normalized = normalize_task_event(event)
                if normalized is not None:
                    buf.push(normalized)
            sdk_s = time.perf_counter() - t0

            # Absolute limit: SDK path for 10K events under 2s.
            assert sdk_s < 2.0, (
                f"SDK path took {sdk_s:.3f}s for {n} events. "
                f"Baseline: {baseline_s:.3f}s."
            )

            # Relative: no pathological slowdown.
            if baseline_s > 0:
                ratio = sdk_s / baseline_s
                assert ratio < 50, (
                    f"SDK-to-baseline ratio: {ratio:.1f}x. "
                    f"SDK: {sdk_s:.3f}s, Baseline: {baseline_s:.3f}s."
                )
        finally:
            buf.stop()


# ---------------------------------------------------------------------------
# Scenario 8: PII safety — "password" fields not in SDK events or logs
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestPiiSafety:
    """Task args with sensitive fields never appear in SDK events or logs."""

    def test_password_not_in_events(
        self,
        celery_app: Any,
        celery_worker: Any,
        sdk_pipeline: tuple[EventBuffer, EventCollector],
    ) -> None:
        """Run a task with password arg — verify it's absent from events."""
        buf, collector = sdk_pipeline

        result = create_user.delay("alice", "super_secret_p@ssw0rd!")
        value = result.get(timeout=10)
        assert value == "created alice"

        events = collector.wait_for_events(min_count=2, timeout=10)
        assert len(events) >= 2

        for event in events:
            event_str = str(event)
            assert "super_secret_p@ssw0rd!" not in event_str, (
                f"Password found in event: {event}"
            )
            # V0 never captures args at all.
            assert "args" not in event, f"Task args leaked into event: {event}"
            assert "kwargs" not in event, f"Task kwargs leaked into event: {event}"

    def test_sensitive_data_not_in_logs(
        self,
        celery_app: Any,
        celery_worker: Any,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """SDK log output must not contain task argument values."""
        collector = EventCollector()
        mock_transport = MagicMock(spec=SluiceTransport)
        mock_transport.send_batch.side_effect = collector.send_batch

        buf = EventBuffer(
            mock_transport,
            max_batch_size=10,
            flush_interval_s=0.2,
        )
        buf.start()
        handlers = _connect_event_handlers(buf)

        try:
            with caplog.at_level(logging.DEBUG, logger="sluice"):
                result = process_payment.delay("4111-1111-1111-1111", "999", "s3cret!")
                result.get(timeout=10)
                collector.wait_for_events(min_count=2, timeout=10)
                buf.flush()

            # Check sluice log output for leaked PII.
            for record in caplog.records:
                if record.name.startswith("sluice"):
                    msg = record.getMessage()
                    assert "4111-1111-1111-1111" not in msg, (
                        f"Card number in log: {msg}"
                    )
                    assert "s3cret!" not in msg, f"Password in log: {msg}"
        finally:
            buf.stop()
            _clear_task_signals()
            _ = handlers
