"""Tests for streaming extractors (SSE, WebSocket, File Watcher)."""

from __future__ import annotations

import asyncio
import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from pycharter.etl_generator.extractors._streaming import (
    BatchAccumulator,
    StreamingConfig,
)

# =============================================================================
# Streaming Utils
# =============================================================================


class TestStreamingConfig:
    """Tests for StreamingConfig defaults."""

    def test_defaults(self):
        """Default values are sensible."""
        cfg = StreamingConfig()
        assert cfg.shutdown_event is None
        assert cfg.max_batches is None
        assert cfg.max_records is None
        assert cfg.idle_timeout is None
        assert cfg.batch_timeout == 5.0
        assert cfg.batch_size == 1000

    def test_custom_values(self):
        """Custom values are stored."""
        event = asyncio.Event()
        cfg = StreamingConfig(
            shutdown_event=event,
            max_batches=10,
            max_records=5000,
            batch_size=500,
        )
        assert cfg.shutdown_event is event
        assert cfg.max_batches == 10
        assert cfg.max_records == 5000
        assert cfg.batch_size == 500


class TestBatchAccumulator:
    """Tests for BatchAccumulator."""

    @pytest.mark.asyncio
    async def test_full_batch(self):
        """Returns batch when batch_size is reached."""
        acc = BatchAccumulator(batch_size=2, batch_timeout=5.0)

        result1 = await acc.add({"id": 1})
        assert result1 is None

        result2 = await acc.add({"id": 2})
        assert result2 is not None
        assert len(result2) == 2

    @pytest.mark.asyncio
    async def test_flush(self):
        """Flush returns partial batch."""
        acc = BatchAccumulator(batch_size=10, batch_timeout=5.0)
        await acc.add({"id": 1})
        await acc.add({"id": 2})

        result = await acc.flush()

        assert result is not None
        assert len(result) == 2

    @pytest.mark.asyncio
    async def test_flush_empty(self):
        """Flush returns None when empty."""
        acc = BatchAccumulator(batch_size=10, batch_timeout=5.0)
        result = await acc.flush()
        assert result is None

    @pytest.mark.asyncio
    async def test_timeout_detection(self):
        """Timeout detection works."""
        acc = BatchAccumulator(batch_size=10, batch_timeout=0.01)
        await acc.add({"id": 1})

        # Initially not timed out
        assert not acc.should_flush_timeout()

        # Wait for timeout
        await asyncio.sleep(0.02)
        assert acc.should_flush_timeout()


# =============================================================================
# SSE Extractor
# =============================================================================


