"""
LLM Guard — circuit breaker, output validation, and hallucination detection.

Integrates with agent_role_router.call_llm() to provide:
  1. Circuit breaker: auto-disable provider after N consecutive failures
  2. Output schema validation: verify JSON structure matches expected schema
  3. Semantic consistency check: basic hallucination detection
"""

from __future__ import annotations

import json
import re
import time
from typing import Any, Dict, List, Optional, Tuple


# ── Circuit Breaker ──────────────────────────────────────────────────────

class CircuitBreaker:
    """Track failures per provider and open circuit after threshold."""

    def __init__(self, failure_threshold: int = 5, recovery_sec: float = 300.0):
        self._failure_threshold = failure_threshold
        self._recovery_sec = recovery_sec
        self._failures: Dict[str, List[float]] = {}  # provider -> [failure_timestamps]
        self._open_since: Dict[str, float] = {}  # provider -> open_timestamp

    def record_success(self, provider: str) -> None:
        """Reset failure count for provider on success."""
        self._failures[provider] = []
        self._open_since.pop(provider, None)

    def record_failure(self, provider: str) -> None:
        """Record a failure for provider."""
        if provider not in self._failures:
            self._failures[provider] = []
        self._failures[provider].append(time.time())
        # Prune old failures (>10 min)
        cutoff = time.time() - 600
        self._failures[provider] = [t for t in self._failures[provider] if t > cutoff]

        if len(self._failures[provider]) >= self._failure_threshold:
            self._open_since[provider] = time.time()

    def is_open(self, provider: str) -> bool:
        """Check if circuit is open (should reject calls)."""
        if provider not in self._open_since:
            return False
        if time.time() - self._open_since[provider] > self._recovery_sec:
            # Recovery period elapsed — half-open, allow one probe
            self._open_since.pop(provider, None)
            self._failures[provider] = []
            return False
        return True

    def status(self) -> Dict[str, Any]:
        """Get circuit breaker status for monitoring."""
        return {
            "failures": {k: len(v) for k, v in self._failures.items()},
            "open": dict(self._open_since),
        }


# ── Global singleton ──
_circuit_breaker = CircuitBreaker()


def get_circuit_breaker() -> CircuitBreaker:
    return _circuit_breaker


# ── Output Validation ────────────────────────────────────────────────────

def extract_json_block(text: str) -> Optional[str]:
    """Extract a JSON block from LLM response (code fence or raw)."""
    # Try ```json ... ``` fence
    m = re.search(r"```(?:json)?\s*([\s\S]*?)```", text, re.IGNORECASE)
    if m:
        return m.group(1).strip()
    # Try raw JSON object/array
    text_stripped = text.strip()
    if text_stripped.startswith("{") or text_stripped.startswith("["):
        # Find matching closing bracket
        depth = 0
        in_string = False
        escape = False
        for i, ch in enumerate(text_stripped):
            if escape:
                escape = False
                continue
            if ch == "\\":
                escape = True
                continue
            if ch == '"' and not escape:
                in_string = not in_string
            if in_string:
                continue
            if ch in "{[":
                depth += 1
            elif ch in "}]":
                depth -= 1
                if depth == 0:
                    return text_stripped[: i + 1]
    return None


def validate_json_output(
    response_text: str, expected_schema: Optional[dict] = None
) -> Tuple[bool, Optional[dict], str]:
    """
    Validate LLM JSON output. Returns (valid, parsed_dict, error_reason).

    If expected_schema is provided, checks required fields exist.
    """
    json_str = extract_json_block(response_text)
    if json_str is None:
        return False, None, "NO_JSON_FOUND"

    try:
        parsed = json.loads(json_str)
    except json.JSONDecodeError as e:
        return False, None, f"JSON_PARSE_ERROR: {e}"

    if not isinstance(parsed, dict):
        return False, None, "NOT_A_DICT"

    # Schema validation: check required fields
    if expected_schema and isinstance(expected_schema, dict):
        required = expected_schema.get("required", [])
        if isinstance(required, list):
            missing = [f for f in required if f not in parsed]
            if missing:
                return False, parsed, f"MISSING_FIELDS: {missing}"

        # Type checking for properties
        properties = expected_schema.get("properties", {})
        if isinstance(properties, dict):
            type_errors = []
            for field, spec in properties.items():
                if field not in parsed:
                    continue
                expected_type = spec.get("type", "")
                actual = parsed[field]
                if expected_type == "number" and not isinstance(actual, (int, float)):
                    type_errors.append(f"{field}:expected_number,got_{type(actual).__name__}")
                elif expected_type == "string" and not isinstance(actual, str):
                    type_errors.append(f"{field}:expected_string,got_{type(actual).__name__}")
                elif expected_type == "boolean" and not isinstance(actual, bool):
                    type_errors.append(f"{field}:expected_bool,got_{type(actual).__name__}")
            if type_errors:
                return False, parsed, f"TYPE_ERRORS: {type_errors}"

    return True, parsed, "OK"


