"""Tests for sluice._events — event normalization.

These tests validate the SDK wire format against the ingestEventSchema
defined in dashboard/lib/schemas/api.ts. Every assertion on field names,
nesting, and types reflects a real Zod constraint.
"""

from __future__ import annotations

from datetime import UTC, datetime

from sluice._events import (
    _CELERY_STATE_MAP,
    _EVENT_TYPE_TO_STATE,
    _WORKER_EVENT_TYPES,
    normalize_task_event,
    normalize_worker_event,
)


def _is_iso8601_z(value: object) -> bool:
    """Check if a value is a valid ISO 8601 datetime string with Z suffix.

    Zod's z.string().datetime() requires the Z suffix.
    """
    if not isinstance(value, str):
        return False
    if not value.endswith("Z"):
        return False
    try:
        # Python's fromisoformat handles Z since 3.11.
        datetime.fromisoformat(value)
        return True
    except ValueError:
        return False


class TestNormalizeTaskEvent:
    """Tests for normalize_task_event().

    Wire format contract (from ingestEventSchema):
    - type: string (min 1)
    - timestamp: ISO 8601 datetime string
    - framework: "celery" | "bullmq" | "sidekiq"
    - job: nested object with camelCase keys
      - externalId: string (min 1, max 512)
      - name: string (min 1, max 1024)
      - state: unified state enum
      - queue, workerId, attempt, error, stacktrace, extensions: optional

    connectionId is injected by the transport, NOT by normalize_task_event.
    """

    def test_all_task_event_types_mapped(self) -> None:
        """Every task event type in the mapping should produce a result."""
        for event_type in _EVENT_TYPE_TO_STATE:
            event = {"type": event_type, "uuid": "abc-123"}
            result = normalize_task_event(event)
            assert result is not None, f"Event type {event_type} returned None"
            assert result["type"] == "job.state_change"
            assert result["framework"] == "celery"
            assert "job" in result

    def test_celery_state_mapping_completeness(self) -> None:
        """All Celery states should be mapped to unified states."""
        expected = {
            "PENDING": "unknown",
            "RECEIVED": "queued",
            "STARTED": "active",
            "SUCCESS": "completed",
            "FAILURE": "failed",
            "RETRY": "retrying",
            "REVOKED": "cancelled",
            "REJECTED": "failed",
        }
        assert expected == _CELERY_STATE_MAP

    def test_task_sent_event(self) -> None:
        """task-sent should produce nested format with 'unknown' state."""
        event = {
            "type": "task-sent",
            "uuid": "task-001",
            "name": "app.tasks.send_email",
            "queue": "default",
            "timestamp": 1700000000.0,
        }
        result = normalize_task_event(event)
        assert result is not None

        # Top-level fields match ingestEventSchema.
        assert result["type"] == "job.state_change"
        assert result["framework"] == "celery"
        assert _is_iso8601_z(result["timestamp"])
        # connectionId is NOT present — injected by transport.
        assert "connectionId" not in result

        # Nested job object.
        job = result["job"]
        assert job["externalId"] == "task-001"
        assert job["state"] == "unknown"
        assert job["name"] == "app.tasks.send_email"
        assert job["queue"] == "default"

    def test_task_received_event(self) -> None:
        """task-received should produce nested 'queued' state with workerId."""
        event = {
            "type": "task-received",
            "uuid": "task-001",
            "hostname": "worker@host",
        }
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["state"] == "queued"
        assert result["job"]["workerId"] == "worker@host"

    def test_task_started_event(self) -> None:
        """task-started should produce nested 'active' state."""
        event = {"type": "task-started", "uuid": "task-001"}
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["state"] == "active"

    def test_task_succeeded_event(self) -> None:
        """task-succeeded should produce nested 'completed' state."""
        event = {
            "type": "task-succeeded",
            "uuid": "task-001",
        }
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["state"] == "completed"

    def test_task_failed_event(self) -> None:
        """task-failed should produce nested 'failed' state with error info."""
        event = {
            "type": "task-failed",
            "uuid": "task-001",
            "exception": "ValueError('bad input')",
            "traceback": "Traceback (most recent call last):\n  ...",
        }
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["state"] == "failed"
        assert result["job"]["error"] == "ValueError('bad input')"
        assert "Traceback" in result["job"]["stacktrace"]

    def test_task_retried_event(self) -> None:
        """task-retried should produce nested 'retrying' state with attempt."""
        event = {
            "type": "task-retried",
            "uuid": "task-001",
            "retries": 2,
        }
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["state"] == "retrying"
        assert result["job"]["attempt"] == 3  # retries + 1

    def test_task_revoked_event(self) -> None:
        """task-revoked should produce nested 'cancelled' state."""
        event = {"type": "task-revoked", "uuid": "task-001"}
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["state"] == "cancelled"

    def test_task_rejected_event(self) -> None:
        """task-rejected should produce nested 'failed' state."""
        event = {"type": "task-rejected", "uuid": "task-001"}
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["state"] == "failed"

    def test_unknown_event_type_returns_none(self) -> None:
        """Unknown event types should return None."""
        event = {"type": "task-unknown", "uuid": "task-001"}
        assert normalize_task_event(event) is None

    def test_missing_uuid_returns_none(self) -> None:
        """Events without a UUID should return None."""
        event = {"type": "task-started"}
        assert normalize_task_event(event) is None

    def test_empty_uuid_returns_none(self) -> None:
        """Events with an empty UUID should return None."""
        event = {"type": "task-started", "uuid": ""}
        assert normalize_task_event(event) is None

    def test_timestamp_is_iso8601_with_z_suffix(self) -> None:
        """Timestamps must be ISO 8601 with Z suffix (Zod requirement)."""
        event = {
            "type": "task-started",
            "uuid": "task-001",
            "timestamp": 1700000000.0,
        }
        result = normalize_task_event(event)
        assert result is not None
        assert isinstance(result["timestamp"], str)
        assert result["timestamp"].endswith("Z"), (
            f"Timestamp must end with Z, got: {result['timestamp']}"
        )
        assert _is_iso8601_z(result["timestamp"])

    def test_missing_timestamp_uses_current_time_with_z_suffix(self) -> None:
        """Events without a timestamp should use current time with Z suffix."""
        event = {"type": "task-started", "uuid": "task-001"}
        before = datetime.now(tz=UTC)
        result = normalize_task_event(event)
        after = datetime.now(tz=UTC)
        assert result is not None
        assert result["timestamp"].endswith("Z")
        ts = datetime.fromisoformat(result["timestamp"])
        assert before <= ts <= after

    def test_extensions_for_eta(self) -> None:
        """ETA should be stored in job.extensions."""
        event = {
            "type": "task-sent",
            "uuid": "task-001",
            "eta": "2026-02-16T12:00:00",
        }
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["extensions"]["eta"] == "2026-02-16T12:00:00"

    def test_extensions_for_routing(self) -> None:
        """Exchange and routing_key go into job.extensions."""
        event = {
            "type": "task-sent",
            "uuid": "task-001",
            "exchange": "celery",
            "routing_key": "default",
        }
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["extensions"]["exchange"] == "celery"
        assert result["job"]["extensions"]["routing_key"] == "default"

    def test_extensions_always_include_celery_state(self) -> None:
        """Extensions should always include the original Celery state."""
        event = {"type": "task-started", "uuid": "task-001"}
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["extensions"]["celeryState"] == "STARTED"

    def test_missing_name_defaults_to_unknown(self) -> None:
        """Events without a task name should default to 'unknown' (min 1)."""
        event = {"type": "task-started", "uuid": "task-001"}
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["name"] == "unknown"

    def test_empty_name_defaults_to_unknown(self) -> None:
        """Events with an empty task name should default to 'unknown'."""
        event = {"type": "task-started", "uuid": "task-001", "name": ""}
        result = normalize_task_event(event)
        assert result is not None
        assert result["job"]["name"] == "unknown"

    def test_optional_job_fields_absent(self) -> None:
        """Optional fields should not appear when not in the source event."""
        event = {"type": "task-started", "uuid": "task-001"}
        result = normalize_task_event(event)
        assert result is not None
        job = result["job"]
        assert "queue" not in job
        assert "workerId" not in job
        assert "attempt" not in job
        assert "error" not in job
        assert "stacktrace" not in job

    def test_top_level_structure_matches_ingest_schema(self) -> None:
        """Verify the complete top-level structure matches ingestEventSchema."""
        event = {
            "type": "task-started",
            "uuid": "task-001",
            "name": "my_task",
            "timestamp": 1700000000.0,
        }
        result = normalize_task_event(event)
        assert result is not None

        # Required top-level fields per ingestEventSchema.
        assert set(result.keys()) == {"type", "timestamp", "framework", "job"}
        assert isinstance(result["type"], str)
        assert isinstance(result["timestamp"], str)
        assert result["framework"] == "celery"
        assert isinstance(result["job"], dict)

    def test_no_snake_case_keys_in_output(self) -> None:
        """Wire format must use camelCase, not snake_case."""
        event = {
            "type": "task-received",
            "uuid": "task-001",
            "hostname": "worker@host",
            "retries": 1,
            "exception": "err",
            "traceback": "tb",
        }
        result = normalize_task_event(event)
        assert result is not None

        # Check top-level keys.
        for key in result:
            assert "_" not in key, f"Top-level key '{key}' contains underscore"

        # Check job keys — camelCase required.
        job = result["job"]
        snake_case_keys = {
            "external_id",
            "worker_id",
            "celery_state",
            "event_type",
            "duration_ms",
        }
        for key in job:
            assert key not in snake_case_keys, (
                f"Job key '{key}' is snake_case — expected camelCase"
            )

    def test_malformed_timestamp_falls_back_to_now(self) -> None:
        """Non-numeric timestamp should not crash — falls back to now."""
        event = {
            "type": "task-started",
            "uuid": "task-001",
            "timestamp": "not-a-number",
        }
        before = datetime.now(tz=UTC)
        result = normalize_task_event(event)
        after = datetime.now(tz=UTC)
        assert result is not None
        ts = datetime.fromisoformat(result["timestamp"])
        assert before <= ts <= after

    def test_succeeded_with_runtime_populates_timing(self) -> None:
        """task-succeeded with runtime should populate startedAt/completedAt."""
        event = {
            "type": "task-succeeded",
            "uuid": "task-001",
            "name": "my_task",
            "timestamp": 1700000010.0,
            "runtime": 2.5,
        }
        result = normalize_task_event(event)
        assert result is not None
        job = result["job"]
        assert "completedAt" in job
        assert "startedAt" in job
        assert job["completedAt"].endswith("Z")
        assert job["startedAt"].endswith("Z")
        # completedAt should be the event timestamp
        completed = datetime.fromisoformat(job["completedAt"])
        started = datetime.fromisoformat(job["startedAt"])
        delta = (completed - started).total_seconds()
        assert abs(delta - 2.5) < 0.01

    def test_non_succeeded_event_has_no_timing(self) -> None:
        """Non-succeeded events should not have startedAt/completedAt."""
        event = {
            "type": "task-started",
            "uuid": "task-001",
            "runtime": 1.0,
        }
        result = normalize_task_event(event)
        assert result is not None
        assert "startedAt" not in result["job"]
        assert "completedAt" not in result["job"]


