#!/usr/bin/env python3
"""Unit tests for OpenViking Curator v2 core functions."""

import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch

# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

# Set required env vars before importing curator
os.environ.setdefault("OAI_BASE", "http://localhost:8000/v1")
os.environ.setdefault("OAI_KEY", "test-key")
os.environ.setdefault("GROK_SEARCH_URL", "http://localhost:8788")

from curator import (
    assess_coverage,
    feedback_store,
    load_context,
    # v2 modules
    ov_retrieve,
    route_scope,
    validate_config,
)
from curator_query import should_route

# ─── route_scope ─────────────────────────────────────────────


class TestRouteScope(unittest.TestCase):
    """Test query scope routing (lightweight, rule-based only)."""

    def test_domain_detection(self):
        scope = route_scope("Python async 怎么用")
        self.assertEqual(scope["domain"], "technology")

    def test_general_domain_fallback(self):
        scope = route_scope("今天天气怎么样")
        self.assertEqual(scope["domain"], "general")
        self.assertIsInstance(scope["keywords"], list)

    def test_keywords_extraction(self):
        scope = route_scope("Nginx 反向代理 502 怎么排查？")
        kw_lower = [k.lower() for k in scope["keywords"]]
        self.assertIn("nginx", kw_lower)

    def test_need_fresh(self):
        scope = route_scope("2026 最新 Python 发布了什么")
        self.assertTrue(scope["need_fresh"])

    def test_no_source_pref(self):
        """v2 router no longer returns source_pref/exclude/confidence."""
        scope = route_scope("Docker 部署指南")
        self.assertNotIn("source_pref", scope)
        self.assertNotIn("confidence", scope)
        self.assertNotIn("exclude", scope)


# ─── assess_coverage (v2) ────────────────────────────────────


class TestAssessCoverage(unittest.TestCase):
    """Test simplified coverage assessment."""

    def test_no_results(self):
        cov, need_ext, reason = assess_coverage({"all_items": []})
        self.assertEqual(cov, 0.0)
        self.assertTrue(need_ext)
        self.assertEqual(reason, "no_results")

    def test_no_scores(self):
        cov, need_ext, reason = assess_coverage({"all_items": [{"uri": "x", "score": 0}]})
        self.assertEqual(cov, 0.0)
        self.assertTrue(need_ext)
        self.assertEqual(reason, "no_scores")

    def test_high_score_sufficient(self):
        items = [
            {"uri": "a", "score": 0.7},
            {"uri": "b", "score": 0.6},
            {"uri": "c", "score": 0.5},
        ]
        cov, need_ext, reason = assess_coverage({"all_items": items})
        self.assertGreater(cov, 0.5)
        self.assertFalse(need_ext)
        self.assertEqual(reason, "local_sufficient")

    def test_marginal_coverage(self):
        items = [{"uri": "a", "score": 0.5}]
        cov, need_ext, reason = assess_coverage({"all_items": items})
        self.assertTrue(need_ext)  # marginal 也外搜补充
        self.assertEqual(reason, "local_marginal")

    def test_low_score_triggers_external(self):
        items = [{"uri": "a", "score": 0.3}]
        cov, need_ext, reason = assess_coverage({"all_items": items})
        self.assertTrue(need_ext)
        self.assertIn(reason, ("low_coverage", "insufficient"))

    def test_score_gap_degrades_coverage(self):
        """Large gap between top1 and top2 should reduce confidence."""
        # Tight cluster: top1=0.7, top2=0.65 → small gap → sufficient
        tight = [
            {"uri": "a", "score": 0.7, "abstract": "docker deploy"},
            {"uri": "b", "score": 0.65, "abstract": "nginx config"},
        ]
        _, need_ext_tight, reason_tight = assess_coverage({"all_items": tight}, query="docker")

        # Wide gap: top1=0.7, top2=0.3 → big gap → less confident
        wide = [
            {"uri": "a", "score": 0.7, "abstract": "docker deploy"},
            {"uri": "b", "score": 0.3, "abstract": "unrelated"},
        ]
        cov_wide, need_ext_wide, reason_wide = assess_coverage({"all_items": wide}, query="docker")

        # Wide gap should degrade coverage compared to tight cluster
        self.assertEqual(reason_tight, "local_sufficient")
        # With gap penalty, wide gap may drop to marginal or stay sufficient
        # but coverage value should be lower
        cov_tight, _, _ = assess_coverage({"all_items": tight}, query="docker")
        self.assertLessEqual(cov_wide, cov_tight)

    def test_keyword_overlap_affects_coverage(self):
        """Low keyword overlap should trigger external search more often."""
        from curator.retrieval_v2 import _keyword_overlap

        # Good overlap
        items_match = [{"abstract": "how to deploy Redis on Ubuntu Linux"}]
        overlap = _keyword_overlap("deploy Redis Ubuntu", items_match)
        self.assertGreater(overlap, 0.5)

        # No overlap
        items_nomatch = [{"abstract": "cooking recipes for pasta"}]
        overlap_none = _keyword_overlap("deploy Redis Ubuntu", items_nomatch)
        self.assertEqual(overlap_none, 0.0)

    def test_keyword_overlap_empty_query(self):
        """Empty query returns 0.0 overlap."""
        from curator.retrieval_v2 import _keyword_overlap

        self.assertEqual(_keyword_overlap("", [{"abstract": "test"}]), 0.0)

    def test_keyword_overlap_no_keywords(self):
        """Query with no extractable keywords returns 1.0 (can't measure)."""
        from curator.retrieval_v2 import _keyword_overlap

        # Single short chars can't form 3+ char EN tokens or 2+ CN chars
        self.assertEqual(_keyword_overlap("a b", [{"abstract": "test"}]), 1.0)


