"""
Tests for the Persistent Output Buffer Service.

Tests the Redis + Supabase backed output buffering for job output streaming.
"""

import json
import time
from unittest.mock import Mock, patch

import pytest

from verlex_server.gateway.backend.output_buffer import (
    BufferStatus,
    OutputEntry,
    PersistentOutputBuffer,
    StreamType,
    configure_output_buffer,
    get_output_buffer,
)


class MockRedisClient:
    """Mock Redis client for testing."""

    def __init__(self):
        self._data = {}
        self._lists = {}
        self._hashes = {}
        self._expiry = {}
        self._pubsub_messages = []

    def rpush(self, key, *values):
        if key not in self._lists:
            self._lists[key] = []
        self._lists[key].extend(values)
        return len(self._lists[key])

    def lrange(self, key, start, end):
        if key not in self._lists:
            return []
        lst = self._lists[key]
        if end == -1:
            return lst[start:]
        return lst[start:end+1]

    def llen(self, key):
        return len(self._lists.get(key, []))

    def hset(self, key, mapping=None, **kwargs):
        if key not in self._hashes:
            self._hashes[key] = {}
        if mapping:
            self._hashes[key].update(mapping)
        self._hashes[key].update(kwargs)
        return len(mapping or kwargs)

    def hgetall(self, key):
        return self._hashes.get(key, {})

    def expire(self, key, ttl):
        self._expiry[key] = time.time() + ttl
        return True

    def delete(self, *keys):
        count = 0
        for key in keys:
            if key in self._lists:
                del self._lists[key]
                count += 1
            if key in self._hashes:
                del self._hashes[key]
                count += 1
            if key in self._data:
                del self._data[key]
                count += 1
        return count

    def scan(self, cursor, pattern):
        # Simple implementation - return all matching keys
        keys = []
        for key in self._lists.keys():
            if pattern.replace("*", "") in key:
                keys.append(key)
        return 0, keys

    def publish(self, channel, message):
        self._pubsub_messages.append((channel, message))
        return 1

    def pubsub(self):
        return MockPubSub()

    def pipeline(self):
        return MockPipeline(self)


class MockPipeline:
    """Mock Redis pipeline."""

    def __init__(self, redis):
        self._redis = redis
        self._commands = []

    def rpush(self, key, *values):
        self._commands.append(('rpush', key, values))
        return self

    def expire(self, key, ttl):
        self._commands.append(('expire', key, ttl))
        return self

    def hset(self, key, mapping=None, **kwargs):
        self._commands.append(('hset', key, mapping or kwargs))
        return self

    def execute(self):
        results = []
        for cmd in self._commands:
            if cmd[0] == 'rpush':
                results.append(self._redis.rpush(cmd[1], *cmd[2]))
            elif cmd[0] == 'expire':
                results.append(self._redis.expire(cmd[1], cmd[2]))
            elif cmd[0] == 'hset':
                results.append(self._redis.hset(cmd[1], mapping=cmd[2]))
        return results


class MockPubSub:
    """Mock Redis PubSub."""

    def __init__(self):
        self._subscribed = []
        self._messages = []

    def subscribe(self, *channels):
        self._subscribed.extend(channels)

    def unsubscribe(self, *channels):
        for ch in channels:
            if ch in self._subscribed:
                self._subscribed.remove(ch)

    def listen(self):
        # Return a single message then stop
        yield {"type": "message", "data": json.dumps({"type": "complete"})}

    def close(self):
        pass


class MockSupabaseClient:
    """Mock Supabase client for testing."""

    def __init__(self):
        self._tables = {}

    @property
    def admin_client(self):
        return self

    def table(self, name):
        if name not in self._tables:
            self._tables[name] = MockTable()
        return self._tables[name]


class MockTable:
    """Mock Supabase table."""

    def __init__(self):
        self._data = []

    def insert(self, data):
        self._data.append(data)
        return MockResponse([data])

    def upsert(self, data, on_conflict=None):
        if isinstance(data, list):
            self._data.extend(data)
        else:
            self._data.append(data)
        return MockResponse(data if isinstance(data, list) else [data])

    def select(self, *fields):
        return MockQuery(self._data)

    def update(self, data):
        return MockQuery(self._data, update_data=data)


class MockQuery:
    """Mock Supabase query."""

    def __init__(self, data, update_data=None):
        self._data = data
        self._update_data = update_data
        self._filters = []

    def eq(self, field, value):
        self._filters.append((field, value))
        return self

    def gte(self, field, value):
        return self

    def lte(self, field, value):
        return self

    def order(self, field, desc=False):
        return self

    def execute(self):
        if self._update_data:
            # Apply update
            for item in self._data:
                match = all(
                    item.get(f) == v for f, v in self._filters
                )
                if match:
                    item.update(self._update_data)
            return MockResponse(self._data)

        # Filter data
        result = []
        for item in self._data:
            match = all(
                item.get(f) == v for f, v in self._filters
            )
            if match or not self._filters:
                result.append(item)
        return MockResponse(result)


