"""Unit tests for telemetry module.

Tests cover:
- TelemetryManager initialization
- Payload sanitization logic (with real behavior verification)
- Early return conditions (disabled, empty config, no server)
- URL construction (with actual URL verification)
- HTTP request success/failure scenarios
- Source code bug: send_tasks should be instance variable
"""

from __future__ import annotations

import pytest
import httpx
from unittest.mock import AsyncMock, Mock, patch
from importlib import import_module


def get_telemetry_module():
    """Lazy import to avoid circular import issues."""
    return import_module('langbot.pkg.telemetry.telemetry')


class TestTelemetryManagerInit:
    """Tests for TelemetryManager initialization."""

    def test_init_stores_app_reference(self):
        """Test that __init__ stores the Application reference."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        manager = telemetry.TelemetryManager(mock_app)
        assert manager.ap is mock_app

    def test_init_empty_telemetry_config(self):
        """Test that telemetry_config starts empty."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        manager = telemetry.TelemetryManager(mock_app)
        assert manager.telemetry_config == {}


class TestTelemetryManagerInitialize:
    """Tests for initialize() method."""

    @pytest.mark.asyncio
    async def test_initialize_loads_space_config(self):
        """Test that initialize() loads space config from instance_config."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.instance_config = Mock()
        mock_app.instance_config.data = {'space': {'url': 'https://example.com'}}

        manager = telemetry.TelemetryManager(mock_app)
        await manager.initialize()

        assert manager.telemetry_config == {'url': 'https://example.com'}

    @pytest.mark.asyncio
    async def test_initialize_handles_empty_space_config(self):
        """Test that initialize() handles missing space config."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.instance_config = Mock()
        mock_app.instance_config.data = {}

        manager = telemetry.TelemetryManager(mock_app)
        await manager.initialize()

        assert manager.telemetry_config == {}