# ─── load_context (v2, strict on-demand) ─────────────────────


class TestLoadContext(unittest.TestCase):
    """Test strict L0→L1→L2 on-demand loading."""

    def test_empty_items(self):
        mock_ov = MagicMock()
        text, uris, stage = load_context(mock_ov, [], "test query")
        self.assertEqual(text, "")
        self.assertEqual(uris, [])

    def test_l0_sufficient_skips_l1_l2(self):
        """When L0 abstracts are rich and score is high, skip L1/L2."""
        mock_ov = MagicMock()
        items = [
            {
                "uri": "a",
                "score": 0.8,
                "abstract": "A detailed abstract about Docker deployment with Nginx reverse proxy and SSL configuration. "
                * 3,
            },
            {
                "uri": "b",
                "score": 0.7,
                "abstract": "Another detailed abstract about container orchestration and systemd service management. "
                * 3,
            },
        ]
        text, uris, stage = load_context(mock_ov, items, "test query")
        # L0 is enough: overview and read should NOT be called
        mock_ov.overview.assert_not_called()
        mock_ov.read.assert_not_called()
        self.assertEqual(len(uris), 2)

    def test_l1_used_when_l0_insufficient(self):
        """When L0 is thin, L1 (overview) should be fetched."""
        mock_ov = MagicMock()
        mock_ov.overview.return_value = "This is a detailed overview of the topic with enough content."

        items = [{"uri": "a", "score": 0.4, "abstract": "short"}]
        text, uris, stage = load_context(mock_ov, items, "test query")

        mock_ov.overview.assert_called()
        mock_ov.read.assert_not_called()

    def test_l2_only_when_l1_insufficient(self):
        """L2 should only trigger when L1 is also not enough."""
        mock_ov = MagicMock()
        mock_ov.overview.return_value = "Overview content here with enough text."
        mock_ov.read.return_value = "Full detailed content of the document for deep reading."

        # Single item, score below L1-enough threshold
        items = [{"uri": "a", "score": 0.45, "abstract": "short"}]
        text, uris, stage = load_context(mock_ov, items, "test query")

        # L1 not enough (only 1 source, score < 0.5), so L2 should be attempted
        self.assertIn("a", uris)

    def test_max_l2_respected(self):
        """Should not read more than max_l2 items at L2."""
        mock_ov = MagicMock()
        mock_ov.overview.return_value = "Overview text sufficient for display."
        mock_ov.read.return_value = "Full content for reading."

        items = [{"uri": f"item_{i}", "score": 0.8 - i * 0.01, "abstract": "abs"} for i in range(5)]
        text, uris, stage = load_context(mock_ov, items, "test", max_l2=2)

        self.assertLessEqual(mock_ov.read.call_count, 2)

    def test_max_l2_zero_skips_read(self):
        """max_l2=0 should never call read()."""
        mock_ov = MagicMock()
        mock_ov.overview.return_value = "Overview with reasonable content."

        items = [{"uri": "a", "score": 0.9, "abstract": "short"}]
        text, uris, stage = load_context(mock_ov, items, "test", max_l2=0)

        mock_ov.read.assert_not_called()


