#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""Reflex LLM — Async LLM analysis layer for Reflex Arc.

Background daemon thread that catches implicit insights, debugging
narratives, and architectural decisions that regex patterns miss.
Non-blocking: bridge calls enqueue_for_llm() which returns immediately.

Writes to the same reflex_patterns.json with pattern_type "llm_detected".
"""
from __future__ import annotations

import hashlib
import logging
import queue
import threading
import time
from datetime import datetime, timezone

from pulse.core.brain_io import atomic_update_json
from pulse.core.brain_utils import EVIDENCE_DIR

logger = logging.getLogger("pulse.reflex_llm")

REFLEX_FILE = EVIDENCE_DIR / "Anti_Patterns" / "reflex_patterns.json"
_MAX_ENTRIES = 500

# --- Tunables ---
_MAX_CALLS_PER_HOUR = 20
_MIN_INTERACTION_CHARS = 200   # skip trivial exchanges
_QUEUE_MAX_SIZE = 50           # drop oldest if queue saturates
_STALENESS_SECONDS = 600       # 10 min — skip stale items
_TEXT_TRUNCATE = 3000           # max chars per text field in prompt

# --- Module state ---
_queue: queue.Queue = queue.Queue(maxsize=_QUEUE_MAX_SIZE)
_worker_thread = None
_started = False
_lock = threading.Lock()


# ---------------------------------------------------------------------------
# Rate limiter (sliding window, thread-safe)
# ---------------------------------------------------------------------------

class _HourlyRateLimiter:
    """Sliding window rate limiter. Thread-safe."""

    def __init__(self, max_per_hour: int):
        self._max = max_per_hour
        self._timestamps: list[float] = []
        self._lock = threading.Lock()

    def try_acquire(self) -> bool:
        """Return True if a call is allowed, False if rate limit hit."""
        now = time.time()
        cutoff = now - 3600.0
        with self._lock:
            self._timestamps = [t for t in self._timestamps if t > cutoff]
            if len(self._timestamps) >= self._max:
                return False
            self._timestamps.append(now)
            return True

    def seconds_until_available(self) -> float:
        """Seconds until next slot opens. 0 if available now."""
        now = time.time()
        cutoff = now - 3600.0
        with self._lock:
            active = [t for t in self._timestamps if t > cutoff]
            if len(active) < self._max:
                return 0.0
            return active[0] - cutoff


_rate_limiter = _HourlyRateLimiter(_MAX_CALLS_PER_HOUR)


# ---------------------------------------------------------------------------
# LLM extraction prompt
# ---------------------------------------------------------------------------

_REFLEX_LLM_PROMPT = """You are a mistake-detection specialist for a software engineering team. Analyze this conversation between a user and an AI agent.