class MockResponse:
    """Mock Supabase response."""

    def __init__(self, data):
        self.data = data

    def execute(self):
        return self


class TestOutputEntry:
    """Tests for OutputEntry dataclass."""

    def test_output_entry_creation(self):
        """Test creating an output entry."""
        entry = OutputEntry(
            index=0,
            content="Hello, World!",
            stream=StreamType.STDOUT,
        )

        assert entry.index == 0
        assert entry.content == "Hello, World!"
        assert entry.stream == StreamType.STDOUT
        assert entry.timestamp > 0

    def test_output_entry_serialization(self):
        """Test output entry to/from dict."""
        entry = OutputEntry(
            index=5,
            content="Error message",
            stream=StreamType.STDERR,
            timestamp=1234567890.0,
        )

        data = entry.to_dict()

        assert data["index"] == 5
        assert data["content"] == "Error message"
        assert data["stream"] == "stderr"
        assert data["timestamp"] == 1234567890.0

        # Reconstruct
        restored = OutputEntry.from_dict(data)

        assert restored.index == entry.index
        assert restored.content == entry.content
        assert restored.stream == entry.stream


class TestPersistentOutputBuffer:
    """Tests for PersistentOutputBuffer."""

    @pytest.fixture
    def mock_redis(self):
        """Create a mock Redis client."""
        redis = Mock()
        redis.client = MockRedisClient()
        return redis

    @pytest.fixture
    def mock_supabase(self):
        """Create a mock Supabase client."""
        return MockSupabaseClient()

    @pytest.fixture
    def buffer(self, mock_redis, mock_supabase):
        """Create a buffer with mocks."""
        return PersistentOutputBuffer(
            redis_client=mock_redis,
            supabase_client=mock_supabase,
            persist_to_supabase=True,
        )

    @pytest.mark.asyncio
    async def test_start_job(self, buffer):
        """Test starting a job buffer."""
        status = await buffer.start_job("job-123", "user-456")

        assert status == BufferStatus.SUCCESS

        # Check metadata was set
        meta_key = f"{buffer.PREFIX_META}job-123"
        meta = buffer.redis.client.hgetall(meta_key)

        assert meta["job_id"] == "job-123"
        assert meta["user_id"] == "user-456"
        assert meta["is_complete"] == "false"

    @pytest.mark.asyncio
    async def test_append_output(self, buffer):
        """Test appending output to buffer."""
        job_id = "job-append"
        user_id = "user-123"

        await buffer.start_job(job_id, user_id)

        # Append stdout
        index, status = await buffer.append(
            job_id=job_id,
            user_id=user_id,
            content="Hello, World!",
            stream=StreamType.STDOUT,
        )

        assert status == BufferStatus.SUCCESS
        assert index == 0

        # Append stderr
        index, status = await buffer.append(
            job_id=job_id,
            user_id=user_id,
            content="Error occurred",
            stream=StreamType.STDERR,
        )

        assert status == BufferStatus.SUCCESS
        assert index == 1

    @pytest.mark.asyncio
    async def test_get_entries(self, buffer):
        """Test getting entries from buffer."""
        job_id = "job-get"
        user_id = "user-123"

        await buffer.start_job(job_id, user_id)

        # Append multiple entries
        for i in range(5):
            await buffer.append(
                job_id=job_id,
                user_id=user_id,
                content=f"Line {i}",
            )

        # Get all entries
        entries = await buffer.get_entries(job_id)

        assert len(entries) == 5
        assert entries[0].content == "Line 0"
        assert entries[4].content == "Line 4"

        # Get subset
        subset = await buffer.get_entries(job_id, start_index=2, end_index=3)

        assert len(subset) == 2
        assert subset[0].content == "Line 2"

    @pytest.mark.asyncio
    async def test_get_entry_count(self, buffer):
        """Test getting entry count."""
        job_id = "job-count"
        user_id = "user-123"

        await buffer.start_job(job_id, user_id)

        # Initially empty
        count = await buffer.get_entry_count(job_id)
        assert count == 0

        # Add entries
        for i in range(10):
            await buffer.append(job_id, user_id, f"Line {i}")

        count = await buffer.get_entry_count(job_id)
        assert count == 10

    @pytest.mark.asyncio
    async def test_complete_job(self, buffer):
        """Test completing a job."""
        job_id = "job-complete"
        user_id = "user-123"

        await buffer.start_job(job_id, user_id)
        await buffer.append(job_id, user_id, "Output")

        status = await buffer.complete_job(job_id, has_error=False)

        assert status == BufferStatus.SUCCESS

        # Check metadata updated
        meta_key = f"{buffer.PREFIX_META}{job_id}"
        meta = buffer.redis.client.hgetall(meta_key)

        assert meta["is_complete"] == "true"
        assert meta["has_error"] == "false"

    @pytest.mark.asyncio
    async def test_complete_job_with_error(self, buffer):
        """Test completing a job with error."""
        job_id = "job-error"
        user_id = "user-123"

        await buffer.start_job(job_id, user_id)

        status = await buffer.complete_job(job_id, has_error=True)

        assert status == BufferStatus.SUCCESS

        meta_key = f"{buffer.PREFIX_META}{job_id}"
        meta = buffer.redis.client.hgetall(meta_key)

        assert meta["has_error"] == "true"

    @pytest.mark.asyncio
    async def test_buffer_full(self, buffer):
        """Test buffer full condition."""
        job_id = "job-full"
        user_id = "user-123"

        # Set a very low max
        buffer.MAX_BUFFER_ENTRIES = 3

        await buffer.start_job(job_id, user_id)

        # Fill the buffer
        for i in range(3):
            await buffer.append(job_id, user_id, f"Line {i}")

        # Try to append beyond limit
        index, status = await buffer.append(job_id, user_id, "Overflow")

        assert status == BufferStatus.BUFFER_FULL

    @pytest.mark.asyncio
    async def test_pubsub_publish(self, buffer):
        """Test that output is published via pubsub."""
        job_id = "job-pubsub"
        user_id = "user-123"

        await buffer.start_job(job_id, user_id)
        await buffer.append(job_id, user_id, "Test output")

        # Check message was published
        messages = buffer.redis.client._pubsub_messages
        assert len(messages) > 0

        channel, message = messages[0]
        assert job_id in channel

        data = json.loads(message)
        assert data["content"] == "Test output"

    @pytest.mark.asyncio
    async def test_persistence_queuing(self, buffer):
        """Test that entries are queued for Supabase persistence."""
        job_id = "job-persist"
        user_id = "user-123"

        await buffer.start_job(job_id, user_id)

        # Append entry with persistence
        await buffer.append(
            job_id=job_id,
            user_id=user_id,
            content="Persistent output",
            persist=True,
        )

        # Check entry is queued
        assert job_id in buffer._pending_writes
        assert len(buffer._pending_writes[job_id]) == 1

    @pytest.mark.asyncio
    async def test_cleanup(self, buffer):
        """Test cleanup of old buffers."""
        job_id = "job-old"
        user_id = "user-123"

        await buffer.start_job(job_id, user_id)
        await buffer.append(job_id, user_id, "Old output")

        # Mark complete with old timestamp
        meta_key = f"{buffer.PREFIX_META}{job_id}"
        buffer.redis.client.hset(meta_key, mapping={
            "is_complete": "true",
            "completed_at": str(time.time() - 100000),  # Old
        })

        cleaned = await buffer.cleanup_completed_jobs(max_age_hours=1)

        assert cleaned >= 1