# ─── feedback_store (with file lock) ─────────────────────────


class TestFeedbackStore(unittest.TestCase):
    def setUp(self):
        self.tmpfile = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
        self.tmpfile.close()
        feedback_store.STORE = Path(self.tmpfile.name)
        Path(self.tmpfile.name).write_text("{}", encoding="utf-8")

    def tearDown(self):
        os.unlink(self.tmpfile.name)

    def test_apply_increments(self):
        result = feedback_store.apply("viking://test", "up")
        self.assertEqual(result["up"], 1)
        result = feedback_store.apply("viking://test", "up")
        self.assertEqual(result["up"], 2)

    def test_load_after_apply(self):
        feedback_store.apply("viking://x", "adopt")
        data = feedback_store.load()
        self.assertEqual(data["viking://x"]["adopt"], 1)

    def test_invalid_action(self):
        with self.assertRaises(ValueError):
            feedback_store.apply("viking://x", "invalid")

    def test_concurrent_safety(self):
        for i in range(20):
            feedback_store.apply("viking://concurrent", "up")
        data = feedback_store.load()
        self.assertEqual(data["viking://concurrent"]["up"], 20)


# ─── validate_config ─────────────────────────────────────────


class TestValidateConfig(unittest.TestCase):
    def test_valid_config(self):
        import curator.config as cfg

        old = (cfg.OAI_BASE, cfg.OAI_KEY, cfg.GROK_KEY)
        cfg.OAI_BASE, cfg.OAI_KEY, cfg.GROK_KEY = "http://x", "k", "g"
        try:
            cfg.validate_config()
        finally:
            cfg.OAI_BASE, cfg.OAI_KEY, cfg.GROK_KEY = old

    @patch.dict(os.environ, {"CURATOR_OAI_BASE": "", "CURATOR_OAI_KEY": ""}, clear=False)
    def test_missing_oai_raises(self):
        import curator.config as cfg

        old_base, old_key = cfg.OAI_BASE, cfg.OAI_KEY
        cfg.OAI_BASE, cfg.OAI_KEY = "", ""
        try:
            with self.assertRaises(RuntimeError) as ctx:
                cfg.validate_config()
            self.assertIn("CURATOR_OAI_BASE", str(ctx.exception))
        finally:
            cfg.OAI_BASE, cfg.OAI_KEY = old_base, old_key


