"""Tests for new features: cache backends, pipeline enhancements, CLI, etc."""

from __future__ import annotations

import contextlib
import json
import tempfile
import threading
import time
from unittest.mock import patch

import pytest

from pretok.detection import DetectionError, DetectionResult
from pretok.detection.base import BaseDetector
from pretok.pipeline.cache import MemoryCache
from pretok.pipeline.core import PipelineResult, PipelineStats, Pretok
from pretok.translation import TranslationError, TranslationResult
from pretok.translation.base import BaseTranslator

# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------


class MockDetector(BaseDetector):
    """Mock detector for testing."""

    @property
    def name(self) -> str:
        return "mock"

    def detect(self, text: str) -> DetectionResult:
        if any("\u4e00" <= c <= "\u9fff" for c in text):
            return DetectionResult(language="zh", confidence=0.95, detector="mock")
        return DetectionResult(language="en", confidence=0.95, detector="mock")


class LowConfidenceDetector(BaseDetector):
    """Detector that returns low confidence results."""

    @property
    def name(self) -> str:
        return "low_confidence"

    def detect(self, text: str) -> DetectionResult:
        return DetectionResult(language="zh", confidence=0.3, detector="low_confidence")


class FailingDetector(BaseDetector):
    """Detector that always fails."""

    @property
    def name(self) -> str:
        return "failing"

    def detect(self, text: str) -> DetectionResult:
        raise DetectionError("Detection failed", detector="failing")


class MockTranslator(BaseTranslator):
    """Mock translator for testing."""

    @property
    def name(self) -> str:
        return "mock"

    def translate(
        self,
        text: str,
        target_language: str,
        source_language: str | None = None,
    ) -> TranslationResult:
        translated = f"[translated:{target_language}]{text}"
        return TranslationResult(
            source_text=text,
            translated_text=translated,
            source_language=source_language or "auto",
            target_language=target_language,
            translator=self.name,
        )


class FailingTranslator(BaseTranslator):
    """Translator that always fails."""

    @property
    def name(self) -> str:
        return "failing"

    def translate(
        self,
        text: str,
        target_language: str,
        source_language: str | None = None,
    ) -> TranslationResult:
        raise TranslationError("Translation failed", translator="failing")


# ===========================================================================
# Tests for MemoryCache thread safety
# ===========================================================================