class TestStreamType:
    """Tests for StreamType enum."""

    def test_stream_types(self):
        """Test stream type values."""
        assert StreamType.STDOUT.value == "stdout"
        assert StreamType.STDERR.value == "stderr"
        assert StreamType.STATUS.value == "status"
        assert StreamType.ERROR.value == "error"
        assert StreamType.SYSTEM.value == "system"


class TestGlobalBufferInstance:
    """Tests for global buffer instance management."""

    def test_get_output_buffer(self):
        """Test getting global buffer instance."""
        with patch('verlex_server.gateway.backend.output_buffer._output_buffer', None):
            with patch('verlex_server.gateway.backend.output_buffer.get_redis_client') as mock_redis:
                with patch('verlex_server.gateway.backend.output_buffer.get_supabase_client') as mock_supa:
                    mock_redis.return_value = Mock(client=MockRedisClient())
                    mock_supa.return_value = MockSupabaseClient()

                    buf1 = get_output_buffer()
                    buf2 = get_output_buffer()

                    # Should be same instance
                    assert buf1 is buf2

    def test_configure_output_buffer(self):
        """Test configuring global buffer instance."""
        with patch('verlex_server.gateway.backend.output_buffer._output_buffer', None):
            mock_redis = Mock(client=MockRedisClient())
            mock_supabase = MockSupabaseClient()

            buffer = configure_output_buffer(
                redis_client=mock_redis,
                supabase_client=mock_supabase,
                persist_to_supabase=False,
            )

            assert buffer._persist_to_supabase is False
