#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
import json, sqlite3, subprocess, sys, tempfile, time, unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import pulse.brain.integrity.anti_pattern_gate as G


def _make_db(anti_patterns=(), failures=(), decisions=(), graph_nodes=(), graph_edges=()):
    tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
    tmp.close()
    db = Path(tmp.name)
    conn = sqlite3.connect(str(db))
    conn.execute(
        "CREATE TABLE anti_patterns (id TEXT PRIMARY KEY, description TEXT, rule TEXT,"
        " evidence_count INTEGER DEFAULT 1, confidence REAL DEFAULT 0.5,"
        " domain TEXT DEFAULT 'general', status TEXT DEFAULT 'active',"
        " source_type TEXT DEFAULT 'INFERRED')"
    )
    conn.execute(
        "CREATE VIRTUAL TABLE knowledge_fts USING fts5"
        "(item_id UNINDEXED, content, title, domain, agent, table_name)"
    )
    conn.execute(
        "CREATE TABLE failures (id TEXT PRIMARY KEY, content TEXT,"
        " confidence REAL DEFAULT 0.5, source_type TEXT DEFAULT 'INFERRED')"
    )
    conn.execute(
        "CREATE TABLE decisions (id TEXT PRIMARY KEY, content TEXT,"
        " source_type TEXT DEFAULT 'INFERRED', validity TEXT DEFAULT 'valid')"
    )
    conn.execute("CREATE TABLE graph_nodes (id INTEGER PRIMARY KEY, text TEXT, type TEXT, confidence REAL)")
    conn.execute("CREATE TABLE graph_edges (id INTEGER PRIMARY KEY, from_node INTEGER, to_node INTEGER, edge_type TEXT, confidence REAL)")
    for ap in anti_patterns:
        conn.execute(
            "INSERT INTO anti_patterns (id,description,rule,evidence_count,confidence,domain,status)"
            " VALUES (?,?,?,?,?,?,?)",
            (ap['id'], ap.get('description', ''), ap.get('rule', ''),
             ap.get('evidence_count', 1), ap.get('confidence', 0.5),
             ap.get('domain', 'general'), ap.get('status', 'active')),
        )
        conn.execute(
            "INSERT INTO knowledge_fts(item_id,content,title,domain,agent,table_name) VALUES (?,?,?,?,?,?)",
            (ap['id'], ap.get('description', ''), ap.get('rule', ''),
             ap.get('domain', 'general'), '', 'anti_patterns'),
        )
    for f in failures:
        conn.execute(
            "INSERT INTO failures(id,content,confidence,source_type) VALUES (?,?,?,?)",
            (f['id'], f.get('content', ''), f.get('confidence', 0.5), f.get('source_type', 'INFERRED')),
        )
    for d in decisions:
        conn.execute(
            "INSERT INTO decisions(id,content,source_type,validity) VALUES (?,?,?,?)",
            (d['id'], d.get('content', ''), d.get('source_type', 'INFERRED'), d.get('validity', 'valid')),
        )
    nid = 1; nmap = {}
    for n in graph_nodes:
        conn.execute("INSERT INTO graph_nodes(id,text,type,confidence) VALUES (?,?,?,?)",
                     (nid, n['text'], n.get('type', 'concept'), n.get('confidence', 0.9)))
        nmap[n['text']] = nid; nid += 1
    for e in graph_edges:
        conn.execute("INSERT INTO graph_edges(from_node,to_node,edge_type,confidence) VALUES (?,?,?,?)",
                     (nmap[e['from']], nmap[e['to']], e['edge_type'], e.get('confidence', 0.9)))
    conn.commit(); conn.close(); return db


def _run_gate(stdin_data, timeout=5.0):
    return subprocess.run(
        [sys.executable, str(ROOT / 'pulse' / 'brain' / 'integrity' / 'anti_pattern_gate.py')],
        input=stdin_data, capture_output=True, text=True,
        timeout=timeout, encoding='utf-8', errors='replace', cwd=str(ROOT),
    )


# ---------------------------------------------------------------------------
# Gate 1: Anti-pattern FTS5
# ---------------------------------------------------------------------------

