"""
parsing.providers — Parsing provider implementations.

Each provider wraps a parsing backend (TextFSM Fire, TTP Fire, Ollama)
behind a common interface. The broker calls them in priority order.

Providers are thread-safe: TextFSM/TTP use asyncio.to_thread for their
blocking SQLite + template engine calls; Ollama uses httpx async.
"""

from __future__ import annotations

import json
import importlib.util
import logging
import re
import sys
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger("nterm.parsing.providers")


# ── Result Types ────────────────────────────────────────────

@dataclass
class ProviderResult:
    """Standardized result from any provider."""
    provider: str                          # "tfsm_fire", "ttp_fire", "ollama"
    template: Optional[str] = None         # template name (None for Ollama)
    score: int = 0                         # 0–100 composite
    records: List[Dict[str, Any]] = field(default_factory=list)
    fields: List[str] = field(default_factory=list)
    all_scores: List[Tuple] = field(default_factory=list)
    parse_time_ms: int = 0
    error: Optional[str] = None

    def summary(self) -> dict:
        """Compact summary for the competing dict."""
        s = {
            "score": self.score,
            "parse_time_ms": self.parse_time_ms,
        }
        if self.template:
            s["template"] = self.template
        if self.error:
            s["error"] = self.error
        return s


# ── Abstract Base ───────────────────────────────────────────

class ParseProvider(ABC):
    """Interface that every parsing provider implements."""

    name: str
    available: bool = False

    @abstractmethod
    def parse(self, output: str, filter_string: str) -> ProviderResult:
        """
        Parse device output using this provider.

        Called via asyncio.to_thread for blocking providers (TextFSM, TTP).
        Returns a ProviderResult with score 0 if parsing fails or no match.
        """
        ...

    def check_availability(self) -> bool:
        """Re-check whether this provider can handle requests."""
        return self.available


# ── Provider 1: TextFSM Fire ───────────────────────────────

class TextFSMProvider(ParseProvider):
    """
    Wraps TextFSMAutoEngine from tfsm_fire.py.

    ~1,000 NTC templates in SQLite. Regex-based state machine parsing.
    Fast and precise for well-structured tabular output.
    """

    name = "tfsm_fire"

    def __init__(self, db_path: str | Path, module_path: Path | None = None):
        self.db_path = Path(db_path)
        self.module_path = module_path
        self.engine = None
        self.available = False
        self._init_engine()

    def _init_engine(self):
        """Try to import and instantiate the TextFSM engine."""
        if not self.db_path.exists():
            logger.warning(
                f"TextFSM templates DB not found: {self.db_path} — provider disabled"
            )
            return

        try:
            TextFSMAutoEngine = self._import_engine_class()
            self.engine = TextFSMAutoEngine(db_path=str(self.db_path))
            self.available = True
            logger.info(f"TextFSM Fire loaded: {self.db_path}")
        except ImportError as e:
            logger.warning(
                f"tfsm_fire module not found — TextFSM provider disabled: {e}  "
                f"(module_path={self.module_path})"
            )
        except Exception as e:
            logger.error(f"TextFSM Fire init failed: {e}", exc_info=True)

    def _import_engine_class(self):
        """Import TextFSMAutoEngine, using module_path for direct file load if set."""
        if self.module_path:
            target = self.module_path / "tfsm_fire.py"
            if target.is_file():
                spec = importlib.util.spec_from_file_location("tfsm_fire", str(target))
                mod = importlib.util.module_from_spec(spec)
                spec.loader.exec_module(mod)
                logger.info(f"Loaded tfsm_fire from {target}")
                return mod.TextFSMAutoEngine

        # Fallback: normal import (works if tfsm_fire.py is on PYTHONPATH)
        from nterm.tfsm_fire import TextFSMAutoEngine
        return TextFSMAutoEngine

    def parse(self, output: str, filter_string: str) -> ProviderResult:
        """Run TextFSM template matching. Called in thread pool."""
        if not self.available or not self.engine:
            return ProviderResult(provider=self.name, error="not available")

        start = time.monotonic()
        try:
            template_name, parsed_data, score, all_scores = (
                self.engine.find_best_template(
                    device_output=output,
                    filter_string=filter_string,
                )
            )
            elapsed = int((time.monotonic() - start) * 1000)

            records = parsed_data or []
            fields = list(records[0].keys()) if records else []

            return ProviderResult(
                provider=self.name,
                template=template_name,
                score=int(score),
                records=records,
                fields=fields,
                all_scores=all_scores or [],
                parse_time_ms=elapsed,
            )

        except Exception as e:
            elapsed = int((time.monotonic() - start) * 1000)
            logger.error(f"TextFSM parse error: {e}")
            return ProviderResult(
                provider=self.name,
                score=0,
                parse_time_ms=elapsed,
                error=str(e),
            )

    def check_availability(self) -> bool:
        if not self.available:
            self._init_engine()
        return self.available


# ── Provider 2: TTP Fire ───────────────────────────────────