class TestMemoryCacheThreadSafety:
    """Tests for MemoryCache with threading."""

    def test_concurrent_set_get(self) -> None:
        """Test concurrent set and get operations."""
        cache = MemoryCache(max_size=1000)
        errors: list[Exception] = []

        def writer(start: int) -> None:
            try:
                for i in range(100):
                    cache.set(f"key_{start}_{i}", f"value_{start}_{i}")
            except Exception as e:
                errors.append(e)

        def reader(start: int) -> None:
            try:
                for i in range(100):
                    cache.get(f"key_{start}_{i}")
            except Exception as e:
                errors.append(e)

        threads = []
        for t_id in range(5):
            threads.append(threading.Thread(target=writer, args=(t_id,)))
            threads.append(threading.Thread(target=reader, args=(t_id,)))

        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0, f"Thread errors: {errors}"

    def test_concurrent_stats(self) -> None:
        """Test that stats don't crash under concurrency."""
        cache = MemoryCache(max_size=100)

        def worker() -> None:
            for i in range(50):
                cache.set(f"k{i}", f"v{i}")
                cache.get(f"k{i}")
                _ = cache.stats
                _ = cache.hit_rate

        threads = [threading.Thread(target=worker) for _ in range(4)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert cache.size() <= 100

    def test_ttl_expiration(self) -> None:
        """Test that TTL actually expires entries."""
        cache = MemoryCache(max_size=100, ttl=1)
        cache.set("key", "value")
        assert cache.get("key") == "value"

        time.sleep(1.1)
        assert cache.get("key") is None


# ===========================================================================
# Tests for SQLiteCache
# ===========================================================================


class TestSQLiteCache:
    """Tests for SQLiteCache."""

    def test_basic_operations(self) -> None:
        """Test basic get/set/delete."""
        from pretok.pipeline.sqlite_cache import SQLiteCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SQLiteCache(path=f"{tmpdir}/test.db", ttl=3600)
            try:
                cache.set("key", "value")
                assert cache.get("key") == "value"

                assert cache.delete("key") is True
                assert cache.get("key") is None
                assert cache.delete("key") is False
            finally:
                cache.close()

    def test_clear(self) -> None:
        """Test clearing cache."""
        from pretok.pipeline.sqlite_cache import SQLiteCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SQLiteCache(path=f"{tmpdir}/test.db")
            try:
                cache.set("a", "1")
                cache.set("b", "2")
                assert cache.size() == 2

                cache.clear()
                assert cache.size() == 0
            finally:
                cache.close()

    def test_ttl_expiration(self) -> None:
        """Test TTL expiration in SQLite cache."""
        from pretok.pipeline.sqlite_cache import SQLiteCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SQLiteCache(path=f"{tmpdir}/test.db", ttl=1)
            try:
                cache.set("key", "value")
                assert cache.get("key") == "value"

                time.sleep(1.1)
                assert cache.get("key") is None
            finally:
                cache.close()

    def test_cleanup_expired(self) -> None:
        """Test cleanup_expired removes old entries."""
        from pretok.pipeline.sqlite_cache import SQLiteCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SQLiteCache(path=f"{tmpdir}/test.db", ttl=1)
            try:
                cache.set("old", "value")
                time.sleep(1.1)
                cache.set("new", "value")

                removed = cache.cleanup_expired()
                assert removed == 1
                assert cache.size() == 1
            finally:
                cache.close()

    def test_stats(self) -> None:
        """Test cache statistics."""
        from pretok.pipeline.sqlite_cache import SQLiteCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SQLiteCache(path=f"{tmpdir}/test.db")
            try:
                cache.set("key", "value")
                cache.get("key")  # Hit
                cache.get("miss")  # Miss

                stats = cache.stats
                assert stats["hits"] == 1
                assert stats["misses"] == 1
                assert stats["backend"] == "sqlite"
                assert stats["hit_rate"] == 0.5
            finally:
                cache.close()

    def test_json_values(self) -> None:
        """Test storing and retrieving JSON-serializable values."""
        from pretok.pipeline.sqlite_cache import SQLiteCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SQLiteCache(path=f"{tmpdir}/test.db")
            try:
                cache.set("str", "hello")
                assert cache.get("str") == "hello"
            finally:
                cache.close()

    def test_persistence(self) -> None:
        """Test that data persists across instances."""
        from pretok.pipeline.sqlite_cache import SQLiteCache

        with tempfile.TemporaryDirectory() as tmpdir:
            db_path = f"{tmpdir}/test.db"

            cache1 = SQLiteCache(path=db_path)
            cache1.set("key", "persisted")
            cache1.close()

            cache2 = SQLiteCache(path=db_path)
            try:
                assert cache2.get("key") == "persisted"
            finally:
                cache2.close()


# ===========================================================================
# Tests for Pipeline confidence_threshold
# ===========================================================================


class TestPipelineConfidenceThreshold:
    """Tests for confidence_threshold in pipeline."""

    def test_low_confidence_skips_translation(self) -> None:
        """Test that low confidence detection skips translation."""
        pretok = Pretok(
            target_language="en",
            detector=LowConfidenceDetector(),
            translator=MockTranslator(),
        )
        # LowConfidenceDetector returns 0.3 confidence, threshold is 0.8
        result = pretok.process("Hello world")
        # Should NOT translate because confidence is below threshold
        assert result.processed_text == "Hello world"
        assert result.stats.skipped_segments > 0

    def test_high_confidence_translates(self) -> None:
        """Test that high confidence detection triggers translation."""
        pretok = Pretok(
            target_language="en",
            detector=MockDetector(),  # Returns 0.95 confidence
            translator=MockTranslator(),
        )
        result = pretok.process("你好世界")
        # Should translate because confidence is above threshold
        assert "[translated:en]" in result.processed_text

    def test_custom_threshold_from_config(self) -> None:
        """Test confidence threshold from config."""
        from pretok.config import PipelineConfig, PretokConfig

        config = PretokConfig(
            pipeline=PipelineConfig(confidence_threshold=0.1),
        )
        pretok = Pretok(
            config=config,
            target_language="en",
            detector=LowConfidenceDetector(),
            translator=MockTranslator(),
        )
        # With threshold=0.1, even 0.3 confidence should pass
        result = pretok.process("Hello world")
        # LowConfidenceDetector says it's "zh", so it should translate
        assert "[translated:en]" in result.processed_text


# ===========================================================================
# Tests for Pipeline strict_mode
# ===========================================================================


class TestPipelineStrictMode:
    """Tests for strict_mode in pipeline."""

    def test_strict_mode_raises_on_detection_error(self) -> None:
        """Test strict mode raises on detection error."""
        from pretok.config import PipelineConfig, PretokConfig

        config = PretokConfig(
            pipeline=PipelineConfig(strict_mode=True),
        )
        pretok = Pretok(
            config=config,
            target_language="en",
            detector=FailingDetector(),
            translator=MockTranslator(),
        )
        with pytest.raises(DetectionError):
            pretok.process("Hello world")

    def test_strict_mode_raises_on_translation_error(self) -> None:
        """Test strict mode raises on translation error."""
        from pretok.config import PipelineConfig, PretokConfig

        # Need a detector that returns non-target language
        class ZhDetector(BaseDetector):
            @property
            def name(self) -> str:
                return "zh_detector"

            def detect(self, text: str) -> DetectionResult:
                return DetectionResult(language="zh", confidence=0.95, detector="zh_detector")

        config = PretokConfig(
            pipeline=PipelineConfig(strict_mode=True),
        )
        pretok = Pretok(
            config=config,
            target_language="en",
            detector=ZhDetector(),
            translator=FailingTranslator(),
        )
        with pytest.raises(TranslationError):
            pretok.process("你好世界")

    def test_non_strict_mode_graceful_degradation(self) -> None:
        """Test non-strict mode handles errors gracefully."""
        pretok = Pretok(
            target_language="en",
            detector=FailingDetector(),
            translator=MockTranslator(),
        )
        # Should not raise
        result = pretok.process("Hello world")
        assert result.processed_text == "Hello world"


# ===========================================================================
# Tests for Pipeline stats
# ===========================================================================


class TestPipelineStats:
    """Tests for PipelineStats."""

    def test_stats_populated(self) -> None:
        """Test that stats are populated after processing."""
        pretok = Pretok(
            target_language="en",
            detector=MockDetector(),
            translator=MockTranslator(),
        )
        result = pretok.process("你好世界")
        assert result.stats.total_segments > 0
        assert result.stats.total_time_ms > 0
        assert result.stats.detection_time_ms >= 0
        assert result.stats.translation_time_ms >= 0

    def test_stats_cache_hit(self) -> None:
        """Test stats for cache hit."""
        pretok = Pretok(
            target_language="en",
            detector=MockDetector(),
            translator=MockTranslator(),
        )
        pretok.process("你好")
        result = pretok.process("你好")
        assert result.stats.cache_hit is True

    def test_stats_empty_text(self) -> None:
        """Test stats for empty text."""
        pretok = Pretok(
            target_language="en",
            detector=MockDetector(),
        )
        result = pretok.process("")
        assert result.stats.total_segments == 0

    def test_pipeline_stats_dataclass(self) -> None:
        """Test PipelineStats dataclass defaults."""
        stats = PipelineStats()
        assert stats.total_segments == 0
        assert stats.translated_segments == 0
        assert stats.skipped_segments == 0
        assert stats.detection_time_ms == 0.0
        assert stats.translation_time_ms == 0.0
        assert stats.total_time_ms == 0.0
        assert stats.cache_hit is False


# ===========================================================================
# Tests for Pipeline hooks
# ===========================================================================


class TestPipelineHooks:
    """Tests for pipeline hooks."""

    def test_add_pre_detection_hook(self) -> None:
        """Test adding and executing a pre-detection hook."""
        pretok = Pretok(
            target_language="en",
            detector=MockDetector(),
            translator=MockTranslator(),
        )
        hook_calls: list[dict] = []

        def my_hook(**kwargs: object) -> None:
            hook_calls.append({"event": "pre_detection", "kwargs": kwargs})

        pretok.add_hook("pre_detection", my_hook)
        pretok.process("Hello")

        assert len(hook_calls) == 1
        assert hook_calls[0]["event"] == "pre_detection"

    def test_add_post_translation_hook(self) -> None:
        """Test adding and executing a post-translation hook."""
        pretok = Pretok(
            target_language="en",
            detector=MockDetector(),
            translator=MockTranslator(),
        )
        hook_calls: list[dict] = []

        def my_hook(**kwargs: object) -> None:
            hook_calls.append({"event": "post_translation", "kwargs": kwargs})

        pretok.add_hook("post_translation", my_hook)
        pretok.process("你好")

        assert len(hook_calls) == 1

    def test_invalid_hook_event(self) -> None:
        """Test that invalid hook event raises ValueError."""
        pretok = Pretok(target_language="en", detector=MockDetector())

        with pytest.raises(ValueError, match="Unknown hook event"):
            pretok.add_hook("invalid_event", lambda **_kw: None)

    def test_remove_hook(self) -> None:
        """Test removing a hook."""
        pretok = Pretok(target_language="en", detector=MockDetector())

        def noop_hook(**_kw: object) -> None:
            pass

        pretok.add_hook("pre_detection", noop_hook)
        assert pretok.remove_hook("pre_detection", noop_hook) is True
        assert pretok.remove_hook("pre_detection", noop_hook) is False

    def test_remove_hook_invalid_event(self) -> None:
        """Test removing hook with invalid event returns False."""
        pretok = Pretok(target_language="en", detector=MockDetector())
        assert pretok.remove_hook("invalid", lambda **_kw: None) is False

    def test_multiple_hooks_same_event(self) -> None:
        """Test multiple hooks on same event."""
        pretok = Pretok(
            target_language="en",
            detector=MockDetector(),
        )
        order: list[int] = []

        pretok.add_hook("pre_detection", lambda **_kw: order.append(1))
        pretok.add_hook("pre_detection", lambda **_kw: order.append(2))

        pretok.process("Hello")
        assert order == [1, 2]


# ===========================================================================
# Tests for process_batch
# ===========================================================================


class TestProcessBatch:
    """Tests for process_batch."""

    def test_batch_processing(self) -> None:
        """Test batch processing of multiple texts."""
        pretok = Pretok(
            target_language="en",
            detector=MockDetector(),
            translator=MockTranslator(),
        )
        texts = ["你好", "世界", "Hello"]
        results = pretok.process_batch(texts)

        assert len(results) == 3
        assert all(isinstance(r, PipelineResult) for r in results)

    def test_batch_empty_list(self) -> None:
        """Test batch with empty list."""
        pretok = Pretok(
            target_language="en",
            detector=MockDetector(),
        )
        results = pretok.process_batch([])
        assert results == []

    def test_batch_with_target_language(self) -> None:
        """Test batch with overridden target language."""
        pretok = Pretok(
            target_language="en",
            detector=MockDetector(),
            translator=MockTranslator(),
        )
        results = pretok.process_batch(["Hello"], target_language="zh")
        assert len(results) == 1


# ===========================================================================
# Tests for Pipeline cache routing
# ===========================================================================


class TestPipelineCacheRouting:
    """Tests for cache backend routing in pipeline."""

    def test_default_memory_cache(self) -> None:
        """Test default uses memory cache."""
        pretok = Pretok(target_language="en", detector=MockDetector())
        assert pretok._cache is not None
        assert isinstance(pretok._cache, MemoryCache)

    def test_sqlite_cache_from_config(self) -> None:
        """Test SQLite cache creation from config."""
        from pretok.config import (
            CacheConfig,
            PipelineConfig,
            PretokConfig,
            SQLiteCacheConfig,
        )
        from pretok.pipeline.sqlite_cache import SQLiteCache

        with tempfile.TemporaryDirectory() as tmpdir:
            config = PretokConfig(
                pipeline=PipelineConfig(cache_backend="sqlite"),
                cache=CacheConfig(
                    sqlite=SQLiteCacheConfig(
                        path=f"{tmpdir}/test.db",
                        ttl=3600,
                    ),
                ),
            )
            pretok = Pretok(
                config=config,
                target_language="en",
                detector=MockDetector(),
            )
            assert isinstance(pretok._cache, SQLiteCache)

    def test_disabled_cache(self) -> None:
        """Test that cache can be disabled."""
        from pretok.config import PipelineConfig, PretokConfig

        config = PretokConfig(
            pipeline=PipelineConfig(cache_enabled=False),
        )
        pretok = Pretok(
            config=config,
            target_language="en",
            detector=MockDetector(),
        )
        assert pretok._cache is None

    def test_redis_fallback_without_package(self) -> None:
        """Test that redis falls back to memory if redis package not installed."""
        from pretok.config import PipelineConfig, PretokConfig

        config = PretokConfig(
            pipeline=PipelineConfig(cache_backend="redis"),
        )
        # Redis might not be installed in test env, should fall back gracefully
        pretok = Pretok(
            config=config,
            target_language="en",
            detector=MockDetector(),
        )
        # Should either be RedisCache or MemoryCache (fallback)
        assert pretok._cache is not None


# ===========================================================================
# Tests for CLI
# ===========================================================================


class TestCLI:
    """Tests for pretok CLI."""

    def test_version(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Test version display."""
        from pretok.__main__ import main

        with pytest.raises(SystemExit) as exc_info:
            main(["--version"])
        assert exc_info.value.code == 0

    def test_no_command_shows_help(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Test that no command shows help."""
        from pretok.__main__ import main

        result = main([])
        assert result == 0

    def test_detect_command(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Test detect command."""
        from pretok.__main__ import main

        result = main(["detect", "Hello, how are you?"])
        captured = capsys.readouterr()
        assert result == 0
        assert "en" in captured.out

    def test_detect_command_json(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Test detect command with JSON output."""
        from pretok.__main__ import main

        result = main(["detect", "--json", "Hello, how are you?"])
        captured = capsys.readouterr()
        assert result == 0

        data = json.loads(captured.out)
        assert "language" in data
        assert "confidence" in data

    def test_segment_command(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Test segment command."""
        from pretok.__main__ import main

        result = main(["segment", "Hello world"])
        captured = capsys.readouterr()
        assert result == 0
        assert "TEXT" in captured.out

    def test_segment_command_json(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Test segment command with JSON output."""
        from pretok.__main__ import main

        result = main(["segment", "--json", "<|im_start|>user\nHello<|im_end|>"])
        captured = capsys.readouterr()
        assert result == 0

        data = json.loads(captured.out)
        assert isinstance(data, list)
        types = {seg["type"] for seg in data}
        assert "ROLE_MARKER" in types
        assert "TEXT" in types

    def test_config_show(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Test config show command."""
        from pretok.__main__ import main

        result = main(["config", "show"])
        captured = capsys.readouterr()
        assert result == 0

        data = json.loads(captured.out)
        assert "pipeline" in data
        assert "version" in data

    def test_config_validate_no_file(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Test config validate without file."""
        from pretok.__main__ import main

        result = main(["config", "validate"])
        assert result == 1

    def test_process_no_text(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Test process command with no text and no stdin."""

        from pretok.__main__ import main

        # Simulate empty tty stdin
        with patch("pretok.__main__._read_text", return_value=""):
            result = main(["process"])
        assert result == 1

    def test_segment_with_format(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Test segment command with format hint."""
        from pretok.__main__ import main

        result = main(["segment", "-f", "chatml", "<|im_start|>user\nHello<|im_end|>"])
        captured = capsys.readouterr()
        assert result == 0
        assert "ROLE_MARKER" in captured.out


# ===========================================================================
# Tests for translation factory updates
# ===========================================================================


class TestTranslationFactory:
    """Tests for updated translation factory."""

    def test_unknown_backend(self) -> None:
        """Test that unknown backend raises ValueError."""
        from pretok.translation.factory import create_translator

        with pytest.raises(ValueError, match="Unknown translator backend"):
            create_translator("unknown")

    def test_google_backend_import_error(self) -> None:
        """Test google backend raises ImportError if package not installed."""
        from pretok.translation.factory import create_translator

        # This will try to import google-cloud-translate which might not be installed
        with contextlib.suppress(ImportError, Exception):
            create_translator("google")

    def test_deepl_backend_import_error(self) -> None:
        """Test deepl backend raises ImportError if package not installed."""
        from pretok.translation.factory import create_translator

        with contextlib.suppress(ImportError, Exception):
            create_translator("deepl")


# ===========================================================================
# Tests for retry_delay config field
# ===========================================================================


class TestRetryDelayConfig:
    """Tests for retry_delay in LLMTranslatorConfig."""

    def test_retry_delay_default(self) -> None:
        """Test default retry_delay value."""
        from pretok.config import LLMTranslatorConfig

        config = LLMTranslatorConfig()
        assert config.retry_delay == 1.0

    def test_retry_delay_custom(self) -> None:
        """Test custom retry_delay value."""
        from pretok.config import LLMTranslatorConfig

        config = LLMTranslatorConfig(retry_delay=2.5)
        assert config.retry_delay == 2.5