class TestGate1AntiPattern(unittest.TestCase):

    def test_blocks_high_conf_code_prescriptive_match(self):
        db = _make_db(anti_patterns=[{
            'id': 'AP-G1-01',
            'description': 'Never write to hippocampus from worker_tools',
            'rule': 'NEVER write to hippocampus from worker_tools without isolation',
            'confidence': 0.85, 'evidence_count': 5,
        }])
        try:
            code = 'def persist(data):\n    hippocampus_path=get_store()\n    worker_tools.write_direct(hippocampus_path,data)\n'
            event = {'tool_input': {'file_path': 'pulse/workers/bad.py', 'new_string': code}}
            real_qap = G._query_anti_patterns  # capture before patch to avoid recursion
            with (
                patch.object(G, '_query_anti_patterns', side_effect=lambda p, kw: real_qap(db, kw)),
                patch.object(G, '_gate2_failure_scan', return_value=(False, None)),
                patch.object(G, '_gate3_prediction_alert', return_value=(False, None)),
                patch.object(G, '_gate4_decision_conflict', return_value=(False, None)),
            ):
                blocked, msg, adv = G._check_gate(event)
            self.assertTrue(blocked)
            self.assertIn('BRAIN BLOCK', msg)
            self.assertIn('Anti-pattern', msg)
        finally:
            db.unlink(missing_ok=True)

    def test_passes_clean_write(self):
        db = _make_db(anti_patterns=[{
            'id': 'AP-G1-02',
            'description': 'Never write to hippocampus from worker_tools',
            'rule': 'NEVER write to hippocampus from worker_tools without isolation',
            'confidence': 0.85, 'evidence_count': 5,
        }])
        try:
            event = {'tool_input': {'file_path': 'pulse/api/pulse_api.py', 'content': 'import fastapi'}}
            with (
                patch.object(G, '_query_anti_patterns', side_effect=lambda p, kw: G._query_anti_patterns(db, kw)),
                patch.object(G, '_gate2_failure_scan', return_value=(False, None)),
                patch.object(G, '_gate3_prediction_alert', return_value=(False, None)),
                patch.object(G, '_gate4_decision_conflict', return_value=(False, None)),
            ):
                blocked, msg, adv = G._check_gate(event)
            self.assertFalse(blocked)
        finally:
            db.unlink(missing_ok=True)

    def test_behavioral_rule_does_not_block(self):
        db = _make_db(anti_patterns=[{
            'id': 'AP-G1-03',
            'description': 'Dont upgrade working code',
            'rule': 'NEVER: Dont upgrade working code without approval',
            'confidence': 0.95, 'evidence_count': 20,
        }])
        try:
            results = G._query_anti_patterns(db, ['upgrade', 'approval', 'working'])
            self.assertEqual(results, [])
        finally:
            db.unlink(missing_ok=True)

    def test_fails_open_on_bad_db_path(self):
        result = G._gate1_anti_pattern('content', ['hippocampus'], Path('/bad/path.db'))
        self.assertEqual(result, (False, None))


# ---------------------------------------------------------------------------
# Gate 2: High-confidence failure scan
# ---------------------------------------------------------------------------

class TestGate2FailureScan(unittest.TestCase):
    _KW = 'distillation'
    _CONTENT = 'distillation pipeline crashed due to missing validation'

    def test_blocks_on_high_conf_failure_keyword_match(self):
        db = _make_db(failures=[{'id': 'F-G2-01', 'content': self._CONTENT, 'confidence': 0.95, 'source_type': 'INFERRED'}])
        try:
            blocked, raw = G._gate2_failure_scan([self._KW], db)
            self.assertTrue(blocked)
            # _gate2_failure_scan returns raw failure content; BRAIN BLOCK msg assembled in _check_gate
            self.assertIsNotNone(raw)
            self.assertIn(self._KW, raw)
        finally:
            db.unlink(missing_ok=True)

    def test_does_not_block_below_threshold(self):
        db = _make_db(failures=[{'id': 'F-G2-02', 'content': self._CONTENT, 'confidence': 0.80, 'source_type': 'INFERRED'}])
        try:
            blocked, _ = G._gate2_failure_scan([self._KW], db)
            self.assertFalse(blocked)
        finally:
            db.unlink(missing_ok=True)

    def test_skips_contested_source(self):
        db = _make_db(failures=[{'id': 'F-G2-03', 'content': self._CONTENT, 'confidence': 0.99, 'source_type': 'CONTESTED'}])
        try:
            blocked, _ = G._gate2_failure_scan([self._KW], db)
            self.assertFalse(blocked)
        finally:
            db.unlink(missing_ok=True)

    def test_short_keywords_never_match(self):
        db = _make_db(failures=[{
            'id': 'F-G2-04',
            'content': 'process cannot access the file because it is being used',
            'confidence': 0.99, 'source_type': 'INFERRED',
        }])
        try:
            blocked, _ = G._gate2_failure_scan(['process', 'access', 'file'], db)
            self.assertFalse(blocked)
        finally:
            db.unlink(missing_ok=True)

    def test_fails_open_on_exception(self):
        blocked, msg = G._gate2_failure_scan([self._KW], Path('/nonexistent/brain.db'))
        self.assertFalse(blocked)
        self.assertIsNone(msg)