class TestSSEExtractor:
    """Tests for SSEExtractor."""

    def test_from_config(self):
        """Create SSEExtractor from config dict."""
        from pycharter.etl_generator.extractors.sse import SSEExtractor

        config = {
            "url": "https://api.example.com/events",
            "headers": {"Authorization": "Bearer token"},
            "event_filter": "update",
            "data_format": "json",
            "batch_size": 500,
            "max_records": 1000,
        }
        extractor = SSEExtractor.from_config(config)

        assert extractor._url == "https://api.example.com/events"
        assert extractor._event_filter == "update"
        assert extractor._data_format == "json"

    def test_validate_config(self):
        """Validation requires url."""
        from pycharter.etl_generator.extractors.sse import SSEExtractor

        extractor = SSEExtractor(url="https://example.com")
        extractor.validate_config({"url": "https://example.com"})

        with pytest.raises(ValueError, match="requires 'url'"):
            extractor.validate_config({})

    def test_parse_json_data(self):
        """JSON data is parsed into a dict."""
        from pycharter.etl_generator.extractors.sse import SSEExtractor

        extractor = SSEExtractor(url="https://example.com")
        record = extractor._parse_data('{"id": 1}', "update", "evt-1")

        assert record["id"] == 1
        assert record["_event_type"] == "update"
        assert record["_event_id"] == "evt-1"

    def test_parse_text_data(self):
        """Text data is wrapped in a dict."""
        from pycharter.etl_generator.extractors.sse import SSEExtractor

        extractor = SSEExtractor(url="https://example.com", data_format="text")
        record = extractor._parse_data("hello world", "", None)

        assert record["data"] == "hello world"

    def test_parse_invalid_json(self):
        """Invalid JSON is captured with parse error flag."""
        from pycharter.etl_generator.extractors.sse import SSEExtractor

        extractor = SSEExtractor(url="https://example.com")
        record = extractor._parse_data("not json", "", None)

        assert record["data"] == "not json"
        assert record["_parse_error"] is True

    @pytest.mark.asyncio
    async def test_extract_with_max_records(self):
        """Extract stops after max_records."""
        from pycharter.etl_generator.extractors.sse import SSEExtractor

        extractor = SSEExtractor(
            url="https://example.com/events",
            max_records=3,
            batch_size=10,
            reconnect=False,
        )

        # Mock the SSE stream
        async def mock_stream_events(last_event_id):
            for i in range(10):
                yield {"id": i, "_event_type": "", "_event_id": str(i)}, str(i)

        with patch.object(extractor, "_stream_events", side_effect=mock_stream_events):
            batches = [batch async for batch in extractor.extract()]

        total = sum(len(b) for b in batches)
        assert total == 3

    @pytest.mark.asyncio
    async def test_extract_event_filter(self):
        """Event filter is applied."""
        from pycharter.etl_generator.extractors.sse import SSEExtractor

        extractor = SSEExtractor(
            url="https://example.com/events",
            event_filter="update",
            max_records=5,
            batch_size=10,
            reconnect=False,
        )

        async def mock_stream_events(last_event_id):
            for i in range(5):
                yield {"id": i, "_event_type": "update", "_event_id": str(i)}, str(i)

        with patch.object(extractor, "_stream_events", side_effect=mock_stream_events):
            batches = [batch async for batch in extractor.extract()]

        total = sum(len(b) for b in batches)
        assert total == 5

    @pytest.mark.asyncio
    async def test_shutdown_event(self):
        """Shutdown event stops extraction."""
        from pycharter.etl_generator.extractors.sse import SSEExtractor

        event = asyncio.Event()
        event.set()  # Already set — should stop immediately

        extractor = SSEExtractor(
            url="https://example.com/events",
            shutdown_event=event,
            reconnect=False,
        )

        batches = [batch async for batch in extractor.extract()]
        assert batches == []


# =============================================================================
# WebSocket Extractor
# =============================================================================


class TestWebSocketExtractor:
    """Tests for WebSocketExtractor."""

    def test_from_config(self):
        """Create WebSocketExtractor from config dict."""
        from pycharter.etl_generator.extractors.websocket import WebSocketExtractor

        config = {
            "url": "wss://stream.example.com",
            "subscribe_messages": [{"action": "subscribe", "channel": "trades"}],
            "data_format": "json",
            "batch_size": 100,
        }
        extractor = WebSocketExtractor.from_config(config)

        assert extractor._url == "wss://stream.example.com"
        assert len(extractor._subscribe_messages) == 1

    def test_validate_config(self):
        """Validation requires url."""
        from pycharter.etl_generator.extractors.websocket import WebSocketExtractor

        extractor = WebSocketExtractor(url="wss://example.com")
        extractor.validate_config({"url": "wss://example.com"})

        with pytest.raises(ValueError, match="requires 'url'"):
            extractor.validate_config({})

    def test_parse_json_message(self):
        """JSON messages are parsed."""
        from pycharter.etl_generator.extractors.websocket import WebSocketExtractor

        extractor = WebSocketExtractor(url="wss://example.com")
        record = extractor._parse_message('{"price": 100.5}')

        assert record["price"] == 100.5

    def test_parse_text_message(self):
        """Text messages are wrapped."""
        from pycharter.etl_generator.extractors.websocket import WebSocketExtractor

        extractor = WebSocketExtractor(url="wss://example.com", data_format="text")
        record = extractor._parse_message("hello")

        assert record["data"] == "hello"

    def test_parse_bytes_message(self):
        """Bytes messages are decoded."""
        from pycharter.etl_generator.extractors.websocket import WebSocketExtractor

        extractor = WebSocketExtractor(url="wss://example.com")
        record = extractor._parse_message(b'{"id": 1}')

        assert record["id"] == 1

    def test_parse_invalid_json(self):
        """Invalid JSON is captured with parse error."""
        from pycharter.etl_generator.extractors.websocket import WebSocketExtractor

        extractor = WebSocketExtractor(url="wss://example.com")
        record = extractor._parse_message("not json")

        assert record["_parse_error"] is True

    @pytest.mark.asyncio
    async def test_shutdown_event(self):
        """Shutdown event stops extraction."""
        from pycharter.etl_generator.extractors.websocket import WebSocketExtractor

        event = asyncio.Event()
        event.set()

        extractor = WebSocketExtractor(
            url="wss://example.com",
            shutdown_event=event,
            reconnect=False,
        )

        batches = [batch async for batch in extractor.extract()]
        assert batches == []

    @pytest.mark.asyncio
    async def test_missing_library_error(self):
        """Clear error message when websockets not installed."""
        from pycharter.etl_generator.extractors.websocket import WebSocketExtractor

        extractor = WebSocketExtractor(url="wss://example.com", reconnect=False)

        with patch.dict("sys.modules", {"websockets": None}):
            with pytest.raises(ImportError, match="websockets"):
                async for _ in extractor.extract():
                    pass