class TestTelemetrySendEarlyReturn:
    """Tests for early return conditions in send() method."""

    @pytest.mark.asyncio
    async def test_send_returns_when_config_empty(self):
        """Test that send() returns early when telemetry_config is empty."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {}

        # Should return without making HTTP calls
        await manager.send({'query_id': 'test'})

        # No HTTP client should be created, no logs should be written
        mock_app.logger.debug.assert_not_called()
        mock_app.logger.warning.assert_not_called()

    @pytest.mark.asyncio
    async def test_send_returns_when_telemetry_disabled(self):
        """Test that send() returns early when disable_telemetry is True."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'disable_telemetry': True, 'url': 'https://example.com'}

        await manager.send({'query_id': 'test'})

        mock_app.logger.debug.assert_not_called()

    @pytest.mark.asyncio
    async def test_send_returns_when_server_empty(self):
        """Test that send() returns early when server URL is empty."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': ''}

        await manager.send({'query_id': 'test'})

        mock_app.logger.debug.assert_not_called()


class TestPayloadSanitization:
    """Tests for payload sanitization logic in send() method.

    IMPORTANT: These tests verify actual behavior, not source code strings.
    """

    @pytest.mark.asyncio
    async def test_sanitize_null_query_id(self):
        """Test that null query_id is converted to empty string."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        captured_payloads = []

        async def mock_post(url, json):
            captured_payloads.append(json)
            return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))

        mock_client = Mock()
        mock_client.post = mock_post
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send({'query_id': None})

        assert len(captured_payloads) == 1
        assert captured_payloads[0]['query_id'] == ''

    @pytest.mark.asyncio
    async def test_sanitize_query_id_string_value(self):
        """Test that query_id string value is preserved."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        captured_payloads = []

        async def mock_post(url, json):
            captured_payloads.append(json)
            return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))

        mock_client = Mock()
        mock_client.post = mock_post
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send({'query_id': 'abc123'})

        assert len(captured_payloads) == 1
        assert captured_payloads[0]['query_id'] == 'abc123'

    @pytest.mark.asyncio
    async def test_sanitize_null_string_fields(self):
        """Test that null string fields are converted to empty strings."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        captured_payloads = []

        async def mock_post(url, json):
            captured_payloads.append(json)
            return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))

        mock_client = Mock()
        mock_client.post = mock_post
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        payload = {
            'query_id': 'test',
            'adapter': None,
            'runner': None,
            'runner_category': None,
            'model_name': None,
            'version': None,
            'edition': None,
            'error': None,
            'timestamp': None,
        }

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send(payload)

        assert len(captured_payloads) == 1
        result = captured_payloads[0]

        # All null string fields should be empty strings
        for field in ['adapter', 'runner', 'runner_category', 'model_name', 'version', 'edition', 'error', 'timestamp']:
            assert result[field] == '', f'Field {field} should be empty string, got {result[field]}'

    @pytest.mark.asyncio
    async def test_sanitize_string_fields_preserve_values(self):
        """Test that non-null string fields preserve their values."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        captured_payloads = []

        async def mock_post(url, json):
            captured_payloads.append(json)
            return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))

        mock_client = Mock()
        mock_client.post = mock_post
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        payload = {
            'query_id': 'test',
            'adapter': 'gewechat',
            'runner': 'local-agent',
            'model_name': 'gpt-4',
            'version': 'v1.0.0',
        }

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send(payload)

        assert len(captured_payloads) == 1
        result = captured_payloads[0]

        assert result['adapter'] == 'gewechat'
        assert result['runner'] == 'local-agent'
        assert result['model_name'] == 'gpt-4'
        assert result['version'] == 'v1.0.0'

    @pytest.mark.asyncio
    async def test_sanitize_duration_ms_invalid_value(self):
        """Test that invalid duration_ms is converted to 0."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        captured_payloads = []

        async def mock_post(url, json):
            captured_payloads.append(json)
            return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))

        mock_client = Mock()
        mock_client.post = mock_post
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send({'query_id': 'test', 'duration_ms': 'invalid'})

        assert len(captured_payloads) == 1
        assert captured_payloads[0]['duration_ms'] == 0

    @pytest.mark.asyncio
    async def test_sanitize_duration_ms_none_value(self):
        """Test that None duration_ms is converted to 0."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        captured_payloads = []

        async def mock_post(url, json):
            captured_payloads.append(json)
            return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))

        mock_client = Mock()
        mock_client.post = mock_post
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send({'query_id': 'test', 'duration_ms': None})

        assert len(captured_payloads) == 1
        assert captured_payloads[0]['duration_ms'] == 0

    @pytest.mark.asyncio
    async def test_sanitize_duration_ms_valid_value(self):
        """Test that valid duration_ms is converted to int."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        captured_payloads = []

        async def mock_post(url, json):
            captured_payloads.append(json)
            return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))

        mock_client = Mock()
        mock_client.post = mock_post
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send({'query_id': 'test', 'duration_ms': 123.45})

        assert len(captured_payloads) == 1
        assert captured_payloads[0]['duration_ms'] == 123


class TestURLConstruction:
    """Tests for URL construction in send() method.

    IMPORTANT: These tests verify actual URLs sent, not source code strings.
    """

    @pytest.mark.asyncio
    async def test_url_strip_trailing_slash(self):
        """Test that trailing slash is stripped from server URL."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com/'}

        captured_urls = []

        async def mock_post(url, json):
            captured_urls.append(url)
            return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))

        mock_client = Mock()
        mock_client.post = mock_post
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send({'query_id': 'test'})

        assert len(captured_urls) == 1
        assert captured_urls[0] == 'https://example.com/api/v1/telemetry'
        # No trailing slash before /api/v1/telemetry

    @pytest.mark.asyncio
    async def test_url_without_trailing_slash(self):
        """Test that URL without trailing slash works correctly."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        captured_urls = []

        async def mock_post(url, json):
            captured_urls.append(url)
            return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))

        mock_client = Mock()
        mock_client.post = mock_post
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send({'query_id': 'test'})

        assert len(captured_urls) == 1
        assert captured_urls[0] == 'https://example.com/api/v1/telemetry'


