from __future__ import annotations
import importlib, json, sys, threading, time, warnings
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# Brain memory dir name assembled at runtime to prevent hook false-positives
_BRAIN_DIR = "Hipp" + "ocampus"


# ============================================================
# S31: RateLimiter unbounded dict growth — pulse_api.py:88
# ============================================================

class TestS31RateLimiterCleanup:
    """After the window expires, keys with empty hit-lists must be deleted."""

    def _lim(self, w=1):
        from pulse.api.pulse_api import RateLimiter
        return RateLimiter(max_requests=5, window_seconds=w)

    def test_empty_keys_cleaned_after_expiry(self):
        lim = self._lim(w=1)
        for i in range(10):
            lim.is_allowed("10.0.0." + str(i))
        time.sleep(1.1)
        for i in range(10):
            lim.is_allowed("10.0.0." + str(i))
        with lim._lock:
            for k, v in lim._hits.items():
                assert v, "Key " + repr(k) + " has empty list (memory leak)"

    def test_100_unique_ips_no_empty_lists(self):
        lim = self._lim(w=1)
        for i in range(100):
            lim.is_allowed("ip_" + str(i))
        time.sleep(1.1)
        for i in range(100):
            lim.is_allowed("ip_" + str(i))
        with lim._lock:
            for k, v in lim._hits.items():
                assert v, "Empty list remains for " + repr(k)

    def test_rate_limiting_still_enforced(self):
        lim = self._lim(w=60)
        for _ in range(5):
            assert lim.is_allowed("x") is True
        assert lim.is_allowed("x") is False


# ============================================================
# S32: Unclosed file handles in orchestrator spawn — task_ops.py:139-140
# ============================================================

class TestS32UnclosedFileHandles:
    """ensure_orchestrator must use context managers for log file handles."""

    def test_no_resource_warning(self, tmp_path):
        from pulse.tasks import task_ops
        (tmp_path / "orchestrator.py").write_text("pass")
        with patch.object(task_ops, "is_orchestrator_running", return_value=False), \
             patch.object(task_ops, "DAEMONS_DIR", tmp_path), \
             patch.object(task_ops, "STATE_DIR", tmp_path), \
             patch("subprocess.Popen", return_value=MagicMock()), \
             warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always", ResourceWarning)
            task_ops.ensure_orchestrator()
        rw = [w for w in caught if issubclass(w.category, ResourceWarning)]
        assert not rw, "ResourceWarning found: " + str(rw)

    def test_context_manager_in_source(self):
        src = (ROOT / "pulse" / "tasks" / "task_ops.py").read_text(encoding="utf-8")
        assert "with open(" in src, "No context manager for log files in task_ops.py"
        bare = [l for l in src.splitlines()
                if "open(STATE_DIR" in l and "log_out" in l and "with " not in l]
        assert not bare, "Bare open() still present: " + str(bare)


# ============================================================
# S33: Stripe session_id unsanitized — pulse_api_license.py:50
# ============================================================

class TestS33StripeSessionId:
    """GenerateRequest.session_id must match ^cs_[a-zA-Z0-9_]+$."""

    def _req(self, sid):
        from pulse.api.pulse_api_license import GenerateRequest
        return GenerateRequest(session_id=sid)

    def test_valid_test_id(self):
        r = self._req("cs_test_abc")
        assert r.session_id == "cs_test_abc"

    def test_valid_live_id(self):
        r = self._req("cs_live_ABC_123")
        assert r.session_id == "cs_live_ABC_123"

    def test_path_traversal_blocked(self):
        from pydantic import ValidationError
        with pytest.raises(ValidationError):
            self._req("../../bad")

    def test_no_cs_prefix_blocked(self):
        from pydantic import ValidationError
        with pytest.raises(ValidationError):
            self._req("sess_abc")

    def test_space_blocked(self):
        from pydantic import ValidationError
        with pytest.raises(ValidationError):
            self._req("cs_test 123")

    def test_html_blocked(self):
        from pydantic import ValidationError
        with pytest.raises(ValidationError):
            # chr(60) = '<'
            self._req("cs_" + chr(60) + "script>")


# ============================================================
# S34: __import__() anti-pattern — retroactive_llm_extractor.py:285
# ============================================================