class TTPProvider(ParseProvider):
    """
    Wraps TTPAutoEngine from ttp_fire.py.

    ~800 templates (80% NTC ported). YAML-based pattern matching —
    more flexible than TextFSM for optional fields and hierarchical output.
    """

    name = "ttp_fire"

    def __init__(self, db_path: str | Path, module_path: Path | None = None):
        self.db_path = Path(db_path)
        self.module_path = module_path
        self.engine = None
        self.available = False
        self._init_engine()

    def _init_engine(self):
        """Try to import and instantiate the TTP engine."""
        if not self.db_path.exists():
            logger.warning(
                f"TTP templates DB not found: {self.db_path} — provider disabled"
            )
            return

        try:
            TTPAutoEngine = self._import_engine_class()
            self.engine = TTPAutoEngine(db_path=str(self.db_path))
            self.available = True
            logger.info(f"TTP Fire loaded: {self.db_path}")
        except ImportError as e:
            logger.warning(
                f"ttp_fire module not found — TTP provider disabled: {e}  "
                f"(module_path={self.module_path})"
            )
        except Exception as e:
            logger.error(f"TTP Fire init failed: {e}", exc_info=True)

    def _import_engine_class(self):
        """Import TTPAutoEngine, using module_path for direct file load if set."""
        if self.module_path:
            target = self.module_path / "ttp_fire.py"
            if target.is_file():
                spec = importlib.util.spec_from_file_location("ttp_fire", str(target))
                mod = importlib.util.module_from_spec(spec)
                spec.loader.exec_module(mod)
                logger.info(f"Loaded ttp_fire from {target}")
                return mod.TTPAutoEngine

        # Fallback: normal import (works if ttp_fire.py is on PYTHONPATH)
        from nterm.ttp_fire import TTPAutoEngine
        return TTPAutoEngine

    def parse(self, output: str, filter_string: str) -> ProviderResult:
        """Run TTP template matching. Called in thread pool."""
        if not self.available or not self.engine:
            return ProviderResult(provider=self.name, error="not available")

        start = time.monotonic()
        try:
            template_name, parsed_data, score, all_scores = (
                self.engine.find_best_template(
                    device_output=output,
                    filter_string=filter_string,
                )
            )
            elapsed = int((time.monotonic() - start) * 1000)

            records = parsed_data or []
            fields = list(records[0].keys()) if records else []

            return ProviderResult(
                provider=self.name,
                template=template_name,
                score=int(score),
                records=records,
                fields=fields,
                all_scores=all_scores or [],
                parse_time_ms=elapsed,
            )

        except Exception as e:
            elapsed = int((time.monotonic() - start) * 1000)
            logger.error(f"TTP parse error: {e}")
            return ProviderResult(
                provider=self.name,
                score=0,
                parse_time_ms=elapsed,
                error=str(e),
            )

    def check_availability(self) -> bool:
        if not self.available:
            self._init_engine()
        return self.available


# ── Provider 3: Ollama (Local LLM) ─────────────────────────