class TestChatRetryPolicy(unittest.TestCase):
    def test_retry_on_http_500_then_success(self):
        import requests

        import curator.config as cfg

        resp_500 = MagicMock()
        http_err = requests.HTTPError("500")
        http_err.response = MagicMock(status_code=500)
        resp_500.raise_for_status.side_effect = http_err

        resp_ok = MagicMock()
        resp_ok.raise_for_status.return_value = None
        resp_ok.json.return_value = {"choices": [{"message": {"content": "ok"}}]}

        old_retry_max = cfg.CHAT_RETRY_MAX
        old_backoff = cfg.CHAT_RETRY_BACKOFF_SEC
        try:
            cfg.CHAT_RETRY_MAX = 3
            cfg.CHAT_RETRY_BACKOFF_SEC = 0
            with patch("curator.config.requests.post", side_effect=[resp_500, resp_ok]) as mock_post:
                out = cfg.chat("http://x", "k", "m", [{"role": "user", "content": "hi"}], timeout=1)
            self.assertEqual(out, "ok")
            self.assertEqual(mock_post.call_count, 2)
        finally:
            cfg.CHAT_RETRY_MAX = old_retry_max
            cfg.CHAT_RETRY_BACKOFF_SEC = old_backoff

    def test_no_retry_on_invalid_payload(self):
        import curator.config as cfg

        resp = MagicMock()
        resp.raise_for_status.return_value = None
        resp.json.return_value = {"unexpected": True}

        old_retry_max = cfg.CHAT_RETRY_MAX
        old_backoff = cfg.CHAT_RETRY_BACKOFF_SEC
        try:
            cfg.CHAT_RETRY_MAX = 3
            cfg.CHAT_RETRY_BACKOFF_SEC = 0
            with patch("curator.config.requests.post", return_value=resp) as mock_post:
                with self.assertRaises(RuntimeError):
                    cfg.chat("http://x", "k", "m", [{"role": "user", "content": "hi"}], timeout=1)
            self.assertEqual(mock_post.call_count, 1)
        finally:
            cfg.CHAT_RETRY_MAX = old_retry_max
            cfg.CHAT_RETRY_BACKOFF_SEC = old_backoff

    def test_retry_max_clamped_to_one(self):
        import curator.config as cfg

        resp = MagicMock()
        resp.raise_for_status.return_value = None
        resp.json.return_value = {"choices": [{"message": {"content": "ok"}}]}

        old_retry_max = cfg.CHAT_RETRY_MAX
        try:
            cfg.CHAT_RETRY_MAX = 0
            with patch("curator.config.requests.post", return_value=resp) as mock_post:
                out = cfg.chat("http://x", "k", "m", [{"role": "user", "content": "hi"}], timeout=1)
            self.assertEqual(out, "ok")
            self.assertEqual(mock_post.call_count, 1)
        finally:
            cfg.CHAT_RETRY_MAX = old_retry_max


# ─── should_route (curator_query gate) ───────────────────────


class TestShouldRoute(unittest.TestCase):
    def test_tech_question_routes(self):
        routed, reason = should_route("Redis 和 Memcached 怎么选")
        self.assertTrue(routed)

    def test_greeting_blocks(self):
        routed, reason = should_route("你好")
        self.assertFalse(routed)

    def test_positive_overrides_weak_negative(self):
        routed, reason = should_route("ok 那 Docker 怎么部署")
        self.assertTrue(routed)

    def test_strong_negative_blocks(self):
        routed, reason = should_route("今天天气怎么样")
        self.assertFalse(routed)

    def test_empty_query(self):
        routed, reason = should_route("")
        self.assertFalse(routed)

    def test_pure_command_blocks(self):
        routed, reason = should_route("帮我跑一下 git status")
        self.assertFalse(routed)


# ─── OVClient.wait_processed ─────────────────────────────────


class TestOVClientWaitProcessed(unittest.TestCase):
    def test_wait_processed_method_exists(self):
        """OVClient should have wait_processed method."""
        from curator.session_manager import OVClient

        self.assertTrue(hasattr(OVClient, "wait_processed"))


# ─── uri_freshness_score (restored) ──────────────────────────


class TestUriFreshnessScore(unittest.TestCase):
    def test_recent_uri_full_freshness(self):
        import time as _time

        from curator.freshness import uri_freshness_score

        recent_ts = int(_time.time()) - 86400
        uri = f"viking://resources/{recent_ts}_test_doc"
        score = uri_freshness_score(uri)
        self.assertEqual(score, 1.0)

    def test_old_uri_decayed(self):
        import time as _time

        from curator.freshness import uri_freshness_score

        old_ts = int(_time.time()) - 120 * 86400
        uri = f"viking://resources/{old_ts}_old_doc"
        score = uri_freshness_score(uri)
        self.assertLess(score, 1.0)
        self.assertGreater(score, 0.2)

    def test_very_old_uri_stale(self):
        import time as _time

        from curator.freshness import uri_freshness_score

        ancient_ts = int(_time.time()) - 400 * 86400
        uri = f"viking://resources/{ancient_ts}_ancient_doc"
        score = uri_freshness_score(uri)
        self.assertEqual(score, 0.1)

    def test_no_timestamp_returns_default(self):
        from curator.freshness import uri_freshness_score

        score = uri_freshness_score("viking://resources/no_timestamp_here")
        self.assertEqual(score, 0.5)