Extract ONLY actionable engineering knowledge. Focus on:
1. Implicit debugging narratives (agent discovered something broke but didn't say "the bug was")
2. Architectural decisions with rationale (why something was chosen over alternatives)
3. Subtle regressions or side effects mentioned in passing
4. Workflow corrections (doing X before Y matters, order-dependent operations)
5. Environment-specific gotchas (Windows vs Linux, encoding, path separators)

ALREADY DETECTED by regex (DO NOT repeat these):
{regex_hits}

RULES:
- Each item: 30-200 chars, complete sentence, actionable, specific
- NO status updates ("was completed", "has been deployed")
- NO vague observations ("the code was refactored")
- If NO new insights beyond what regex already caught: return empty arrays
- Be CONSERVATIVE: only extract items you are confident are genuine engineering knowledge

OUTPUT — strict JSON, no markdown fences:
{{"lessons": ["..."], "failures": ["..."], "patterns": ["..."]}}

Conversation ({source} agent):
USER: {user_text}
AGENT: {agent_text}
"""

# Map LLM output keys -> (pattern_name, confidence)
_CATEGORY_MAP = {
    "failures": ("llm_failure", 0.70),
    "lessons":  ("llm_lesson", 0.65),
    "patterns": ("llm_pattern", 0.65),
}


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def enqueue_for_llm(
    user_text: str,
    agent_text: str,
    source: str,
    regex_hits: list[dict],
) -> bool:
    """Enqueue an interaction for background LLM analysis.

    Non-blocking. Returns True if enqueued, False if skipped/dropped.
    Called from pulse_bridge._flush_interaction() after check_reflex().
    """
    combined_len = len(user_text or "") + len(agent_text or "")
    if combined_len < _MIN_INTERACTION_CHARS:
        return False

    _ensure_worker_started()

    item = {
        "user_text": (user_text or "")[:_TEXT_TRUNCATE],
        "agent_text": (agent_text or "")[:_TEXT_TRUNCATE],
        "source": source,
        "regex_hits": regex_hits or [],
        "enqueued_at": time.time(),
    }

    try:
        _queue.put_nowait(item)
        return True
    except queue.Full:
        logger.debug("Reflex LLM queue full, dropping interaction")
        return False


# ---------------------------------------------------------------------------
# Background worker
# ---------------------------------------------------------------------------

def _ensure_worker_started():
    """Start the background worker thread if not already running."""
    global _started, _worker_thread
    if _started:
        return
    with _lock:
        if _started:
            return
        _worker_thread = threading.Thread(
            target=_worker_loop,
            daemon=True,
            name="reflex_llm_worker",
        )
        _worker_thread.start()
        _started = True
        logger.info("Reflex LLM worker thread started")


def _worker_loop():
    """Background worker: process queue items sequentially via LLM."""
    while True:
        try:
            item = _queue.get(timeout=30.0)
        except queue.Empty:
            continue

        # Rate limit
        if not _rate_limiter.try_acquire():
            wait = _rate_limiter.seconds_until_available()
            logger.debug("Rate limited, waiting %.0fs", wait)
            try:
                _queue.put_nowait(item)
            except queue.Full:
                pass
            time.sleep(min(wait + 1, 60))
            continue

        # Staleness check
        if time.time() - item["enqueued_at"] > _STALENESS_SECONDS:
            _queue.task_done()
            continue

        try:
            _process_item(item)
        except Exception as e:
            logger.debug("Reflex LLM processing error: %s", e)
        finally:
            _queue.task_done()


def _process_item(item: dict):
    """Call LLM and write results to reflex_patterns.json."""
    # Check provider availability
    try:
        from pulse.core.provider_state import check_availability
        avail = check_availability()
        if not any(avail.values()):
            logger.debug("No LLM providers available, skipping")
            return
    except Exception:
        return

    # Build dedup context from regex hits
    regex_descriptions = []
    for hit in item.get("regex_hits", []):
        desc = hit.get("description", "")
        if desc:
            regex_descriptions.append(desc)
    regex_context = "\n".join(f"- {d}" for d in regex_descriptions) if regex_descriptions else "None"

    # Build prompt
    prompt = _REFLEX_LLM_PROMPT.format(
        regex_hits=regex_context,
        source=item["source"],
        user_text=item["user_text"],
        agent_text=item["agent_text"],
    )

    # Dispatch via fast cascade
    from pulse.core.llm_router import dispatch, extract_json
    try:
        raw = dispatch(prompt, task_type="fast")
    except RuntimeError:
        return  # all providers failed — graceful degradation

    parsed = extract_json(raw)

    # Collect valid items
    all_items = []
    for category, (pattern_name, confidence) in _CATEGORY_MAP.items():
        for desc in parsed.get(category, []):
            if isinstance(desc, str) and 30 <= len(desc) <= 200:
                all_items.append((desc, pattern_name, confidence))

    if not all_items:
        return

    # Fuzzy dedup sets
    regex_descs_lower = {d.lower().strip() for d in regex_descriptions}
    regex_word_sets = [set(d.lower().split()) for d in regex_descriptions]

    now_iso = datetime.now(timezone.utc).isoformat()
    source_upper = item["source"].upper()
    written = 0

    for desc, pattern_name, confidence in all_items:
        # Exact dedup vs regex
        if desc.lower().strip() in regex_descs_lower:
            continue

        # Fuzzy dedup: >60% word overlap with any regex hit
        desc_words = set(desc.lower().split())
        skip = False
        for rx_words in regex_word_sets:
            if rx_words and desc_words:
                overlap = len(desc_words & rx_words) / max(len(desc_words), len(rx_words))
                if overlap > 0.6:
                    skip = True
                    break
        if skip:
            continue

        desc_hash = hashlib.md5(desc.lower().strip().encode()).hexdigest()

        def _updater(data, _desc=desc, _hash=desc_hash, _iso=now_iso,
                     _src=source_upper, _pname=pattern_name, _conf=confidence):
            if not isinstance(data, dict):
                data = {"entries": [], "stats": {"total": 0, "last_detection": None}}
            entries = data.setdefault("entries", [])
            stats = data.setdefault("stats", {"total": 0, "last_detection": None})
            # Hash dedup (same as reflex_arc.py)
            if _hash in {e.get("_hash") for e in entries}:
                return data
            seq = stats.get("total", 0) + 1
            entries.append({
                "id": f"RX-{_iso[:10].replace('-', '')}-{seq:03d}",
                "description": _desc,
                "pattern_type": "llm_detected",
                "pattern_name": _pname,
                "source_agent": _src,
                "confidence": _conf,
                "severity": "medium",
                "domain": "general",
                "status": "active",
                "timestamp": _iso,
                "_hash": _hash,
            })
            if len(entries) > _MAX_ENTRIES:
                data["entries"] = entries[-_MAX_ENTRIES:]
            stats["total"] = seq
            stats["last_detection"] = _iso
            return data

        try:
            atomic_update_json(REFLEX_FILE, _updater,
                               default={"entries": [], "stats": {"total": 0}})
            written += 1
        except Exception:
            pass  # never crash the worker

    if written:
        logger.info("Reflex LLM: wrote %d entries from %s", written, source_upper)
    return written


# ---------------------------------------------------------------------------
# Synchronous API (for manual use / backfill)
# ---------------------------------------------------------------------------

def analyze_now(user_text: str, agent_text: str, source: str = "MANUAL") -> int:
    """Synchronous LLM analysis — bypasses queue and rate limiter.

    Returns count of entries written to reflex_patterns.json.
    Use for manual testing or backfill scripts.
    """
    from pulse.daemons.bridge.reflex_arc import detect_mistakes

    combined = f"{user_text}\n{agent_text}" if agent_text else user_text
    regex_hits = detect_mistakes(combined)

    item = {
        "user_text": (user_text or "")[:_TEXT_TRUNCATE],
        "agent_text": (agent_text or "")[:_TEXT_TRUNCATE],
        "source": source,
        "regex_hits": regex_hits,
        "enqueued_at": time.time(),
    }
    return _process_item(item) or 0


def backfill_from_logs(log_dir: str = None, limit: int = 0, verbose: bool = True):
    """Process existing capture logs through LLM reflex analysis.

    Pairs USER+AGENT messages, feeds each through analyze_now().
    Skips trivial interactions (<200 chars combined).

    Args:
        log_dir: Directory with pulse_captured_*.log files. Default: state/
        limit: Max interactions to process (0 = all).
        verbose: Print progress to stdout.
    """
    import re
    from pathlib import Path

    if log_dir:
        state_dir = Path(log_dir)
    else:
        state_dir = EVIDENCE_DIR.parent.parent / "state"

    _LOG_LINE = re.compile(
        r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+"
        r"(USER|CLAUDE|GEMINI|CODEX|GROK):\s+"
        r"(.+?)(?:\s*\|\s*SIG:\s*\S+)?\s*$"
    )

    log_files = sorted(state_dir.glob("pulse_captured_*.log"))
    if not log_files:
        if verbose:
            print(f"No capture logs found in {state_dir}")
        return

    total_processed = 0
    total_written = 0

    for log_file in log_files:
        # Derive source from filename: pulse_captured_claude.log -> CLAUDE
        stem = log_file.stem  # pulse_captured_claude
        source = stem.replace("pulse_captured_", "").upper()
        if not source or len(source) > 30:
            source = "UNKNOWN"

        if verbose:
            print(f"\n[BACKFILL] Processing {log_file.name} (source: {source})")

        # Parse into USER/AGENT pairs
        current_user = None
        agent_lines = []

        with open(log_file, "r", encoding="utf-8", errors="replace") as f:
            for line in f:
                m = _LOG_LINE.match(line)
                if not m:
                    # Continuation line
                    if agent_lines:
                        agent_lines.append(line.rstrip())
                    continue

                _ts, role, text = m.group(1), m.group(2), m.group(3)

                if role == "USER":
                    # Flush previous pair if we have one
                    if current_user and agent_lines:
                        agent_text = " ".join(agent_lines)
                        combined_len = len(current_user) + len(agent_text)
                        if combined_len >= _MIN_INTERACTION_CHARS:
                            written = analyze_now(current_user, agent_text, source)
                            total_written += written
                            total_processed += 1
                            if verbose and written:
                                print(f"  [{total_processed}] +{written} entries")
                            if limit and total_processed >= limit:
                                break
                    current_user = text
                    agent_lines = []
                else:
                    agent_lines.append(text)

            # Flush last pair
            if current_user and agent_lines and (not limit or total_processed < limit):
                agent_text = " ".join(agent_lines)
                if len(current_user) + len(agent_text) >= _MIN_INTERACTION_CHARS:
                    written = analyze_now(current_user, agent_text, source)
                    total_written += written
                    total_processed += 1
                    if verbose and written:
                        print(f"  [{total_processed}] +{written} entries")

        if limit and total_processed >= limit:
            break

    if verbose:
        print(f"\n[BACKFILL] Done: {total_processed} interactions processed, "
              f"{total_written} entries written to reflex_patterns.json")


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import sys
    import os
    from pathlib import Path

    # Ensure pulse package is importable when run directly
    ROOT_DIR = Path(__file__).resolve().parent.parent.parent.parent
    if str(ROOT_DIR) not in sys.path:
        sys.path.insert(0, str(ROOT_DIR))

    if os.name == "nt":
        import io
        sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
        sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")

    logging.basicConfig(level=logging.INFO, format="%(message)s")

    args = sys.argv[1:]
    if "--backfill" in args:
        limit_val = 0
        for i, a in enumerate(args):
            if a == "--limit" and i + 1 < len(args):
                limit_val = int(args[i + 1])
        print("[REFLEX LLM] Starting backfill from capture logs...")
        backfill_from_logs(limit=limit_val)
    elif "--test" in args:
        user = "The bridge keeps crashing when I run it with multiple workers"
        agent = ("I found the issue. The filelock timeout was set to 10 seconds "
                 "which is too low for 24 concurrent agents. When multiple workers "
                 "try to write to the same JSON file simultaneously, they all "
                 "timeout and crash. The fix is to increase the timeout to 60 "
                 "seconds in brain_io.py.")
        print("[REFLEX LLM] Running test analysis...")
        n = analyze_now(user, agent, "TEST")
        print(f"[REFLEX LLM] Wrote {n} entries")
    else:
        print("Usage:")
        print("  python reflex_llm.py --backfill [--limit N]  Process capture logs")
        print("  python reflex_llm.py --test                  Run a test extraction")
