"""Tests for the pipeline stage event publisher."""

from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

import pytest
import redis

from src.services import pipeline_events
from src.services.pipeline_events import (
    _build_channel,
    _build_payload,
    publish_stage_event,
)

PUBLISH_KWARGS = {
    "document_id": "doc-123",
    "tenant_id": "tenant-acme",
    "node_id": "node-root",
    "correlation_id": "run-abc",
}


@pytest.fixture
def enabled_settings():
    with patch.object(pipeline_events.settings, "pipeline_events_enabled", True), \
         patch.object(pipeline_events.settings, "pipeline_event_channel_prefix", "pipeline"), \
         patch.object(pipeline_events.settings, "redis_url", "redis://localhost:6379/0"):
        yield


@pytest.fixture
def mock_redis(enabled_settings):
    fake_client = MagicMock()
    with patch.object(pipeline_events.redis.Redis, "from_url", return_value=fake_client) as from_url:
        yield fake_client, from_url


class TestBuildChannel:
    def test_channel_format(self):
        with patch.object(pipeline_events.settings, "pipeline_event_channel_prefix", "pipeline"):
            assert _build_channel("tenant-acme", "node-root") == "pipeline:tenant-acme:node-root"

    def test_honors_custom_prefix(self):
        with patch.object(pipeline_events.settings, "pipeline_event_channel_prefix", "pipe-dev"):
            assert _build_channel("t1", "n1") == "pipe-dev:t1:n1"


class TestBuildPayload:
    def test_required_fields_present(self):
        payload = _build_payload(
            stage="processing",
            status="started",
            **PUBLISH_KWARGS,
        )
        for key in ("correlation_id", "document_id", "tenant_id", "node_id", "stage", "status", "timestamp"):
            assert key in payload
        assert "error" not in payload

    def test_error_included_when_provided(self):
        payload = _build_payload(
            stage="failed",
            status="failed",
            error="boom",
            **PUBLISH_KWARGS,
        )
        assert payload["error"] == "boom"

    def test_timestamp_is_iso_utc_z(self):
        payload = _build_payload(stage="pending", status="started", **PUBLISH_KWARGS)
        assert payload["timestamp"].endswith("Z")


class TestPublishStageEvent:
    def test_happy_path_publishes_to_redis(self, mock_redis):
        fake_client, from_url = mock_redis

        publish_stage_event("processing", "started", **PUBLISH_KWARGS)

        from_url.assert_called_once_with("redis://localhost:6379/0")
        fake_client.publish.assert_called_once()
        channel, raw_payload = fake_client.publish.call_args.args
        assert channel == "pipeline:tenant-acme:node-root"
        payload = json.loads(raw_payload)
        assert payload["stage"] == "processing"
        assert payload["status"] == "started"
        assert payload["document_id"] == "doc-123"
        assert payload["tenant_id"] == "tenant-acme"
        assert payload["node_id"] == "node-root"
        assert payload["correlation_id"] == "run-abc"
        assert "timestamp" in payload
        assert "error" not in payload

    def test_payload_includes_error_on_failed_terminal(self, mock_redis):
        fake_client, _ = mock_redis

        publish_stage_event("failed", "failed", error="parser_crashed", **PUBLISH_KWARGS)

        _, raw_payload = fake_client.publish.call_args.args
        payload = json.loads(raw_payload)
        assert payload["stage"] == "failed"
        assert payload["status"] == "failed"
        assert payload["error"] == "parser_crashed"

    def test_flag_off_is_noop(self):
        with patch.object(pipeline_events.settings, "pipeline_events_enabled", False), \
             patch.object(pipeline_events.redis.Redis, "from_url") as from_url:
            publish_stage_event("processing", "started", **PUBLISH_KWARGS)
            from_url.assert_not_called()

    def test_missing_tenant_id_skips_publish(self, enabled_settings):
        with patch.object(pipeline_events.redis.Redis, "from_url") as from_url, \
             patch.object(pipeline_events.logger, "error") as err_log:
            publish_stage_event(
                "processing",
                "started",
                document_id="doc-1",
                tenant_id="",
                node_id="node-root",
                correlation_id="run-1",
            )
            from_url.assert_not_called()
            err_log.assert_called_once()
            assert err_log.call_args.args[0] == "pipeline_event.missing_routing_id"

    def test_missing_node_id_skips_publish(self, enabled_settings):
        with patch.object(pipeline_events.redis.Redis, "from_url") as from_url:
            publish_stage_event(
                "processing",
                "started",
                document_id="doc-1",
                tenant_id="tenant-acme",
                node_id="",
                correlation_id="run-1",
            )
            from_url.assert_not_called()

    def test_redis_error_is_swallowed(self, enabled_settings):
        failing_client = MagicMock()
        failing_client.publish.side_effect = redis.ConnectionError("redis down")

        with patch.object(pipeline_events.redis.Redis, "from_url", return_value=failing_client), \
             patch.object(pipeline_events.logger, "warning") as warn_log:
            publish_stage_event("processing", "started", **PUBLISH_KWARGS)

        failing_client.publish.assert_called_once()
        warn_log.assert_called_once()
        assert warn_log.call_args.args[0] == "pipeline_event.publish_failed"

    def test_from_url_error_is_swallowed(self, enabled_settings):
        with patch.object(
            pipeline_events.redis.Redis,
            "from_url",
            side_effect=redis.RedisError("cannot connect"),
        ):
            publish_stage_event("processing", "started", **PUBLISH_KWARGS)