# ─── scan_duplicates (report-only) ───────────────────────────


class TestScanDuplicates(unittest.TestCase):
    def test_too_few_uris(self):
        from curator.dedup import scan_duplicates

        mock_ov = MagicMock()
        result = scan_duplicates(mock_ov, ["viking://a"])
        self.assertEqual(result["checked"], 0)
        self.assertEqual(result["duplicates"], [])

    def test_no_auto_delete(self):
        """scan_duplicates should never call delete/rm on the client."""
        from curator.dedup import scan_duplicates

        mock_ov = MagicMock()
        mock_ov.read.return_value = "some content " * 50
        scan_duplicates(mock_ov, ["viking://a", "viking://b"], max_checks=1)
        # Should never try to delete anything
        mock_ov.rm.assert_not_called()
        mock_ov.add_resource.assert_not_called()


# ─── load_context returns stage ──────────────────────────────


class TestLoadContextStage(unittest.TestCase):
    def test_returns_stage_none_on_empty(self):
        mock_ov = MagicMock()
        text, uris, stage = load_context(mock_ov, [], "test")
        self.assertEqual(stage, "none")

    def test_returns_stage_l0_on_high_score(self):
        mock_ov = MagicMock()
        items = [
            {"uri": "a", "score": 0.8, "abstract": "A detailed abstract about Docker deployment. " * 5},
            {"uri": "b", "score": 0.7, "abstract": "Another detailed abstract about container management. " * 5},
        ]
        text, uris, stage = load_context(mock_ov, items, "test")
        self.assertEqual(stage, "L0")


# ─── _log_query (Phase 1: query logging) ────────────────────


class TestLogQuery(unittest.TestCase):
    """Test query log writing."""

    def test_log_query_writes_jsonl(self):
        """_log_query should write a valid JSONL entry."""
        from curator.pipeline_v2 import _log_query

        with tempfile.TemporaryDirectory() as tmpdir:
            import curator.config as cfg
            import curator.pipeline_v2 as pv2

            old_data_path = cfg.DATA_PATH
            # Patch DATA_PATH at module level in pipeline_v2
            _ = pv2.DATA_PATH if hasattr(pv2, "DATA_PATH") else None  # noqa: F841
            try:
                # _log_query uses DATA_PATH from config import
                cfg.DATA_PATH = tmpdir
                # Re-import or patch the module-level reference
                import importlib

                importlib.reload(pv2)

                pv2._log_query(
                    query="test query",
                    coverage=0.75,
                    need_external=True,
                    reason="low_coverage",
                    used_uris=["viking://a", "viking://b"],
                    trace={"load_stage": "L1", "llm_calls": 1},
                )

                log_path = os.path.join(tmpdir, "query_log.jsonl")
                self.assertTrue(os.path.exists(log_path))
                with open(log_path, "r") as f:
                    lines = f.readlines()
                self.assertEqual(len(lines), 1)

                entry = json.loads(lines[0])
                self.assertEqual(entry["query"], "test query")
                self.assertAlmostEqual(entry["coverage"], 0.75, places=2)
                self.assertTrue(entry["external_triggered"])
                self.assertEqual(entry["reason"], "low_coverage")
                self.assertEqual(entry["used_uris"], ["viking://a", "viking://b"])
                self.assertEqual(entry["load_stage"], "L1")
                self.assertEqual(entry["llm_calls"], 1)
                self.assertIn("timestamp", entry)
            finally:
                cfg.DATA_PATH = old_data_path
                importlib.reload(pv2)

    def test_log_query_appends(self):
        """_log_query should append, not overwrite."""
        from curator.pipeline_v2 import _log_query

        with tempfile.TemporaryDirectory() as tmpdir:
            import curator.config as cfg
            import curator.pipeline_v2 as pv2

            old_data_path = cfg.DATA_PATH
            try:
                cfg.DATA_PATH = tmpdir
                import importlib

                importlib.reload(pv2)

                for i in range(3):
                    pv2._log_query(f"query_{i}", 0.5, False, "ok", [], {"load_stage": "L0", "llm_calls": 0})

                log_path = os.path.join(tmpdir, "query_log.jsonl")
                with open(log_path, "r") as f:
                    lines = f.readlines()
                self.assertEqual(len(lines), 3)
            finally:
                cfg.DATA_PATH = old_data_path
                importlib.reload(pv2)

    def test_log_query_failure_silent(self):
        """_log_query should not raise even if writing fails."""
        from curator.pipeline_v2 import _log_query

        with patch("builtins.open", side_effect=PermissionError("denied")):
            import curator.config as cfg
            import curator.pipeline_v2 as pv2

            old = cfg.DATA_PATH
            cfg.DATA_PATH = "/nonexistent/path"
            try:
                import importlib

                importlib.reload(pv2)
                # Should not raise
                pv2._log_query("q", 0.5, False, "ok", [], {"load_stage": "L0", "llm_calls": 0})
            finally:
                cfg.DATA_PATH = old
                importlib.reload(pv2)


