"""LLM-powered self-healing agent.

Reads errors from health events, sends context to LLM,
applies suggested fixes if auto_apply is enabled.
"""
from __future__ import annotations

import asyncio
import json
import subprocess
import time
from collections import defaultdict
from pathlib import Path
from typing import Any

import httpx
import structlog

from proxym.config import get_settings
from proxym.observability.health_events import HealthEvent

log = structlog.get_logger()


class RepairAgent:
    """Agent naprawiający błędy w kodzie proxym za pomocą LLM.

    Tryby:
      suggest — diagnoza + propozycja (domyślny, bezpieczny)
      apply   — zastosuj fix + uruchom testy
      auto    — watchdog automatycznie uruchamia na critical errors

    Bezpieczeństwo:
      - Circuit breaker: max 3 próby na unikalny błąd
      - Git stash przed zmianami, revert jeśli testy failują
      - Budżet: max $PROXYM_REPAIR_BUDGET_DAILY / dzień
      - Blacklist plików: config.py, .env, middleware
    """

    BLACKLISTED_FILES = {
        "src/proxym/config.py",
        "src/proxym/middleware/__init__.py",
        ".env", ".env.example",
        "docker-compose.yml",
    }

    def __init__(
        self,
        project_root: str | None = None,
        mode: str = "suggest",  # suggest | apply | auto
    ) -> None:
        self._root = Path(project_root or ".").resolve()
        self._mode = mode
        self._attempts: defaultdict[str, int] = defaultdict(int)
        self._daily_cost: float = 0.0
        self._daily_cost_reset: float = time.time()
        self._history: list[dict[str, Any]] = []

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

    async def diagnose_and_repair(
        self,
        event: HealthEvent | None = None,
        error_text: str = "",
        file_path: str = "",
        traceback_text: str = "",
    ) -> dict[str, Any]:
        """Główna metoda: diagnozuj błąd i zaproponuj/zastosuj fix.

        Returns dict z kluczami: diagnosis, suggestion, applied, test_result
        """
        settings = get_settings()
        model = getattr(settings, "repair_model", "") or "google/gemini-2.5-flash"
        api_key = getattr(settings, "repair_api_key", "") or getattr(settings, "openrouter_api_key", "")
        budget = getattr(settings, "repair_budget_daily", 2.0)

        if not api_key:
            return {"error": "No PROXYM_REPAIR_API_KEY or OPENROUTER_API_KEY in .env"}

        # Circuit breaker
        error_key = f"{event.source}:{event.title}" if event else error_text[:100]
        self._attempts[error_key] += 1
        if self._attempts[error_key] > 3:
            return {"error": f"Circuit breaker: already tried 3 times for '{error_key}'"}

        # Budget check
        self._maybe_reset_daily_budget()
        if self._daily_cost >= budget:
            return {"error": f"Daily repair budget exhausted (${self._daily_cost:.2f}/${budget:.2f})"}

        # Build context
        context = self._build_context(event, error_text, file_path, traceback_text)

        # Call LLM
        prompt = self._build_prompt(context)
        llm_response = await self._call_llm(prompt, model, api_key)

        if "error" in llm_response:
            return llm_response

        result: dict[str, Any] = {
            "diagnosis": llm_response.get("diagnosis", ""),
            "suggestion": llm_response.get("suggestion", ""),
            "files_to_change": llm_response.get("files", []),
            "diff": llm_response.get("diff", ""),
            "applied": False,
            "test_result": None,
            "cost_usd": llm_response.get("cost", 0.0),
        }
        self._daily_cost += result["cost_usd"]

        # Apply if mode allows
        if self._mode in ("apply", "auto") and result["diff"]:
            apply_result = await self._apply_fix(result)
            result.update(apply_result)

        self._history.append({
            "timestamp": time.time(),
            "error_key": error_key,
            **result,
        })

        return result

    async def diagnose_from_logs(self) -> dict[str, Any]:
        """Zbierz ostatnie błędy z ring buffer i zaproponuj fix."""
        from proxym.observability import get_error_summary
        summary = get_error_summary()
        if not summary["recent_errors"]:
            return {"status": "no_errors", "message": "No recent errors to diagnose"}

        # Zbierz najbardziej powtarzający się błąd
        top_source = max(summary["by_source"], key=summary["by_source"].get, default="")
        recent = [e for e in summary["recent_errors"] if e.get("source") == top_source]
        latest = recent[-1] if recent else summary["recent_errors"][-1]

        return await self.diagnose_and_repair(
            error_text=latest.get("error", str(latest)),
            traceback_text=latest.get("traceback", ""),
            file_path=latest.get("source", "").replace(".", "/") + ".py",
        )

    def get_history(self, limit: int = 20) -> list[dict]:
        return self._history[-limit:]

    # ----- Context building -----

    def _build_context(
        self,
        event: HealthEvent | None,
        error_text: str,
        file_path: str,
        traceback_text: str,
    ) -> dict[str, Any]:
        context: dict[str, Any] = {
            "error": error_text or (event.detail if event else ""),
            "title": event.title if event else "",
            "source": event.source if event else "",
            "traceback": traceback_text,
        }

        # Read source file if path provided and safe
        if file_path and file_path not in self.BLACKLISTED_FILES:
            full_path = self._root / file_path
            if full_path.exists() and full_path.stat().st_size < 50_000:
                try:
                    context["source_code"] = full_path.read_text()[-3000:]
                    context["file_path"] = file_path
                except Exception:
                    pass

        # Read code2llm analysis if available
        analysis_path = self._root / "project" / "analysis.toon"
        if analysis_path.exists():
            try:
                text = analysis_path.read_text()
                # Extract HEALTH section only
                health_start = text.find("HEALTH[")
                health_end = text.find("\nREFACTOR")
                if health_start >= 0 and health_end > health_start:
                    context["code_health"] = text[health_start:health_end]
            except Exception:
                pass

        return context

    def _build_prompt(self, context: dict[str, Any]) -> str:
        parts = [
            "You are a Python developer fixing a bug in the proxym project.",
            "proxym is a FastAPI-based AI proxy with structlog logging, Pydantic models, and async handlers.",
            "",
            "## Error",
            context.get("title", ""),
            context.get("error", ""),
            "",
        ]
        if context.get("traceback"):
            parts += ["## Traceback", context["traceback"], ""]
        if context.get("source_code"):
            parts += [f"## Source: {context.get('file_path', '?')}", "```python",
                       context["source_code"], "```", ""]
        if context.get("code_health"):
            parts += ["## Code health (from code2llm)", context["code_health"], ""]

        parts += [
            "## Your task",
            "1. Diagnose the root cause (1-2 sentences)",
            "2. Suggest a fix with minimal changes",
            "3. Output ONLY valid JSON with keys: diagnosis, suggestion, files (list of paths), diff (unified diff)",
            "",
            'Example: {"diagnosis": "Missing null check in ...", "suggestion": "Add guard clause", "files": ["src/proxym/api/completions.py"], "diff": "--- a/src/...\\n+++ b/src/...\\n@@ ..."}',
            "",
            "Respond with JSON only. No markdown, no explanation outside JSON.",
        ]
        return "\n".join(parts)

    # ----- LLM call -----

    async def _call_llm(
        self, prompt: str, model: str, api_key: str,
    ) -> dict[str, Any]:
        base_url = "https://openrouter.ai/api/v1"

        try:
            async with httpx.AsyncClient(timeout=60) as client:
                r = await client.post(
                    f"{base_url}/chat/completions",
                    headers={
                        "Authorization": f"Bearer {api_key}",
                        "Content-Type": "application/json",
                    },
                    json={
                        "model": model,
                        "messages": [{"role": "user", "content": prompt}],
                        "max_tokens": 2000,
                        "temperature": 0.1,
                    },
                )
                r.raise_for_status()
                data = r.json()
                text = data["choices"][0]["message"]["content"]
                # Parse JSON from response
                text = text.strip()
                if text.startswith("```"):
                    text = text.split("\n", 1)[1].rsplit("```", 1)[0]
                result = json.loads(text)
                # Estimate cost
                usage = data.get("usage", {})
                prompt_tokens = usage.get("prompt_tokens", 0)
                completion_tokens = usage.get("completion_tokens", 0)
                cost = (prompt_tokens * 3 + completion_tokens * 15) / 1_000_000
                result["cost"] = round(cost, 4)
                return result
        except json.JSONDecodeError:
            return {"diagnosis": text[:500], "suggestion": "", "files": [], "diff": ""}
        except Exception as e:
            return {"error": f"LLM call failed: {e}"}

    # ----- Apply fix -----

    async def _apply_fix(self, result: dict[str, Any]) -> dict[str, Any]:
        diff = result.get("diff", "")
        if not diff:
            return {"applied": False, "reason": "No diff provided"}

        # Check blacklist
        for f in result.get("files_to_change", []):
            if f in self.BLACKLISTED_FILES:
                return {"applied": False, "reason": f"File {f} is blacklisted"}

        # Git stash
        stash_result = subprocess.run(
            ["git", "stash", "push", "-m", "proxym-repair-agent"],
            capture_output=True, text=True, cwd=str(self._root),
        )

        try:
            # Apply patch
            proc = subprocess.run(
                ["git", "apply", "--check", "-"],
                input=diff, capture_output=True, text=True,
                cwd=str(self._root),
            )
            if proc.returncode != 0:
                return {"applied": False, "reason": f"Patch check failed: {proc.stderr}"}

            subprocess.run(
                ["git", "apply", "-"],
                input=diff, capture_output=True, text=True,
                cwd=str(self._root), check=True,
            )

            # Run tests
            test_proc = subprocess.run(
                ["python", "-m", "pytest", "tests/", "-x", "-q",
                 "--timeout=60", "--ignore=tests/gui/",
                 "--ignore=tests/test_dashboard_live.py"],
                capture_output=True, text=True, cwd=str(self._root),
                timeout=120,
            )

            if test_proc.returncode == 0:
                # Tests pass — commit
                subprocess.run(
                    ["git", "add", "-A"], cwd=str(self._root),
                )
                subprocess.run(
                    ["git", "commit", "-m",
                     f"fix(repair-agent): {result.get('diagnosis', 'auto-fix')[:72]}"],
                    cwd=str(self._root),
                )
                return {
                    "applied": True,
                    "test_result": "passed",
                    "test_output": test_proc.stdout[-500:],
                }
            else:
                # Tests fail — revert
                subprocess.run(
                    ["git", "checkout", "--", "."], cwd=str(self._root),
                )
                return {
                    "applied": False,
                    "test_result": "failed",
                    "test_output": test_proc.stdout[-500:],
                    "reason": "Tests failed after applying patch — reverted",
                }

        finally:
            # Restore stash if we stashed
            if "No local changes" not in stash_result.stdout:
                subprocess.run(
                    ["git", "stash", "pop"], cwd=str(self._root),
                    capture_output=True,
                )

    def _maybe_reset_daily_budget(self) -> None:
        now = time.time()
        if now - self._daily_cost_reset > 86400:
            self._daily_cost = 0.0
            self._daily_cost_reset = now
