#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""P1 Cognition Audit Tests — verifies all 5 P1 fixes are in place."""
import ast
import importlib
import inspect
import sqlite3
import sys
import textwrap
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))


# ---------------------------------------------------------------------------
# P1-1: Reward signal item ID plumbing
# ---------------------------------------------------------------------------

class TestRewardSignalItemIds:
    """P1-1: worker_execute.py must collect brain item IDs from brain_search,
    not from scraping FTS over trace text."""

    def test_initial_brain_item_ids_variable_exists(self):
        """Source must declare _initial_brain_item_ids before the tool loop."""
        src = (ROOT / "pulse/workers/runner/worker_execute.py").read_text(encoding="utf-8")
        assert "_initial_brain_item_ids" in src, (
            "_initial_brain_item_ids variable missing from worker_execute.py — "
            "reward signal P1-1 fix not applied"
        )

    def test_brain_search_import_for_ids(self):
        """Must import brain_search (not just tool_brain_query) to get structured IDs."""
        src = (ROOT / "pulse/workers/runner/worker_execute.py").read_text(encoding="utf-8")
        assert "brain_search" in src, (
            "brain_search import missing — P1-1 fix requires structured result with id fields"
        )

    def test_fts_scraping_heuristic_removed(self):
        """Old FTS scraping loop (iterating trace for brain_query tool events) must be gone."""
        src = (ROOT / "pulse/workers/runner/worker_execute.py").read_text(encoding="utf-8")
        # The old heuristic iterated over trace entries looking for tool == "brain_query"
        # and then ran FTS MATCH on content chunks.  That block is replaced.
        assert 'e.get("tool") != "brain_query"' not in src, (
            "Old FTS scraping heuristic still present — P1-1 fix incomplete"
        )

    def test_record_task_outcome_uses_initial_ids(self):
        """record_task_outcome must receive _initial_brain_item_ids, not retrieved_ids."""
        src = (ROOT / "pulse/workers/runner/worker_execute.py").read_text(encoding="utf-8")
        assert "_initial_brain_item_ids" in src
        # Old variable name should be gone
        assert "retrieved_ids" not in src, (
            "retrieved_ids still referenced — old FTS heuristic variable not fully removed"
        )

    def test_entry_id_field_collected(self):
        """ID collection must use entry_id or id field from brain_search hits."""
        src = (ROOT / "pulse/workers/runner/worker_execute.py").read_text(encoding="utf-8")
        assert 'entry_id' in src or '"id"' in src, (
            "entry_id / id field not referenced in ID collection loop"
        )


# ---------------------------------------------------------------------------
# P1-2: Episodic scoring cap
# ---------------------------------------------------------------------------

class TestEpisodicScoringCap:
    """P1-2: Episodic results must apply CONVERSATIONAL ground_weight (0.35)
    so they don't dominate lessons/patterns on a different scale."""

    def _get_src(self):
        return (ROOT / "pulse/brain/search_sources_episodic.py").read_text(encoding="utf-8")

    def test_conversational_weight_applied_sqlite_path(self):
        src = self._get_src()
        assert "_CONVERSATIONAL_WEIGHT" in src or "CONVERSATIONAL_WEIGHT" in src, (
            "CONVERSATIONAL_WEIGHT constant missing from search_sources_episodic.py"
        )

    def test_conversational_weight_value(self):
        """Weight must be 0.35 (matching SOURCE_WEIGHT['CONVERSATIONAL'])."""
        src = self._get_src()
        assert "0.35" in src, (
            "CONVERSATIONAL ground_weight 0.35 not found — episodic salience not capped correctly"
        )

    def test_salience_not_raw_10(self):
        """Raw `min(score * 0.8, 10.0)` must be multiplied by the weight, not returned raw."""
        src = self._get_src()
        # After the fix the pattern is: `min(score * 0.8, 10.0) * _CONVERSATIONAL_WEIGHT`
        assert "* _CONVERSATIONAL_WEIGHT" in src, (
            "Salience not multiplied by _CONVERSATIONAL_WEIGHT — cap not applied"
        )

    def test_both_paths_fixed(self):
        """Both SQLite and flat-file paths must apply the cap."""
        src = self._get_src()
        # Count occurrences — should appear in both _search_episodic_sqlite and _search_episodic_flatfile
        count = src.count("_CONVERSATIONAL_WEIGHT")
        assert count >= 2, (
            f"_CONVERSATIONAL_WEIGHT appears {count} time(s) — both SQLite and flat-file paths must be fixed"
        )

    def test_episodic_salience_bounded(self):
        """Integration: computed salience must be <= 1.0 for max-score input."""
        # score=3.5 gives raw salience = min(3.5*0.8, 10.0) = 2.8; * 0.35 = 0.98 < 1.0
        raw_score = 3.5  # entity match (3.0) + 1 keyword overlap (0.5)
        _CONVERSATIONAL_WEIGHT = 0.35
        salience = round(min(raw_score * 0.8, 10.0) * _CONVERSATIONAL_WEIGHT, 3)
        assert salience <= 1.0, f"Episodic salience {salience} exceeds 1.0 cap"