# ---------------------------------------------------------------------------
# Gate 3: Oracle prediction alerts
# ---------------------------------------------------------------------------

class TestGate3PredictionAlert(unittest.TestCase):

    def test_blocks_on_active_alert_conf_09_file_path_match(self):
        with patch.object(G, '_load_prediction_alerts', return_value=[{
            'prediction': 'gate rule embeddings may misfire on legitimate code changes',
            'confidence': 0.92, 'status': 'active',
            'risk_components': ['anti_pattern_gate.py'],
        }]):
            blocked, raw = G._gate3_prediction_alert('pulse/brain/anti_pattern_gate.py', ['distillation'])
        self.assertTrue(blocked)
        # _gate3 returns raw prediction text; BRAIN BLOCK msg assembled in _check_gate
        self.assertIsNotNone(raw)
        self.assertIn('gate', raw.lower())

    def test_blocks_on_keyword_overlap_in_prediction_text(self):
        with patch.object(G, '_load_prediction_alerts', return_value=[{
            'prediction': 'hippocampus distillation pipeline will corrupt on concurrent writes',
            'confidence': 0.91, 'status': 'active', 'risk_components': [],
        }]):
            blocked, _ = G._gate3_prediction_alert('pulse/brain/something.py', ['distillation'])
        self.assertTrue(blocked)

    def test_does_not_block_below_conf_threshold(self):
        with patch.object(G, '_load_prediction_alerts', return_value=[{
            'prediction': 'distillation pipeline may fail',
            'confidence': 0.85, 'status': 'active', 'risk_components': [],
        }]):
            blocked, _ = G._gate3_prediction_alert('pulse/brain/foo.py', ['distillation'])
        self.assertFalse(blocked)

    def test_does_not_block_inactive_alert(self):
        with patch.object(G, '_load_prediction_alerts', return_value=[{
            'prediction': 'distillation pipeline will fail catastrophically',
            'confidence': 0.99, 'status': 'resolved', 'risk_components': [],
        }]):
            blocked, _ = G._gate3_prediction_alert('pulse/brain/foo.py', ['distillation'])
        self.assertFalse(blocked)

    def test_fails_open_on_exception(self):
        with patch.object(G, '_load_prediction_alerts', side_effect=RuntimeError('boom')):
            blocked, msg = G._gate3_prediction_alert('pulse/brain/foo.py', ['distillation'])
        self.assertFalse(blocked)
        self.assertIsNone(msg)


# ---------------------------------------------------------------------------
# Gate 4: Decision conflict check
# ---------------------------------------------------------------------------

class TestGate4DecisionConflict(unittest.TestCase):

    def test_blocks_on_contested_source_type(self):
        db = _make_db(decisions=[{
            'id': 'D-G4-01',
            'content': 'hippocampus must never be written from distillation pipeline directly',
            'source_type': 'CONTESTED', 'validity': 'valid',
        }])
        try:
            blocked, raw = G._gate4_decision_conflict(['hippocampus', 'distillation'], db)
            self.assertTrue(blocked)
            # _gate4 returns raw decision content; BRAIN BLOCK msg assembled in _check_gate
            self.assertIsNotNone(raw)
            self.assertIn('hippocampus', raw.lower())
        finally:
            db.unlink(missing_ok=True)

    def test_blocks_on_validity_contested(self):
        db = _make_db(decisions=[{
            'id': 'D-G4-02',
            'content': 'distillation pipeline validates before writing hippocampus entries',
            'source_type': 'INFERRED', 'validity': 'contested',
        }])
        try:
            blocked, _ = G._gate4_decision_conflict(['distillation'], db)
            self.assertTrue(blocked)
        finally:
            db.unlink(missing_ok=True)

    def test_does_not_block_valid_decision(self):
        db = _make_db(decisions=[{
            'id': 'D-G4-03',
            'content': 'distillation pipeline validates hippocampus entries properly',
            'source_type': 'INFERRED', 'validity': 'valid',
        }])
        try:
            blocked, _ = G._gate4_decision_conflict(['distillation'], db)
            self.assertFalse(blocked)
        finally:
            db.unlink(missing_ok=True)

    def test_fails_open_on_exception(self):
        blocked, msg = G._gate4_decision_conflict(['distillation'], Path('/nonexistent/brain.db'))
        self.assertFalse(blocked)
        self.assertIsNone(msg)