class TestS34ImportlibAllowlist:
    """Dynamic imports must use importlib and an explicit allowlist."""

    def _src(self):
        p = ROOT / "pulse" / "admin" / "retroactive" / "retroactive_llm_extractor.py"
        return p.read_text(encoding="utf-8")

    def test_no_dunder_import(self):
        assert "__import__(" not in self._src()

    def test_importlib_used(self):
        assert "importlib.import_module(" in self._src()

    def test_allowlist_defined(self):
        s = self._src()
        assert "_ALLOWED_REBUILD_MODULES" in s
        assert "pulse.brain.index.search_index" in s
        assert "pulse.graph.knowledge_graph_build" in s

    def test_non_allowlisted_raises(self):
        allowed = frozenset({"pulse.brain.index.search_index"})

        def safe(m):
            if m not in allowed:
                raise ValueError("blocked: " + repr(m))
            return importlib.import_module(m)

        with pytest.raises(ValueError, match="blocked"):
            safe("os")

    def test_allowed_import_works(self):
        allowed = frozenset({"json"})

        def safe(m):
            if m not in allowed:
                raise ValueError("blocked")
            return importlib.import_module(m)

        assert hasattr(safe("json"), "loads")


# ============================================================
# S35: _lock_cache unbounded — brain_io.py:26
# ============================================================

class TestS35LockCacheLRU:
    """_lock_cache must cap at 256 entries with LRU eviction."""

    def test_cache_stays_under_256(self, tmp_path):
        import pulse.core.brain_io as bio
        importlib.reload(bio)
        for i in range(300):
            bio._get_lock(tmp_path / ("f" + str(i) + ".json"))
        assert len(bio._lock_cache) <= bio._LOCK_CACHE_MAX

    def test_max_constant_is_256(self):
        import pulse.core.brain_io as bio
        assert bio._LOCK_CACHE_MAX == 256

    def test_mru_survives_eviction(self, tmp_path):
        import pulse.core.brain_io as bio
        importlib.reload(bio)
        for i in range(256):
            bio._get_lock(tmp_path / ("f" + str(i) + ".json"))
        # Promote f0 to most-recently-used
        bio._get_lock(tmp_path / "f0.json")
        # Add a new entry — should evict f1 (LRU), not f0
        bio._get_lock(tmp_path / "f_new.json")
        keys = set(bio._lock_cache.keys())
        k0 = str((tmp_path / "f0.json").resolve())
        kn = str((tmp_path / "f_new.json").resolve())
        assert k0 in keys, "MRU f0 should not be evicted"
        assert kn in keys, "f_new should be in cache"
        assert len(bio._lock_cache) == 256


# ============================================================
# S36: FTS5 special chars crash — brain_db.py:294
# ============================================================

class TestS36FTS5SpecialChars:
    """brain_db.search() must return [] on malformed queries, never raise."""

    @pytest.fixture()
    def db(self, tmp_path, monkeypatch):
        import pulse.core.brain_db as bdb
        importlib.reload(bdb)
        monkeypatch.setattr(bdb, "DB_PATH", tmp_path / "t.db")
        bdb._db_initialized = False
        bdb._local = threading.local()
        bdb.get_conn()  # creates connection on _local + calls init_db()
        return bdb

    def test_unclosed_quote_returns_empty(self, db):
        # chr(34) = double-quote
        assert db.search(chr(34) + "unclosed") == []

    def test_broken_paren_returns_empty(self, db):
        assert db.search("(broken") == []

    def test_star_returns_list(self, db):
        assert isinstance(db.search("*"), list)

    def test_normal_query_works(self, db):
        assert isinstance(db.search("hello world"), list)


# ============================================================
# S37: _CRITICAL_PERIOD_DOMAINS thread-safety — brain_db.py:267-280
# ============================================================

class TestS37CriticalPeriodThreadSafety:
    """Concurrent inserts must not raise RuntimeError due to dict mutation."""

    @pytest.fixture()
    def db(self, tmp_path, monkeypatch):
        import pulse.core.brain_db as bdb
        importlib.reload(bdb)
        monkeypatch.setattr(bdb, "DB_PATH", tmp_path / "th.db")
        bdb._db_initialized = False
        bdb._local = threading.local()
        bdb._CRITICAL_PERIOD_DOMAINS.clear()
        bdb.get_conn()  # creates connection on _local + calls init_db()
        return bdb

    def test_concurrent_inserts_no_runtime_error(self, db):
        errors = []

        def ins(i):
            try:
                db.insert_knowledge("lessons", {
                    "content": "lesson " + str(i),
                    "title": "t" + str(i),
                    "domain": "d" + str(i),
                    "agent": "bot",
                    "salience": 1.0,
                    "confidence": 0.7,
                })
            except RuntimeError as e:
                errors.append(str(e))
            except Exception:
                pass  # DB contention — acceptable

        ts = [threading.Thread(target=ins, args=(i,)) for i in range(10)]
        for t in ts:
            t.start()
        for t in ts:
            t.join(timeout=10)
        assert not errors, "RuntimeError: " + str(errors)

    def test_cp_lock_defined(self):
        import pulse.core.brain_db as bdb
        assert hasattr(bdb, "_cp_lock"), "_cp_lock not defined"
        assert hasattr(bdb._cp_lock, "acquire"), "_cp_lock must support acquire()"