# ---------------------------------------------------------------------------
# P1-3: anti_patterns validity + access_count columns
# ---------------------------------------------------------------------------

class TestAntiPatternsSchema:
    """P1-3: anti_patterns table must have validity and access_count columns."""

    def _get_src(self):
        return (ROOT / "pulse/core/brain_db.py").read_text(encoding="utf-8")

    def test_validity_in_create_table(self):
        src = self._get_src()
        # Find the anti_patterns CREATE TABLE block
        start = src.find("CREATE TABLE IF NOT EXISTS anti_patterns")
        assert start != -1, "anti_patterns CREATE TABLE not found in brain_db.py"
        block_end = src.find(");", start)
        block = src[start:block_end]
        assert "validity" in block, (
            "validity column missing from anti_patterns CREATE TABLE"
        )

    def test_access_count_in_create_table(self):
        src = self._get_src()
        start = src.find("CREATE TABLE IF NOT EXISTS anti_patterns")
        block_end = src.find(");", start)
        block = src[start:block_end]
        assert "access_count" in block, (
            "access_count column missing from anti_patterns CREATE TABLE"
        )

    def test_validity_migration_present(self):
        """ALTER TABLE migration must exist for existing DBs without the columns."""
        src = self._get_src()
        assert "anti_patterns ADD COLUMN" in src or (
            "anti_patterns" in src and "validity" in src and "ALTER" in src
        ), "No ALTER TABLE migration for anti_patterns.validity found in brain_db.py"

    def test_access_count_migration_present(self):
        src = self._get_src()
        assert "access_count" in src, (
            "access_count migration not present in brain_db.py"
        )

    def test_in_memory_db_has_columns(self):
        """Create an in-memory DB with the schema and verify columns exist."""
        conn = sqlite3.connect(":memory:")
        conn.execute("""
            CREATE TABLE IF NOT EXISTS anti_patterns (
                id TEXT PRIMARY KEY, description TEXT NOT NULL, rule TEXT,
                reason TEXT, domain TEXT DEFAULT 'general', keywords TEXT DEFAULT '[]',
                severity TEXT DEFAULT 'medium', evidence_count INTEGER DEFAULT 1,
                status TEXT DEFAULT 'active', source TEXT, created TEXT,
                metadata TEXT DEFAULT '{}',
                validity TEXT DEFAULT 'valid',
                access_count INTEGER DEFAULT 0
            )
        """)
        cursor = conn.execute("PRAGMA table_info(anti_patterns)")
        cols = {row[1] for row in cursor.fetchall()}
        assert "validity" in cols, "validity column not in in-memory anti_patterns table"
        assert "access_count" in cols, "access_count column not in in-memory anti_patterns table"
        conn.close()

    def test_validity_default_is_valid(self):
        """Default value for validity must be 'valid' (not null or empty)."""
        src = self._get_src()
        # Check for DEFAULT 'valid' near validity
        idx = src.find("validity TEXT DEFAULT")
        assert idx != -1, "validity column with DEFAULT not found"
        snippet = src[idx:idx+40]
        assert "'valid'" in snippet, f"validity DEFAULT not 'valid': {snippet!r}"


# ---------------------------------------------------------------------------
# P1-4: STDP exponential temporal decay
# ---------------------------------------------------------------------------