# ─── analyze_weak (Phase 1: weakness analysis) ──────────────


class TestAnalyzeWeak(unittest.TestCase):
    """Test weakness analysis from query logs."""

    def _write_log(self, tmpdir, entries):
        log_path = os.path.join(tmpdir, "query_log.jsonl")
        with open(log_path, "w") as f:
            for e in entries:
                f.write(json.dumps(e) + "\n")

    def test_identifies_weak_topics(self):
        sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"))
        from analyze_weak import analyze

        with tempfile.TemporaryDirectory() as tmpdir:
            # Same topic extracted ("redis cluster"), all triggering external search
            entries = [
                {"query": "redis cluster 部署", "coverage": 0.2, "external_triggered": True, "reason": "low_coverage"},
                {"query": "redis cluster 部署", "coverage": 0.3, "external_triggered": True, "reason": "low_coverage"},
                {"query": "redis cluster 部署", "coverage": 0.25, "external_triggered": True, "reason": "low_coverage"},
            ]
            self._write_log(tmpdir, entries)
            weak = analyze(tmpdir, min_queries=2)
            self.assertGreater(len(weak), 0)
            # At least one topic should have external_rate > 0.5
            self.assertTrue(any(t["external_rate"] > 0.5 for t in weak))

    def test_no_weak_when_coverage_good(self):
        sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"))
        from analyze_weak import analyze

        with tempfile.TemporaryDirectory() as tmpdir:
            entries = [
                {
                    "query": "docker compose 部署",
                    "coverage": 0.8,
                    "external_triggered": False,
                    "reason": "local_sufficient",
                },
                {
                    "query": "docker compose 配置",
                    "coverage": 0.9,
                    "external_triggered": False,
                    "reason": "local_sufficient",
                },
            ]
            self._write_log(tmpdir, entries)
            weak = analyze(tmpdir, min_queries=2)
            self.assertEqual(len(weak), 0)

    def test_min_queries_filter(self):
        sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"))
        from analyze_weak import analyze

        with tempfile.TemporaryDirectory() as tmpdir:
            entries = [
                {"query": "kubernetes helm", "coverage": 0.1, "external_triggered": True, "reason": "no_results"},
            ]
            self._write_log(tmpdir, entries)
            # min_queries=2, only 1 query → no weak topics
            weak = analyze(tmpdir, min_queries=2)
            self.assertEqual(len(weak), 0)
            # min_queries=1 → should find it
            weak = analyze(tmpdir, min_queries=1)
            self.assertGreater(len(weak), 0)

    def test_empty_log(self):
        sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"))
        from analyze_weak import analyze

        with tempfile.TemporaryDirectory() as tmpdir:
            # No log file
            weak = analyze(tmpdir)
            self.assertEqual(len(weak), 0)

    def test_extract_keywords(self):
        sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"))
        from analyze_weak import extract_keywords

        kws = extract_keywords("Redis 和 Memcached 怎么选")
        kw_lower = [k.lower() for k in kws]
        self.assertIn("redis", kw_lower)
        self.assertIn("memcached", kw_lower)
        # 停用词应被过滤
        self.assertNotIn("怎么", kw_lower)
        self.assertNotIn("和", kw_lower)


