"""Tests for Recorder lifecycle, thread safety, and serialization.

Tests the core recording engine that captures all I/O during test runs.
"""

from __future__ import annotations

import json
import threading
from datetime import date, datetime
from unittest.mock import patch

import pytest

from autodebug_probe.recorder import (
    MAX_RECORD_SIZE_BYTES,
    Recorder,
    RecordingMode,
    _json_default,
    _truncate_record,
    fingerprint_http,
    fingerprint_redis,
    fingerprint_sql,
    get_recording_mode,
)


class TestJsonDefault:
    """Tests for _json_default() custom JSON serializer."""

    def test_datetime_serialized(self):
        dt = datetime(2025, 1, 15, 10, 30, 0)
        result = _json_default(dt)
        assert isinstance(result, str)
        assert "2025" in result

    def test_date_serialized(self):
        d = date(2025, 1, 15)
        result = _json_default(d)
        assert isinstance(result, str)
        assert "2025" in result

    def test_bytes_base64_encoded(self):
        data = b"hello world"
        result = _json_default(data)
        assert isinstance(result, str)

    def test_unknown_type_raises(self):
        with pytest.raises(TypeError):
            _json_default(object())

    def test_full_json_serialization(self):
        """Verify _json_default works as json.dumps default."""
        record = {
            "timestamp": datetime(2025, 1, 15, 10, 0, 0),
            "data": b"binary",
            "count": 42,
        }
        result = json.dumps(record, default=_json_default)
        parsed = json.loads(result)
        assert parsed["count"] == 42
        assert isinstance(parsed["timestamp"], str)


class TestTruncateRecord:
    """Tests for _truncate_record() size enforcement."""

    def test_small_record_unchanged(self):
        record = {"key": "value", "num": 42}
        result, raw = _truncate_record(record)
        assert result == record
        assert isinstance(raw, bytes)

    def test_large_truncatable_field_gets_marker(self):
        """Large truncatable fields should get _truncated marker."""
        record = {"response_body": "x" * 20000}
        result, raw = _truncate_record(record)
        assert result is not None
        assert isinstance(raw, bytes)

    def test_huge_truncatable_field_gets_truncated(self):
        """Fields over MAX_RECORD_SIZE_BYTES with truncatable fields are marked."""
        large_body = "x" * (MAX_RECORD_SIZE_BYTES + 10000)
        record = {"response_body": large_body}
        result, raw = _truncate_record(record)
        assert result.get("_truncated") is True
        assert len(result["response_body"]) <= 10100
        assert isinstance(raw, bytes)


class TestFingerprinting:
    """Tests for record fingerprinting (used in deduplication)."""

    def test_http_fingerprint_strips_query(self):
        r1 = {"method": "GET", "url": "http://api.test/users?page=1"}
        r2 = {"method": "GET", "url": "http://api.test/users?page=2"}
        assert fingerprint_http(r1) == fingerprint_http(r2)

    def test_http_fingerprint_different_methods(self):
        r1 = {"method": "GET", "url": "http://api.test/users"}
        r2 = {"method": "POST", "url": "http://api.test/users"}
        assert fingerprint_http(r1) != fingerprint_http(r2)

    def test_sql_fingerprint_normalizes_values(self):
        r1 = {"query": "SELECT * FROM users WHERE id = 1"}
        r2 = {"query": "SELECT * FROM users WHERE id = 999"}
        assert fingerprint_sql(r1) == fingerprint_sql(r2)

    def test_redis_fingerprint_by_command_and_key(self):
        r1 = {"command": "GET", "key": "user:123"}
        r2 = {"command": "GET", "key": "user:123"}
        assert fingerprint_redis(r1) == fingerprint_redis(r2)

    def test_redis_fingerprint_different_commands(self):
        r1 = {"command": "GET", "key": "user:123"}
        r2 = {"command": "SET", "key": "user:123"}
        assert fingerprint_redis(r1) != fingerprint_redis(r2)

    def test_fingerprints_are_16_hex(self):
        r = {"method": "GET", "url": "http://test.com/api"}
        fp = fingerprint_http(r)
        assert len(fp) == 16
        assert all(c in "0123456789abcdef" for c in fp)