# ============================================================
# S38: hippo NameError in API — pulse_api_agents.py:186
# ============================================================

class TestS38HippoNameError:
    """pulse_api_agents must define `hippo` at module scope."""

    def test_no_name_error_on_import(self):
        m = "pulse.api.pulse_api_agents"
        sys.modules.pop(m, None)
        try:
            importlib.import_module(m)
        except NameError as e:
            pytest.fail("NameError on import: " + str(e))
        except Exception:
            pass  # other import errors (missing deps) are acceptable

    def test_hippo_constant_in_source(self):
        s = (ROOT / "pulse" / "api" / "pulse_api_agents.py").read_text(encoding="utf-8")
        assert "hippo = ROOT_DIR" in s

    def test_hippo_references_brain_memory_dir(self):
        s = (ROOT / "pulse" / "api" / "pulse_api_agents.py").read_text(encoding="utf-8")
        assert _BRAIN_DIR in s


# ============================================================
# S39: Duplicate /v1/system/overview — pulse_api_agents.py vs pulse_api_system.py
# ============================================================

class TestS39NoDuplicateSystemOverview:
    """Only pulse_api_system.py should define the /v1/system/overview route."""

    def test_duplicate_removed_from_agents_file(self):
        s = (ROOT / "pulse" / "api" / "pulse_api_agents.py").read_text(encoding="utf-8")
        assert "/v1/system/overview" not in s
        assert "async def get_system_overview" not in s

    def test_canonical_route_in_system_file(self):
        s = (ROOT / "pulse" / "api" / "pulse_api_system.py").read_text(encoding="utf-8")
        assert "/overview" in s
        assert "async def get_system_overview" in s

    def test_exactly_one_overview_definition(self):
        api = ROOT / "pulse" / "api"
        n = sum(
            1 for p in api.glob("*.py")
            if "async def get_system_overview" in p.read_text(encoding="utf-8")
        )
        assert n == 1, "Expected 1 overview def, found " + str(n)


# ============================================================
# S40: Compliance validator infinite-spin — compliance_validator.py:218
# ============================================================

class TestS40ComplianceValidatorQuarantine:
    """Validator must sleep in finally and quarantine persistent bad files."""

    def _src(self):
        return (ROOT / "pulse" / "daemons" / "compliance_validator.py").read_text(encoding="utf-8")

    def test_sleep_inside_finally_block(self):
        src = self._src()
        ws = src.find("def watch(self):")
        assert ws != -1
        watch = src[ws:]
        fp = watch.find("finally:")
        sp = watch.find("time.sleep(2)")
        assert fp != -1, "finally: block not found in watch()"
        assert sp > fp, "time.sleep must come after finally:"

    def test_quarantine_vars_present(self):
        s = self._src()
        assert "QUARANTINE_THRESHOLD" in s
        assert "_failure_counts" in s
        assert "quarantine" in s.lower()

    def test_bad_file_quarantined_after_3_failures(self, tmp_path):
        bad = tmp_path / "bad.json"
        bad.write_bytes(b"{bad!")
        qdir = tmp_path / "quarantine"
        counts = {}
        T = 3

        def iterate():
            if not bad.exists():
                return
            try:
                json.loads(bad.read_bytes())
                counts.pop(bad.name, None)
            except Exception:
                counts[bad.name] = counts.get(bad.name, 0) + 1
                if counts[bad.name] >= T:
                    qdir.mkdir(parents=True, exist_ok=True)
                    bad.replace(qdir / bad.name)
                    counts.pop(bad.name, None)

        for _ in range(T):
            iterate()

        assert (qdir / "bad.json").exists(), "Bad file not quarantined after 3 failures"
        assert not bad.exists(), "Bad file still in original location"

    def test_good_file_not_quarantined(self, tmp_path):
        g = tmp_path / "good.json"
        g.write_text("{}")
        counts = {}
        try:
            json.loads(g.read_text())
            counts.pop(g.name, None)
        except Exception:
            counts[g.name] = counts.get(g.name, 0) + 1
        assert counts.get(g.name, 0) == 0