# ─── CuratorPipeline class ───────────────────────────────


class TestCuratorPipeline(unittest.TestCase):
    """Test CuratorPipeline reusable instance."""

    def _mock_pipeline_deps(self):
        return {
            "backend_retrieve": MagicMock(
                return_value={
                    "all_items": [{"uri": "a", "score": 0.8, "abstract": "good"}],
                    "all_items_raw": [{"uri": "a", "score": 0.8, "abstract": "good"}],
                    "memories": [],
                    "resources": [{"uri": "a", "score": 0.8, "abstract": "good"}],
                    "skills": [],
                    "query_plan": None,
                }
            ),
            "assess_coverage": MagicMock(return_value=(0.8, False, "local_sufficient")),
            "load_context": MagicMock(return_value=("context", ["viking://a"], "L0")),
            "capture_case": MagicMock(return_value=None),
            "validate_config": MagicMock(),
        }

    def test_pipeline_class_runs(self):
        """CuratorPipeline can be instantiated and run."""
        from curator.backend_memory import InMemoryBackend
        from curator.pipeline_v2 import CuratorPipeline

        backend = InMemoryBackend()
        patches = self._mock_pipeline_deps()

        with patch.multiple("curator.pipeline_v2", **patches):
            pipeline = CuratorPipeline(backend=backend)
            result = pipeline.run("test query")

        self.assertEqual(result["query"], "test query")
        self.assertIn("context_text", result)
        self.assertIn("meta", result)

    def test_pipeline_reuses_backend(self):
        """Multiple runs reuse the same backend instance."""
        from curator.backend_memory import InMemoryBackend
        from curator.pipeline_v2 import CuratorPipeline

        backend = InMemoryBackend()
        patches = self._mock_pipeline_deps()

        with patch.multiple("curator.pipeline_v2", **patches):
            pipeline = CuratorPipeline(backend=backend)
            r1 = pipeline.run("query 1")
            r2 = pipeline.run("query 2")

        self.assertIs(pipeline.backend, backend)
        self.assertEqual(r1["query"], "query 1")
        self.assertEqual(r2["query"], "query 2")

    def test_pipeline_health_ttl(self):
        """Session not re-initialised within health_ttl window."""
        from curator.backend_memory import InMemoryBackend
        from curator.pipeline_v2 import CuratorPipeline

        backend = InMemoryBackend()
        patches = self._mock_pipeline_deps()

        # Track how many times load_or_create_session is called
        call_count = [0]
        original = backend.load_or_create_session

        def _counted(*args, **kwargs):
            call_count[0] += 1
            return original(*args, **kwargs)

        backend.load_or_create_session = _counted

        with patch.multiple("curator.pipeline_v2", **patches):
            pipeline = CuratorPipeline(backend=backend, health_ttl=60.0)
            pipeline.run("query 1")
            pipeline.run("query 2")

        # load_or_create_session called once (first run), cached for second run (health_ttl)
        self.assertEqual(call_count[0], 1)

    def test_empty_context_forces_external_search(self):
        """When load_context returns empty content, pipeline must force need_external=True."""
        from curator.backend_memory import InMemoryBackend
        from curator.pipeline_v2 import CuratorPipeline

        backend = InMemoryBackend()
        patches = self._mock_pipeline_deps()
        # Simulate: OV scores look fine (coverage=0.7, need_external=False)
        # but tiered loading produced no usable content (all abstracts too short)
        patches["assess_coverage"] = MagicMock(return_value=(0.7, False, "local_sufficient"))
        patches["load_context"] = MagicMock(return_value=("", [], "L2"))

        with patch.multiple("curator.pipeline_v2", **patches):
            pipeline = CuratorPipeline(backend=backend)
            result = pipeline.run("some query")

        # External should have been triggered due to empty content
        self.assertTrue(result["meta"]["external_triggered"])
        self.assertEqual(result["coverage"], 0.0)