class TestNormalizeWorkerEvent:
    """Tests for normalize_worker_event().

    Wire format contract (from ingestEventSchema):
    - type: string (min 1)
    - timestamp: ISO 8601 datetime string
    - framework: "celery"
    - worker: nested object with camelCase keys
      - externalId: string (min 1, max 512)
      - hostname: string (min 1, max 512)
      - state: worker state enum ("online" | "heartbeat" | "offline" etc.)
      - pid, activeJobs, concurrency, metadata: optional
    """

    def test_all_worker_event_types(self) -> None:
        """All known worker event types should produce results."""
        for event_type in _WORKER_EVENT_TYPES:
            event = {"type": event_type, "hostname": "worker@host"}
            result = normalize_worker_event(event)
            assert result is not None, f"Worker event {event_type} returned None"
            assert result["type"] == "worker.state_change"
            assert result["framework"] == "celery"
            assert "worker" in result

    def test_worker_online_event(self) -> None:
        """worker-online should produce nested 'online' state."""
        event = {
            "type": "worker-online",
            "hostname": "celery@worker1",
            "pid": 12345,
            "sw_ident": "py-celery",
            "sw_ver": "5.6.2",
            "timestamp": 1700000000.0,
        }
        result = normalize_worker_event(event)
        assert result is not None

        # Top-level structure.
        assert result["type"] == "worker.state_change"
        assert result["framework"] == "celery"
        assert _is_iso8601_z(result["timestamp"])
        assert "connectionId" not in result

        # Nested worker object.
        worker = result["worker"]
        assert worker["externalId"] == "celery@worker1"
        assert worker["hostname"] == "celery@worker1"
        assert worker["state"] == "online"
        assert worker["pid"] == 12345
        assert worker["metadata"]["software"] == "py-celery"
        assert worker["metadata"]["softwareVersion"] == "5.6.2"

    def test_worker_heartbeat_event(self) -> None:
        """worker-heartbeat should produce 'heartbeat' state with metrics."""
        event = {
            "type": "worker-heartbeat",
            "hostname": "celery@worker1",
            "active": 3,
            "processed": 1500,
            "loadavg": [1.2, 0.8, 0.6],
            "freq": 2.0,
        }
        result = normalize_worker_event(event)
        assert result is not None

        worker = result["worker"]
        assert worker["state"] == "heartbeat"
        assert worker["activeJobs"] == 3
        assert worker["metadata"]["processedTotal"] == 1500
        assert worker["metadata"]["loadavg"] == [1.2, 0.8, 0.6]
        assert worker["metadata"]["heartbeatFreq"] == 2.0

    def test_worker_offline_event(self) -> None:
        """worker-offline should produce nested 'offline' state."""
        event = {"type": "worker-offline", "hostname": "celery@worker1"}
        result = normalize_worker_event(event)
        assert result is not None
        assert result["worker"]["state"] == "offline"

    def test_unknown_worker_event_returns_none(self) -> None:
        """Unknown worker event types should return None."""
        event = {"type": "worker-unknown", "hostname": "celery@worker1"}
        assert normalize_worker_event(event) is None

    def test_missing_hostname_returns_none(self) -> None:
        """Worker events without hostname should return None."""
        event = {"type": "worker-online"}
        assert normalize_worker_event(event) is None

    def test_empty_hostname_returns_none(self) -> None:
        """Worker events with empty hostname should return None."""
        event = {"type": "worker-online", "hostname": ""}
        assert normalize_worker_event(event) is None

    def test_optional_fields_absent(self) -> None:
        """Optional fields should not appear when missing from source."""
        event = {"type": "worker-online", "hostname": "worker@host"}
        result = normalize_worker_event(event)
        assert result is not None
        worker = result["worker"]
        assert "pid" not in worker
        assert "activeJobs" not in worker
        assert "metadata" not in worker

    def test_worker_timestamp_is_iso8601(self) -> None:
        """Worker timestamps should be ISO 8601 strings."""
        event = {
            "type": "worker-online",
            "hostname": "worker@host",
            "timestamp": 1700000000.0,
        }
        result = normalize_worker_event(event)
        assert result is not None
        assert isinstance(result["timestamp"], str)
        assert _is_iso8601_z(result["timestamp"])

    def test_worker_top_level_structure_matches_ingest_schema(self) -> None:
        """Verify the complete top-level structure for worker events."""
        event = {
            "type": "worker-online",
            "hostname": "worker@host",
            "timestamp": 1700000000.0,
        }
        result = normalize_worker_event(event)
        assert result is not None
        assert set(result.keys()) == {"type", "timestamp", "framework", "worker"}

    def test_no_flat_snake_case_fields(self) -> None:
        """Worker events must not have flat snake_case fields."""
        event = {
            "type": "worker-heartbeat",
            "hostname": "worker@host",
            "active": 3,
            "processed": 100,
            "sw_ident": "py-celery",
            "sw_ver": "5.6.2",
            "loadavg": [1.0],
            "freq": 2.0,
        }
        result = normalize_worker_event(event)
        assert result is not None

        # Old flat snake_case fields must NOT be present at top level.
        assert "external_id" not in result
        assert "active_jobs" not in result
        assert "processed_total" not in result
        assert "software" not in result
        assert "software_version" not in result
        assert "heartbeat_freq" not in result

    def test_worker_malformed_timestamp_falls_back_to_now(self) -> None:
        """Non-numeric worker timestamp should not crash."""
        event = {
            "type": "worker-online",
            "hostname": "worker@host",
            "timestamp": "bogus",
        }
        before = datetime.now(tz=UTC)
        result = normalize_worker_event(event)
        after = datetime.now(tz=UTC)
        assert result is not None
        ts = datetime.fromisoformat(result["timestamp"])
        assert before <= ts <= after