class OllamaProvider(ParseProvider):
    """
    Local LLM fallback via Ollama API.

    Only runs when template-based providers can't help (score < 30).
    Non-deterministic but handles any CLI output without templates.
    All processing stays local — no data leaves the engineer's machine.
    """

    name = "ollama"

    def __init__(
        self,
        base_url: str = "http://localhost:11434",
        model: str = "llama3:8b",
        timeout_s: float = 10.0,
        max_output_chars: int = 4000,
        health_check_interval_s: float = 60.0,
    ):
        self.base_url = base_url.rstrip("/")
        self.model = model
        self.timeout_s = timeout_s
        self.max_output_chars = max_output_chars
        self.health_check_interval_s = health_check_interval_s
        self.available = False
        self._last_health_check: float = 0

    # ── Sync wrapper (called via asyncio.to_thread) ──

    def parse(self, output: str, filter_string: str) -> ProviderResult:
        """
        Parse via Ollama. Called in thread pool.

        filter_string is used to extract the command name for the prompt
        but isn't used for template lookup (there are no templates).
        """
        if not self.available:
            return ProviderResult(provider=self.name, error="not available")

        if len(output) > self.max_output_chars:
            return ProviderResult(
                provider=self.name,
                error=f"output too large ({len(output)} chars, limit {self.max_output_chars})",
            )

        # Extract command from filter string for the prompt
        command = self._filter_to_command(filter_string)

        start = time.monotonic()
        try:
            import httpx

            response = httpx.post(
                f"{self.base_url}/api/generate",
                json={
                    "model": self.model,
                    "prompt": self._build_prompt(command, output),
                    "stream": False,
                    "format": "json",
                },
                timeout=self.timeout_s,
            )
            response.raise_for_status()
            elapsed = int((time.monotonic() - start) * 1000)

            body = response.json()
            raw_text = body.get("response", "")
            records = self._extract_records(raw_text)
            score = self._score_records(records)
            fields = list(records[0].keys()) if records else []

            return ProviderResult(
                provider=self.name,
                template=None,
                score=score,
                records=records,
                fields=fields,
                parse_time_ms=elapsed,
            )

        except Exception as e:
            elapsed = int((time.monotonic() - start) * 1000)
            logger.error(f"Ollama parse error: {e}")
            return ProviderResult(
                provider=self.name,
                score=0,
                parse_time_ms=elapsed,
                error=str(e),
            )

    # ── Health Check ──

    def check_availability(self) -> bool:
        """Ping Ollama /api/tags to verify it's running."""
        now = time.monotonic()
        if now - self._last_health_check < self.health_check_interval_s:
            return self.available

        self._last_health_check = now
        try:
            import httpx
            r = httpx.get(f"{self.base_url}/api/tags", timeout=3.0)
            self.available = r.status_code == 200
        except Exception:
            self.available = False

        if self.available:
            logger.info(f"Ollama reachable at {self.base_url} (model: {self.model})")
        else:
            logger.debug(f"Ollama not reachable at {self.base_url}")

        return self.available

    # ── Prompt Construction ──

    @staticmethod
    def _build_prompt(command: str, output: str) -> str:
        return (
            "Parse this network device CLI output into structured JSON.\n"
            "Return an array of records, where each record is an object "
            "with consistent field names.\n"
            "Use lowercase_underscore field names. "
            "All values MUST be flat strings or numbers — no nested objects or arrays.\n"
            "Return ONLY valid JSON, no explanation.\n"
            "\n"
            f"Command: {command}\n"
            "Output:\n"
            f"{output}\n"
        )
    @staticmethod
    def _filter_to_command(filter_string: str) -> str:
        """
        Reconstruct a human-readable command from the filter string.
        'arista_eos_show_ip_bgp_summary' → 'show ip bgp summary'
        """
        parts = filter_string.split("_")
        # Drop vendor prefix tokens (common: arista, eos, cisco, ios, juniper, junos, etc.)
        vendor_tokens = {
            "arista", "eos", "cisco", "ios", "iosxe", "iosxr", "nxos",
            "juniper", "junos", "paloalto", "panos", "fortinet", "fortios",
            "linux", "vyos",
        }
        # Walk forward, skip vendor tokens at the start
        idx = 0
        while idx < len(parts) and parts[idx].lower() in vendor_tokens:
            idx += 1
        return " ".join(parts[idx:]) if idx < len(parts) else filter_string

    # ── Response Parsing ──

    @staticmethod
    def _extract_records(raw_text: str) -> List[Dict[str, Any]]:
        """
        Parse LLM JSON response into a list of dicts.
        Handles: bare array, {"records": [...]}, {"data": [...]}, single object.
        """
        try:
            # Strip markdown fences if present
            cleaned = re.sub(r"^```(?:json)?\s*", "", raw_text.strip())
            cleaned = re.sub(r"\s*```$", "", cleaned)
            parsed = json.loads(cleaned)

            if isinstance(parsed, list):
                return [r for r in parsed if isinstance(r, dict)]
            if isinstance(parsed, dict):
                # Look for common wrapper keys
                for key in ("records", "data", "results", "items"):
                    if key in parsed and isinstance(parsed[key], list):
                        return [r for r in parsed[key] if isinstance(r, dict)]
                # Single record
                return [parsed]
            return []

        except (json.JSONDecodeError, TypeError):
            logger.warning("Ollama returned non-JSON response")
            return []

    @staticmethod
    def _score_records(records: List[Dict]) -> int:
        """
        Score LLM-extracted records using the same 4-factor system
        as template providers, for comparable results.

        Factors (0–100 total):
          - Record count:    0–30
          - Field richness:  0–30
          - Population rate: 0–25
          - Consistency:     0–15
        """
        if not records:
            return 0

        import math

        # Factor 1: Record count (0–30, log scale)
        n = len(records)
        record_score = min(30, int(10 * math.log2(n + 1)))

        # Factor 2: Field richness (0–30)
        avg_fields = sum(len(r) for r in records) / n
        if avg_fields >= 10:
            field_score = 30
        elif avg_fields >= 6:
            field_score = 22
        elif avg_fields >= 3:
            field_score = 15
        elif avg_fields >= 1:
            field_score = 8
        else:
            field_score = 0

        # Factor 3: Population rate (0–25)
        all_keys = set()
        for r in records:
            all_keys.update(r.keys())
        total_cells = n * len(all_keys) if all_keys else 1
        filled = sum(
            1 for r in records for k in all_keys
            if k in r and r[k] is not None and str(r[k]).strip() != ""
        )
        pop_rate = filled / total_cells
        pop_score = int(pop_rate * 25)

        # Factor 4: Consistency (0–15)
        if n == 1:
            consistency_score = 15
        else:
            key_sets = [set(r.keys()) for r in records]
            common = set.intersection(*key_sets) if key_sets else set()
            union = set.union(*key_sets) if key_sets else set()
            ratio = len(common) / len(union) if union else 0
            consistency_score = int(ratio * 15)

        return record_score + field_score + pop_score + consistency_score