class TestDegradationTracking(unittest.TestCase):
    """Verify degradation flags surface when LLM/search calls fail."""

    def _mock_pipeline_deps(self):
        from unittest.mock import MagicMock

        return {
            "validate_config": MagicMock(),
            "route_scope": MagicMock(return_value={"domain": "general", "need_fresh": False, "keywords": []}),
            "backend_retrieve": MagicMock(
                return_value={"all_items": [], "memories": [], "resources": [], "skills": []}
            ),
            "assess_coverage": MagicMock(return_value=(0.2, True, "coverage_low")),
            "load_context": MagicMock(return_value=("some context", ["uri1"], "L0")),
            "external_search": MagicMock(side_effect=RuntimeError("all providers failed")),
            "judge_and_ingest": MagicMock(return_value={"pass": False, "reason": "no_response"}),
            "cross_validate": MagicMock(return_value={"validated": "", "warnings": []}),
            "capture_case": MagicMock(return_value=None),
            "format_report": MagicMock(return_value=""),
        }

    def test_external_search_failure_sets_degraded(self):
        from curator.backend_memory import InMemoryBackend
        from curator.pipeline_v2 import CuratorPipeline

        backend = InMemoryBackend()
        patches = self._mock_pipeline_deps()

        with patch.multiple("curator.pipeline_v2", **patches):
            result = CuratorPipeline(backend=backend).run("test query")

        self.assertTrue(result["meta"]["degraded"])
        self.assertTrue(any("external_search" in r for r in result["meta"]["degraded_reasons"]))

    def test_no_degradation_when_all_succeeds(self):
        from curator.backend_memory import InMemoryBackend
        from curator.pipeline_v2 import CuratorPipeline

        backend = InMemoryBackend()
        patches = self._mock_pipeline_deps()
        # Coverage sufficient → no external search needed → no degradation
        patches["assess_coverage"] = MagicMock(return_value=(0.8, False, "local_sufficient"))

        with patch.multiple("curator.pipeline_v2", **patches):
            result = CuratorPipeline(backend=backend).run("test query")

        self.assertFalse(result["meta"].get("degraded", False))
        self.assertEqual(result["meta"].get("degraded_reasons", []), [])


class TestSanitizeMarkdown(unittest.TestCase):
    """Tests for _sanitize_markdown HTML/XSS cleaning."""

    def _sanitize(self, text):
        from curator.review import _sanitize_markdown

        return _sanitize_markdown(text)

    def test_removes_script_tag(self):
        self.assertEqual(self._sanitize("<script>alert(1)</script>"), "")

    def test_removes_iframe(self):
        self.assertEqual(self._sanitize('<iframe src="x"></iframe>'), "")

    def test_removes_on_event_handlers(self):
        result = self._sanitize('<img src="ok.png" onerror="alert(1)">')
        self.assertNotIn("onerror", result)
        self.assertIn("ok.png", result)

    def test_removes_onclick(self):
        result = self._sanitize('<a href="#" onclick="steal()">link</a>')
        self.assertNotIn("onclick", result)

    def test_neutralizes_javascript_uri(self):
        result = self._sanitize('<a href="javascript:alert(1)">click</a>')
        self.assertNotIn("javascript:", result)
        self.assertIn("#sanitized:", result)

    def test_neutralizes_data_uri(self):
        result = self._sanitize('<img src="data:text/html,<script>x</script>">')
        self.assertNotIn("data:", result)

    def test_preserves_safe_content(self):
        safe = "# Hello\n\nSome **markdown** with [link](https://example.com)"
        self.assertEqual(self._sanitize(safe), safe)

    def test_empty_and_none(self):
        self.assertEqual(self._sanitize(""), "")
        self.assertIsNone(self._sanitize(None))


if __name__ == "__main__":
    unittest.main()