def check_semantic_consistency(parsed: dict, role: str) -> List[str]:
    """Basic semantic consistency checks. Returns list of warnings."""
    warnings = []

    if role in ("trend_analyst", "commander"):
        direction = str(parsed.get("direction", "")).upper()
        confidence = parsed.get("confidence", 0)
        probability = parsed.get("probability", 0)

        if direction not in ("LONG", "SHORT", "NEUTRAL"):
            warnings.append(f"Invalid direction: {direction}")
        if not (0 <= confidence <= 1):
            warnings.append(f"Confidence out of range: {confidence}")
        if confidence > 0.8 and direction == "NEUTRAL":
            warnings.append("High confidence on NEUTRAL — inconsistency")
        if abs(confidence - probability) > 0.3:
            warnings.append("Confidence-probability gap > 0.3")

    if role in ("risk_manager", "verifier"):
        risk_level = str(parsed.get("risk_level", "")).upper()
        if risk_level in ("HIGH", "CRITICAL"):
            recommendation = str(parsed.get("recommendation", "")).upper()
            if recommendation not in ("BLOCK", "HOLD"):
                warnings.append(f"HIGH risk but recommendation={recommendation}")

    return warnings


# ── Input Data Validation ─────────────────────────────────────────────────
# Guards against the "blind LLM" anti-pattern: never pass None/empty data
# to an LLM call that requires market context.

_REQUIRED_MARKET_FIELDS = {
    "macro_direction": ["session_data", "current_regime"],
    "market_interpreter": ["session_data", "structure_snapshot"],
    "llm_verifier": ["regime", "signal_side", "signal_confidence"],
    "strategy_developer": ["document"],
}

_DATA_GUARD_LOG_PATH = None


def set_data_guard_log_path(path: str) -> None:
    global _DATA_GUARD_LOG_PATH
    _DATA_GUARD_LOG_PATH = path


def _log_data_guard_violation(role: str, missing: list[str], caller: str) -> None:
    """Log a data guard violation — always visible, never silent."""
    msg = (
        f"[LLM_DATA_GUARD] BLOCKED {role} call from {caller}: "
        f"missing required data fields={missing}. "
        f"LLM call skipped — using fallback instead."
    )
    # Always print to stderr (visible in console/logs)
    import sys
    print(msg, file=sys.stderr, flush=True)
    # Also write to guard log if configured
    if _DATA_GUARD_LOG_PATH:
        try:
            import os as _os
            _os.makedirs(_os.path.dirname(_DATA_GUARD_LOG_PATH), exist_ok=True)
            with open(_DATA_GUARD_LOG_PATH, "a", encoding="utf-8") as f:
                import datetime as _dt
                f.write(f"{_dt.datetime.now().isoformat()} {msg}\n")
        except Exception:
            pass


def validate_llm_input(
    role: str,
    data: dict,
    *,
    caller: str = "unknown",
    required_fields: list[str] | None = None,
) -> tuple[bool, list[str]]:
    """Validate that all required market data fields are present and non-None.

    Args:
        role: The LLM role being called (e.g. "macro_direction")
        data: The input data dict
        caller: Function name for logging
        required_fields: Explicit list of required keys. If None, uses
                         _REQUIRED_MARKET_FIELDS.get(role, []).

    Returns:
        (is_valid, missing_fields) — caller MUST check is_valid before
        proceeding with the LLM call. If False, use a fallback.
    """
    required = required_fields or _REQUIRED_MARKET_FIELDS.get(role, [])
    missing = []
    for field in required:
        value = data.get(field)
        if value is None:
            missing.append(f"{field}=None")
        elif isinstance(value, (list, dict, str)) and len(value) == 0:
            missing.append(f"{field}=empty")
        elif isinstance(value, str) and value.upper() in ("UNKNOWN", "NONE", ""):
            missing.append(f"{field}={value!r}")

    if missing:
        _log_data_guard_violation(role, missing, caller)
        return False, missing
    return True, []


def safe_call_llm_with_guard(
    call_fn,
    provider: str,
    role: str,
    prompt: str,
    output_schema: Optional[dict] = None,
    max_retries: int = 2,
    **kwargs,
) -> Dict[str, Any]:
    """
    Wrap an LLM call with circuit breaker + output validation + retry.

    Returns: {ok, content, parsed, warnings, degraded, degraded_reason}
    """
    cb = get_circuit_breaker()

    if cb.is_open(provider):
        return {
            "ok": False,
            "content": "",
            "parsed": None,
            "warnings": [],
            "degraded": True,
            "degraded_reason": f"CIRCUIT_OPEN:{provider}",
        }

    last_error = None
    for attempt in range(max_retries + 1):
        try:
            response_text = call_fn(role=role, prompt=prompt, **kwargs)

            if output_schema:
                valid, parsed, error = validate_json_output(response_text, output_schema)
                if valid and parsed:
                    warnings = check_semantic_consistency(parsed, role)
                    cb.record_success(provider)
                    return {
                        "ok": True,
                        "content": response_text,
                        "parsed": parsed,
                        "warnings": warnings,
                        "degraded": False,
                        "degraded_reason": "",
                    }
                # JSON invalid — retry with error feedback
                if attempt < max_retries:
                    prompt = f"{prompt}\n\n[Previous response was invalid: {error}. Please output valid JSON matching the schema.]"
                last_error = error
            else:
                cb.record_success(provider)
                return {
                    "ok": True,
                    "content": response_text,
                    "parsed": None,
                    "warnings": [],
                    "degraded": False,
                    "degraded_reason": "",
                }

        except Exception as e:
            last_error = str(e)
            cb.record_failure(provider)
            if attempt < max_retries:
                time.sleep(1.0 * (attempt + 1))  # exponential-ish backoff
            continue

    # All retries exhausted
    return {
        "ok": False,
        "content": "",
        "parsed": None,
        "warnings": [],
        "degraded": True,
        "degraded_reason": f"ALL_RETRIES_FAILED: {last_error}",
    }