# =============================================================================
# File Watcher Extractor
# =============================================================================


class TestFileWatcherExtractor:
    """Tests for FileWatcherExtractor."""

    def test_from_config(self):
        """Create FileWatcherExtractor from config dict."""
        from pycharter.etl_generator.extractors.file_watcher import FileWatcherExtractor

        config = {
            "watch_dir": "/data/incoming",
            "patterns": ["*.json"],
            "recursive": True,
            "process_existing": True,
        }
        extractor = FileWatcherExtractor.from_config(config)

        assert extractor._watch_dir == Path("/data/incoming")
        assert extractor._patterns == ["*.json"]
        assert extractor._recursive is True

    def test_validate_config(self):
        """Validation requires watch_dir."""
        from pycharter.etl_generator.extractors.file_watcher import FileWatcherExtractor

        extractor = FileWatcherExtractor(watch_dir="/tmp")
        extractor.validate_config({"watch_dir": "/tmp"})

        with pytest.raises(ValueError, match="requires 'watch_dir'"):
            extractor.validate_config({})

    @pytest.mark.asyncio
    async def test_process_existing_json(self, tmp_path: Path):
        """Processes existing JSON files."""
        from pycharter.etl_generator.extractors.file_watcher import FileWatcherExtractor

        # Create test files
        data_file = tmp_path / "data.json"
        data_file.write_text(json.dumps([{"id": 1}, {"id": 2}]))

        shutdown = asyncio.Event()
        extractor = FileWatcherExtractor(
            watch_dir=tmp_path,
            patterns=["*.json"],
            process_existing=True,
            shutdown_event=shutdown,
            max_records=10,
        )

        # Process existing files, then stop
        shutdown.set()
        batches = [batch async for batch in extractor.extract()]

        total = sum(len(b) for b in batches)
        assert total == 2

    @pytest.mark.asyncio
    async def test_pattern_matching(self, tmp_path: Path):
        """Only matching patterns are processed."""
        from pycharter.etl_generator.extractors.file_watcher import FileWatcherExtractor

        (tmp_path / "data.json").write_text(json.dumps([{"id": 1}]))
        (tmp_path / "data.csv").write_text("id\n1\n")

        shutdown = asyncio.Event()
        shutdown.set()

        extractor = FileWatcherExtractor(
            watch_dir=tmp_path,
            patterns=["*.json"],
            process_existing=True,
            shutdown_event=shutdown,
            max_records=10,
        )

        batches = [batch async for batch in extractor.extract()]
        total = sum(len(b) for b in batches)
        assert total == 1  # Only JSON file

    @pytest.mark.asyncio
    async def test_no_double_processing(self, tmp_path: Path):
        """Files are only processed once."""
        from pycharter.etl_generator.extractors.file_watcher import FileWatcherExtractor

        (tmp_path / "data.json").write_text(json.dumps([{"id": 1}]))

        shutdown = asyncio.Event()

        extractor = FileWatcherExtractor(
            watch_dir=tmp_path,
            patterns=["*.json"],
            process_existing=True,
            shutdown_event=shutdown,
        )

        # First run
        shutdown.set()
        batches1 = [batch async for batch in extractor.extract()]

        # Reset and run again — should get nothing new
        shutdown = asyncio.Event()
        shutdown.set()
        extractor._streaming_config = StreamingConfig(shutdown_event=shutdown)
        batches2: list[list[dict[str, Any]]] = []
        # The file is in _processed_files so process_existing won't re-process
        for f in extractor._find_matching_files():
            assert f in extractor._processed_files

    @pytest.mark.asyncio
    async def test_move_after_process(self, tmp_path: Path):
        """Files are moved to archive directory after processing."""
        from pycharter.etl_generator.extractors.file_watcher import FileWatcherExtractor

        data_dir = tmp_path / "incoming"
        data_dir.mkdir()
        archive_dir = tmp_path / "archive"

        (data_dir / "data.json").write_text(json.dumps([{"id": 1}]))

        shutdown = asyncio.Event()
        shutdown.set()

        extractor = FileWatcherExtractor(
            watch_dir=data_dir,
            patterns=["*.json"],
            process_existing=True,
            move_after_process=archive_dir,
            shutdown_event=shutdown,
            max_records=10,
        )

        batches = [batch async for batch in extractor.extract()]

        assert not (data_dir / "data.json").exists()
        assert (archive_dir / "data.json").exists()

    @pytest.mark.asyncio
    async def test_csv_file(self, tmp_path: Path):
        """Processes CSV files."""
        from pycharter.etl_generator.extractors.file_watcher import FileWatcherExtractor

        (tmp_path / "data.csv").write_text("id,name\n1,Alice\n2,Bob\n")

        shutdown = asyncio.Event()
        shutdown.set()

        extractor = FileWatcherExtractor(
            watch_dir=tmp_path,
            patterns=["*.csv"],
            process_existing=True,
            shutdown_event=shutdown,
            max_records=10,
        )

        batches = [batch async for batch in extractor.extract()]
        total = sum(len(b) for b in batches)
        assert total == 2