# ---------------------------------------------------------------------------
# Gate 5: Temporal prerequisite (advisory only)
# ---------------------------------------------------------------------------

class TestGate5TemporalAdvisory(unittest.TestCase):

    def test_returns_prerequisites_as_list(self):
        db = _make_db(
            graph_nodes=[
                {'text': 'hippocampus initialisation must run first'},
                {'text': 'distillation pipeline setup step'},
            ],
            graph_edges=[{
                'from': 'hippocampus initialisation must run first',
                'to': 'distillation pipeline setup step',
                'edge_type': 'TEMPORAL', 'confidence': 0.9,
            }],
        )
        try:
            advisories = G._gate5_temporal_advisory(['distillation'], db)
            self.assertIsInstance(advisories, list)
        finally:
            db.unlink(missing_ok=True)

    def test_never_blocks_in_check_gate(self):
        event = {'tool_input': {'file_path': 'pulse/brain/distillation.py', 'new_string': 'def run_distillation_pipeline():pass'}}
        with (
            patch.object(G, '_gate1_anti_pattern', return_value=(False, None)),
            patch.object(G, '_gate2_failure_scan', return_value=(False, None)),
            patch.object(G, '_gate3_prediction_alert', return_value=(False, None)),
            patch.object(G, '_gate4_decision_conflict', return_value=(False, None)),
            patch.object(G, '_gate5_temporal_advisory', return_value=['hippocampus init is a prerequisite']),
        ):
            blocked, msg, adv = G._check_gate(event)
        self.assertFalse(blocked, 'Gate 5 must NEVER block')
        self.assertIsNone(msg)
        self.assertEqual(adv, ['hippocampus init is a prerequisite'])

    def test_returns_empty_on_no_temporal_edges(self):
        db = _make_db()
        try:
            self.assertEqual(G._gate5_temporal_advisory(['distillation'], db), [])
        finally:
            db.unlink(missing_ok=True)

    def test_fails_open_on_exception(self):
        result = G._gate5_temporal_advisory(['distillation'], Path('/bad/path.db'))
        self.assertEqual(result, [])


# ---------------------------------------------------------------------------
# Fail-open: every gate swallows exceptions
# ---------------------------------------------------------------------------