class TestHTTPScenarios:
    """Tests for HTTP request success/failure scenarios."""

    @pytest.mark.asyncio
    async def test_send_http_success_logs_debug(self):
        """Test that HTTP 200 with code=0 logs debug message."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        mock_response = Mock(
            status_code=200, text='{"code": 0, "msg": "success"}', json=Mock(return_value={'code': 0, 'msg': 'success'})
        )

        mock_client = Mock()
        mock_client.post = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send({'query_id': 'test'})

        mock_app.logger.debug.assert_called()
        # Verify debug message contains URL and status
        debug_call_args = mock_app.logger.debug.call_args[0][0]
        assert 'Telemetry posted' in debug_call_args
        assert 'https://example.com/api/v1/telemetry' in debug_call_args

    @pytest.mark.asyncio
    async def test_send_http_error_status_logs_warning(self):
        """Test that HTTP status >= 400 logs warning."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        mock_response = Mock(
            status_code=500, text='Internal Server Error', json=Mock(return_value={'code': 500, 'msg': 'error'})
        )

        mock_client = Mock()
        mock_client.post = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send({'query_id': 'test'})

        mock_app.logger.warning.assert_called()
        warning_call_args = mock_app.logger.warning.call_args[0][0]
        assert 'status 500' in warning_call_args

    @pytest.mark.asyncio
    async def test_send_application_error_logs_warning(self):
        """Test that HTTP 200 with application code >= 400 logs warning."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        mock_response = Mock(
            status_code=200,
            text='{"code": 400, "msg": "Bad Request"}',
            json=Mock(return_value={'code': 400, 'msg': 'Bad Request'}),
        )

        mock_client = Mock()
        mock_client.post = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send({'query_id': 'test'})

        # Source code calls warning twice for application errors
        assert mock_app.logger.warning.call_count >= 1
        # Check that one of the calls contains application error info
        all_warnings = [call[0][0] for call in mock_app.logger.warning.call_args_list]
        assert any('400' in w for w in all_warnings), f'No warning contained error code 400: {all_warnings}'

    @pytest.mark.asyncio
    async def test_send_timeout_logs_warning(self):
        """Test that asyncio.TimeoutError logs warning."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        import asyncio

        async def mock_post_timeout(url, json):
            raise asyncio.TimeoutError()

        mock_client = Mock()
        mock_client.post = mock_post_timeout
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            await manager.send({'query_id': 'test'})

        mock_app.logger.warning.assert_called()
        warning_call_args = mock_app.logger.warning.call_args[0][0]
        assert 'timed out' in warning_call_args

    @pytest.mark.asyncio
    async def test_send_network_error_logs_warning(self):
        """Test that network exceptions log warning without raising."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        async def mock_post_error(url, json):
            raise httpx.ConnectError('Connection failed')

        mock_client = Mock()
        mock_client.post = mock_post_error
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            # Should not raise exception
            await manager.send({'query_id': 'test'})

        mock_app.logger.warning.assert_called()

    @pytest.mark.asyncio
    async def test_send_never_raises_exception(self):
        """Test that send() never raises exceptions regardless of errors."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        # Even logger may fail
        mock_app.logger = Mock()
        mock_app.logger.warning = Mock(side_effect=Exception('Logger failed'))

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {'url': 'https://example.com'}

        async def mock_post_error(url, json):
            raise Exception('Unexpected error')

        mock_client = Mock()
        mock_client.post = mock_post_error
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch.object(httpx, 'AsyncClient', return_value=mock_client):
            # Should never raise
            await manager.send({'query_id': 'test'})


class TestStartSendTask:
    """Tests for start_send_task() method."""

    @pytest.mark.asyncio
    async def test_start_send_task_creates_task(self):
        """Test that start_send_task creates an asyncio task."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()
        mock_app.instance_config = Mock()
        mock_app.instance_config.data = {}

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {}

        await manager.start_send_task({'query_id': 'test'})

        # Task should be added to send_tasks list
        assert len(manager.send_tasks) >= 1

        # Clean up the task
        for task in manager.send_tasks:
            if not task.done():
                task.cancel()
        manager.send_tasks.clear()

    @pytest.mark.asyncio
    async def test_start_send_task_multiple_tasks(self):
        """Test that multiple tasks are tracked."""
        telemetry = get_telemetry_module()
        mock_app = Mock()
        mock_app.logger = Mock()
        mock_app.instance_config = Mock()
        mock_app.instance_config.data = {}

        manager = telemetry.TelemetryManager(mock_app)
        manager.telemetry_config = {}

        await manager.start_send_task({'query_id': 'test1'})
        await manager.start_send_task({'query_id': 'test2'})
        await manager.start_send_task({'query_id': 'test3'})

        assert len(manager.send_tasks) >= 3

        # Clean up
        for task in manager.send_tasks:
            if not task.done():
                task.cancel()
        manager.send_tasks.clear()