# =============================================================================
# Factory Registration
# =============================================================================


class TestStreamingFactoryRegistration:
    """Tests for streaming extractor factory registration."""

    def test_sse_in_factory_list(self):
        """SSE type is available in factory."""
        from pycharter.etl_generator.extractors.factory import ExtractorFactory

        types = ExtractorFactory.list_types()
        assert "sse" in types

    def test_websocket_in_factory_list(self):
        """WebSocket type is available in factory."""
        from pycharter.etl_generator.extractors.factory import ExtractorFactory

        types = ExtractorFactory.list_types()
        assert "websocket" in types

    def test_file_watcher_in_factory_list(self):
        """File watcher type is available in factory."""
        from pycharter.etl_generator.extractors.factory import ExtractorFactory

        types = ExtractorFactory.list_types()
        assert "file_watcher" in types


# =============================================================================
# Config Models
# =============================================================================


class TestStreamingConfigModels:
    """Tests for streaming extract config models."""

    def test_sse_config(self):
        """SSEExtractConfig parses correctly."""
        from pycharter.etl_generator.config_models import SSEExtractConfig

        config = SSEExtractConfig(url="https://example.com/events")
        assert config.type == "sse"
        assert config.url == "https://example.com/events"
        assert config.batch_size == 1000

    def test_websocket_config(self):
        """WebSocketExtractConfig parses correctly."""
        from pycharter.etl_generator.config_models import WebSocketExtractConfig

        config = WebSocketExtractConfig(url="wss://example.com")
        assert config.type == "websocket"
        assert config.ping_interval == 20.0

    def test_file_watcher_config(self):
        """FileWatcherExtractConfig parses correctly."""
        from pycharter.etl_generator.config_models import FileWatcherExtractConfig

        config = FileWatcherExtractConfig(watch_dir="/data/incoming")
        assert config.type == "file_watcher"
        assert config.recursive is False

    def test_parse_extract_config_sse(self):
        """parse_extract_config handles SSE type."""
        from pycharter.etl_generator.config_models import (
            SSEExtractConfig,
            parse_extract_config,
        )

        config = parse_extract_config(
            {"type": "sse", "url": "https://example.com/events"}
        )
        assert isinstance(config, SSEExtractConfig)

    def test_parse_extract_config_websocket(self):
        """parse_extract_config handles WebSocket type."""
        from pycharter.etl_generator.config_models import (
            WebSocketExtractConfig,
            parse_extract_config,
        )

        config = parse_extract_config({"type": "websocket", "url": "wss://example.com"})
        assert isinstance(config, WebSocketExtractConfig)

    def test_parse_extract_config_file_watcher(self):
        """parse_extract_config handles file_watcher type."""
        from pycharter.etl_generator.config_models import (
            FileWatcherExtractConfig,
            parse_extract_config,
        )

        config = parse_extract_config(
            {"type": "file_watcher", "watch_dir": "/data/incoming"}
        )
        assert isinstance(config, FileWatcherExtractConfig)
