"""Client-side validation tests for caliper resources.

Mirrors TypeScript's validation.unit.test.ts: proves that invalid inputs
raise InputValidationError *before* any transport call is made.

Uses a stub transport that throws on any request, so if validation is
bypassed the test fails with a clear error rather than silently passing.
"""

from __future__ import annotations

from typing import Any

import pytest

from timeback_caliper.lib.testing import create_recording_transport
from timeback_caliper.resources.events import EventsResource
from timeback_caliper.resources.jobs import JobsResource
from timeback_caliper.types.api import CaliperEnvelope
from timeback_common import InputValidationError


class _NeverCalledTransport:
    """Stub transport that explodes if any request method is called."""

    class _Paths:
        send = "/caliper/events"
        validate = "/caliper/events/validate"
        list = "/caliper/events"
        get = "/caliper/events/{id}"
        job_status = "/caliper/jobs/{id}/status"

    paths = _Paths()
    base_url = "https://stub.test"

    def __init__(self) -> None:
        self.request_count = 0

    async def get(self, _path: str, **_kw: Any) -> dict[str, Any]:
        self.request_count += 1
        msg = "Transport.get should not be called — validation should have rejected first"
        raise AssertionError(msg)

    async def post(self, _path: str, _data: Any) -> dict[str, Any]:
        self.request_count += 1
        msg = "Transport.post should not be called — validation should have rejected first"
        raise AssertionError(msg)


class _CaptureEnvelopeTransformer:
    """Capture transformed envelopes for payload normalization assertions."""

    def __init__(self) -> None:
        self.last_envelope: dict[str, Any] | None = None

    def transform(self, envelope: dict[str, Any]) -> dict[str, Any]:
        self.last_envelope = envelope
        return envelope


class TestEventsValidation:
    """Validation fires before transport for events methods."""

    @pytest.mark.asyncio
    async def test_send_rejects_empty_sensor(self) -> None:
        transport = _NeverCalledTransport()
        events = EventsResource(transport)

        with pytest.raises(InputValidationError):
            await events.send("", [])

        assert transport.request_count == 0

    @pytest.mark.asyncio
    async def test_send_envelope_rejects_empty_sensor(self) -> None:
        transport = _NeverCalledTransport()
        events = EventsResource(transport)

        envelope = CaliperEnvelope(
            sensor="",
            sendTime="2024-01-15T10:00:00Z",
            dataVersion="http://purl.imsglobal.org/ctx/caliper/v1p2",
            data=[{"type": "Event"}],
        )

        with pytest.raises(InputValidationError):
            await events.send_envelope(envelope)

        assert transport.request_count == 0

    @pytest.mark.asyncio
    async def test_list_rejects_zero_limit(self) -> None:
        transport = _NeverCalledTransport()
        events = EventsResource(transport)

        with pytest.raises(InputValidationError):
            await events.list(limit=0)

        assert transport.request_count == 0

    @pytest.mark.asyncio
    async def test_list_rejects_negative_offset(self) -> None:
        transport = _NeverCalledTransport()
        events = EventsResource(transport)

        with pytest.raises(InputValidationError):
            await events.list(offset=-1)

        assert transport.request_count == 0

    @pytest.mark.asyncio
    async def test_list_rejects_whitespace_only_sensor(self) -> None:
        transport = _NeverCalledTransport()
        events = EventsResource(transport)

        with pytest.raises(InputValidationError):
            await events.list(sensor="   ")

        assert transport.request_count == 0

    def test_stream_rejects_zero_limit(self) -> None:
        transport = _NeverCalledTransport()
        events = EventsResource(transport)

        with pytest.raises(InputValidationError):
            events.stream(limit=0)

        assert transport.request_count == 0

    def test_stream_rejects_zero_max_items(self) -> None:
        transport = _NeverCalledTransport()
        events = EventsResource(transport)

        with pytest.raises(InputValidationError):
            events.stream(max_items=0)

        assert transport.request_count == 0

    @pytest.mark.asyncio
    async def test_get_rejects_empty_id(self) -> None:
        transport = _NeverCalledTransport()
        events = EventsResource(transport)

        with pytest.raises(InputValidationError):
            await events.get("")

        assert transport.request_count == 0

    @pytest.mark.asyncio
    async def test_send_trims_sensor_before_posting(self) -> None:
        transport = create_recording_transport()
        transformer = _CaptureEnvelopeTransformer()
        events = EventsResource(transport, event_transformer=transformer)

        await events.send("  https://example.edu/sensors/1  ", [{"type": "Event"}])

        assert transformer.last_envelope is not None
        assert transformer.last_envelope["sensor"] == "https://example.edu/sensors/1"

    @pytest.mark.asyncio
    async def test_list_trims_sensor_and_actor_id_before_request(self) -> None:
        transport = create_recording_transport()
        events = EventsResource(transport)

        await events.list(sensor="  sensor-1  ", actor_id="  actor-1  ")

        assert transport.calls
        params = transport.calls[-1]["params"]
        assert params["sensor"] == "sensor-1"
        assert params["actorId"] == "actor-1"


class TestJobsValidation:
    """Validation fires before transport for jobs methods."""

    @pytest.mark.asyncio
    async def test_get_status_rejects_empty_id(self) -> None:
        transport = _NeverCalledTransport()
        jobs = JobsResource(transport)

        with pytest.raises(InputValidationError):
            await jobs.get_status("")

        assert transport.request_count == 0

    @pytest.mark.asyncio
    async def test_wait_for_completion_rejects_negative_timeout(self) -> None:
        transport = _NeverCalledTransport()
        jobs = JobsResource(transport)

        with pytest.raises(InputValidationError):
            await jobs.wait_for_completion("job-1", timeout=-1)

        assert transport.request_count == 0

    @pytest.mark.asyncio
    async def test_wait_for_completion_rejects_zero_poll_interval(self) -> None:
        transport = _NeverCalledTransport()
        jobs = JobsResource(transport)

        with pytest.raises(InputValidationError):
            await jobs.wait_for_completion("job-1", poll_interval=0)

        assert transport.request_count == 0