class TestSTDPExponentialDecay:
    """P1-4: STDP must apply exponential decay based on time between retrievals."""

    def _get_src(self):
        return (ROOT / "pulse/brain/stdp.py").read_text(encoding="utf-8")

    def test_math_imported(self):
        src = self._get_src()
        assert "import math" in src, "math not imported in stdp.py — exp() unavailable"

    def test_exp_call_present(self):
        src = self._get_src()
        assert "math.exp" in src, (
            "math.exp() not found in stdp.py — exponential decay not implemented"
        )

    def test_time_constant_30(self):
        """Time constant must be 30.0 seconds as specified."""
        src = self._get_src()
        assert "30.0" in src or "/ 30" in src, (
            "Time constant 30.0s not found in stdp.py — decay formula incorrect"
        )

    def test_decay_formula_shape(self):
        """Decay: exp(-delta_s / 30.0) — items 0s apart get full delta, 120s get ~2%."""
        src = self._get_src()
        assert "delta_s" in src and "exp" in src, (
            "delta_s and exp not both present — decay formula missing or renamed"
        )

    def test_exponential_decay_math(self):
        """Unit test: verify exponential decay formula gives expected values."""
        import math
        time_constant = 30.0
        ltp = 0.08

        # At delta=0: full signal
        assert abs(ltp * math.exp(0 / time_constant) - ltp) < 1e-9

        # At delta=30s: ~36.8% of full signal
        decayed_30 = ltp * math.exp(-30.0 / time_constant)
        assert abs(decayed_30 - ltp * 0.368) < 0.001, f"30s decay: {decayed_30}"

        # At delta=120s: ~2% of full signal (4 time constants)
        decayed_120 = ltp * math.exp(-120.0 / time_constant)
        assert decayed_120 < ltp * 0.025, f"120s should be <2.5% of full delta, got {decayed_120}"

    def test_causal_delta_uses_decay(self):
        """causal_delta must be the decayed value, not the raw LTP_DELTA constant."""
        src = self._get_src()
        # The fix replaces: causal_delta = LTP_DELTA if success else LTD_DELTA
        # with: raw_delta = ...; causal_delta = raw_delta * math.exp(...)
        assert "raw_delta" in src or "math.exp(-delta_s" in src, (
            "causal_delta not computed with exponential decay — P1-4 fix incomplete"
        )


# ---------------------------------------------------------------------------
# P1-5: Thompson Sampling exploration
# ---------------------------------------------------------------------------

class TestThompsonSampling:
    """P1-5: should_rotate must use Beta distribution Thompson Sampling."""

    def _get_src(self):
        return (ROOT / "pulse/tasks/pulse_dispatcher.py").read_text(encoding="utf-8")

    def test_betavariate_present(self):
        src = self._get_src()
        assert "betavariate" in src, (
            "betavariate not found in pulse_dispatcher.py — Thompson Sampling not implemented"
        )

    def test_should_rotate_accepts_model_domain(self):
        """should_rotate must accept model and domain args (not zero-arg)."""
        src = self._get_src()
        # Signature should include model and domain params
        assert "def should_rotate(model" in src or "def should_rotate(model:" in src, (
            "should_rotate does not accept model parameter — Thompson Sampling needs model+domain"
        )

    def test_hebbian_balancer_import(self):
        """Must import get_model_fitness from hebbian_balancer."""
        src = self._get_src()
        assert "get_model_fitness" in src, (
            "get_model_fitness import missing — Thompson Sampling has no fitness data source"
        )

    def test_fallback_to_uniform(self):
        """Must fall back to uniform 10% if Hebbian data unavailable."""
        src = self._get_src()
        assert "random.random() < 0.10" in src, (
            "Fallback to uniform 10% missing — should_rotate fragile without Hebbian data"
        )

    def test_explore_threshold_03(self):
        """Explore threshold must be 0.3 (explore if sampled quality is low)."""
        src = self._get_src()
        assert "< 0.3" in src, (
            "Exploration threshold 0.3 not found — Thompson Sampling threshold wrong"
        )

    def test_thompson_sampling_math(self):
        """Integration: Beta(10, 1) (high success) should rarely explore."""
        import random
        random.seed(42)
        explorations = sum(
            1 for _ in range(1000)
            if random.betavariate(10, 1) < 0.3
        )
        # Beta(10,1) mean=0.91 — should explore rarely (<5%)
        assert explorations < 50, (
            f"Beta(10,1) explored {explorations}/1000 times — high-fitness model over-explores"
        )

    def test_thompson_sampling_explores_weak_model(self):
        """Beta(1, 10) (mostly failures) should explore frequently."""
        import random
        random.seed(42)
        explorations = sum(
            1 for _ in range(1000)
            if random.betavariate(1, 10) < 0.3
        )
        # Beta(1,10) mean=0.09 — almost always below 0.3
        assert explorations > 800, (
            f"Beta(1,10) only explored {explorations}/1000 — weak model not rotating enough"
        )