class TestFailOpen(unittest.TestCase):

    def _event(self):
        return {'tool_input': {'new_string': 'hippocampus_distillation_pipeline()'}}

    def test_gate1_fails_open_on_exception(self):
        with patch.object(G, '_query_anti_patterns', side_effect=Exception('boom')):
            blocked, _, _ = G._check_gate(self._event())
        self.assertFalse(blocked)

    def test_gate2_fails_open_on_exception(self):
        with (
            patch.object(G, '_gate1_anti_pattern', return_value=(False, None)),
            patch.object(G, '_gate2_failure_scan', side_effect=Exception('boom')),
        ):
            blocked, _, _ = G._check_gate(self._event())
        self.assertFalse(blocked)

    def test_gate3_fails_open_on_exception(self):
        with (
            patch.object(G, '_gate1_anti_pattern', return_value=(False, None)),
            patch.object(G, '_gate2_failure_scan', return_value=(False, None)),
            patch.object(G, '_gate3_prediction_alert', side_effect=Exception('boom')),
        ):
            blocked, _, _ = G._check_gate(self._event())
        self.assertFalse(blocked)

    def test_gate4_fails_open_on_exception(self):
        with (
            patch.object(G, '_gate1_anti_pattern', return_value=(False, None)),
            patch.object(G, '_gate2_failure_scan', return_value=(False, None)),
            patch.object(G, '_gate3_prediction_alert', return_value=(False, None)),
            patch.object(G, '_gate4_decision_conflict', side_effect=Exception('boom')),
        ):
            blocked, _, _ = G._check_gate(self._event())
        self.assertFalse(blocked)

    def test_gate5_fails_open_on_exception(self):
        with (
            patch.object(G, '_gate1_anti_pattern', return_value=(False, None)),
            patch.object(G, '_gate2_failure_scan', return_value=(False, None)),
            patch.object(G, '_gate3_prediction_alert', return_value=(False, None)),
            patch.object(G, '_gate4_decision_conflict', return_value=(False, None)),
            patch.object(G, '_gate5_temporal_advisory', side_effect=Exception('boom')),
        ):
            blocked, _, adv = G._check_gate(self._event())
        self.assertFalse(blocked)
        self.assertEqual(adv, [])

    def test_db_path_failure_fails_open(self):
        with patch.object(G, '_get_db_path', side_effect=ImportError('no paths')):
            blocked, _, _ = G._check_gate(self._event())
        self.assertFalse(blocked)


# ---------------------------------------------------------------------------
# Timing
# ---------------------------------------------------------------------------

class TestTiming(unittest.TestCase):

    def test_total_execution_under_2_seconds_subprocess(self):
        event = json.dumps({'tool_input': {'file_path': 'pulse/brain/anti_pattern_gate.py', 'new_string': 'def s():return[]'}})
        t0 = time.monotonic()
        r = _run_gate(event, timeout=6.0)
        elapsed = time.monotonic() - t0
        self.assertLess(elapsed, 2.0, f'Subprocess {elapsed:.2f}s')
        self.assertIn(r.returncode, (0, 2))

    def test_check_gate_under_2_seconds_in_process(self):
        t0 = time.monotonic()
        G._check_gate({'tool_input': {'file_path': 'x.py', 'new_string': 'def s():return[]'}})
        self.assertLess(time.monotonic() - t0, 2.0)


# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------

class TestEdgeCases(unittest.TestCase):

    def test_empty_stdin_exits_0(self):
        self.assertEqual(_run_gate('').returncode, 0)

    def test_whitespace_stdin_exits_0(self):
        self.assertEqual(_run_gate('   ').returncode, 0)

    def test_malformed_json_exits_0(self):
        self.assertEqual(_run_gate('{bad').returncode, 0)

    def test_user_prompt_submit_exits_0(self):
        event = json.dumps({'hook_event_name': 'UserPromptSubmit', 'prompt': 'help'})
        self.assertEqual(_run_gate(event).returncode, 0)

    def test_empty_tool_input_unblocked(self):
        blocked, msg, adv = G._check_gate({'tool_input': {}})
        self.assertFalse(blocked)
        self.assertIsNone(msg)
        self.assertEqual(adv, [])

    def test_stopword_only_no_keywords(self):
        blocked, _, _ = G._check_gate({'tool_input': {'new_string': 'import json'}})
        self.assertFalse(blocked)


# ---------------------------------------------------------------------------
# _keyword_overlap precision
# ---------------------------------------------------------------------------

class TestKeywordOverlapPrecision(unittest.TestCase):

    def test_9_plus_char_keyword_matches(self):
        self.assertTrue(G._keyword_overlap('distillation error occurred', ['distillation']))

    def test_7_char_keyword_skipped(self):
        self.assertFalse(G._keyword_overlap('process crashed hard', ['process']))

    def test_8_char_keyword_skipped(self):
        self.assertFalse(G._keyword_overlap('fallback triggered', ['fallback']))

    def test_long_keyword_matches(self):
        self.assertTrue(G._keyword_overlap('hippocampus path store', ['hippocampus']))

    def test_word_boundary_no_substring(self):
        self.assertFalse(G._keyword_overlap('super_hippocampus_extended', ['hippocampus']))

    def test_empty_text_false(self):
        self.assertFalse(G._keyword_overlap('', ['hippocampus']))

    def test_empty_keywords_false(self):
        self.assertFalse(G._keyword_overlap('hippocampus text', []))


if __name__ == '__main__':
    unittest.main(verbosity=2)