class TestRecordingMode:
    """Tests for RecordingMode enum and parsing."""

    def test_all_mode(self):
        with patch.dict("os.environ", {"AUTODEBUG_RECORD_MODE": "all"}):
            assert get_recording_mode() == RecordingMode.ALL

    def test_none_mode(self):
        with patch.dict("os.environ", {"AUTODEBUG_RECORD_MODE": "none"}):
            assert get_recording_mode() == RecordingMode.NONE

    def test_once_mode(self):
        with patch.dict("os.environ", {"AUTODEBUG_RECORD_MODE": "once"}):
            assert get_recording_mode() == RecordingMode.ONCE

    def test_default_mode(self):
        with patch.dict("os.environ", {}, clear=True):
            mode = get_recording_mode()
            assert mode == RecordingMode.ALL


class TestRecorderLifecycle:
    """Tests for Recorder creation, recording, and closing."""

    def test_creates_output_directory(self, tmp_path):
        run_dir = tmp_path / "run_001"
        run_dir.mkdir()  # Recorder expects dir to exist
        recorder = Recorder(run_dir, mode=RecordingMode.ALL)
        assert run_dir.exists()
        recorder.close()

    def test_records_event(self, tmp_path):
        run_dir = tmp_path / "run_002"
        run_dir.mkdir()
        recorder = Recorder(run_dir, mode=RecordingMode.ALL)
        recorder.record_event({"type": "EXCEPTION", "message": "test error"})
        recorder.close()

        # Verify events file exists
        events_files = list(run_dir.glob("events*"))
        assert len(events_files) > 0

    def test_records_http(self, tmp_path):
        run_dir = tmp_path / "run_003"
        run_dir.mkdir()
        recorder = Recorder(run_dir, mode=RecordingMode.ALL)
        recorder.record_http({
            "method": "GET",
            "url": "http://api.test/users",
            "status": 200,
            "response_body": '{"users": []}',
        })
        recorder.close()

        http_files = list(run_dir.glob("deps_http*"))
        assert len(http_files) > 0

    def test_records_sql(self, tmp_path):
        run_dir = tmp_path / "run_004"
        run_dir.mkdir()
        recorder = Recorder(run_dir, mode=RecordingMode.ALL)
        recorder.record_sql({
            "query": "SELECT * FROM users",
            "rows_returned": 5,
        })
        recorder.close()

        sql_files = list(run_dir.glob("deps_sql*"))
        assert len(sql_files) > 0

    def test_close_is_idempotent(self, tmp_path):
        run_dir = tmp_path / "run_005"
        run_dir.mkdir()
        recorder = Recorder(run_dir, mode=RecordingMode.ALL)
        recorder.record_event({"type": "START"})
        recorder.close()
        recorder.close()  # Should not raise

    def test_none_mode_skips_recording(self, tmp_path):
        run_dir = tmp_path / "run_006"
        run_dir.mkdir()
        recorder = Recorder(run_dir, mode=RecordingMode.NONE)
        recorder.record_event({"type": "EXCEPTION"})
        recorder.record_http({"method": "GET", "url": "/"})
        recorder.close()


class TestRecorderThreadSafety:
    """Tests that concurrent writes to the recorder don't corrupt data."""

    def test_concurrent_writes(self, tmp_path):
        """10 threads writing simultaneously should not raise."""
        run_dir = tmp_path / "run_concurrent"
        run_dir.mkdir()
        recorder = Recorder(run_dir, mode=RecordingMode.ALL)
        errors = []

        def write_records(thread_id):
            try:
                for i in range(20):
                    recorder.record_event({
                        "type": "TEST",
                        "thread": thread_id,
                        "seq": i,
                    })
            except Exception as e:
                errors.append(e)

        threads = [
            threading.Thread(target=write_records, args=(t,))
            for t in range(10)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        recorder.close()
        assert errors == [], f"Thread safety violation: {errors}"

    def test_sequence_numbers_unique(self, tmp_path):
        """Sequence numbers should be unique even with concurrent writes."""
        run_dir = tmp_path / "run_seq"
        run_dir.mkdir()
        recorder = Recorder(run_dir, mode=RecordingMode.ALL)
        seqs = []
        lock = threading.Lock()

        def collect_seqs(n):
            for _ in range(50):
                s = recorder.next_seq()
                with lock:
                    seqs.append(s)

        threads = [threading.Thread(target=collect_seqs, args=(i,)) for i in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        recorder.close()
        assert len(seqs) == len(set(seqs)), "Duplicate sequence numbers generated"
