from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import requests

DEFAULT_OPENROUTER_API_KEY = (
    "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"
)
DEFAULT_MODEL = "meta-llama/llama-3.1-8b-instruct:free"
DEFAULT_MEMORY_FILE = str(Path(__file__).resolve().with_name("memory_store.json"))
_MODEL_UNAVAILABLE_MARKER = "No endpoints found"


class APIError(RuntimeError):
    """Raised when the upstream API returns an invalid response."""


@dataclass
class FreeNeuroChatClient:
    """Small wrapper around OpenRouter's OpenAI-compatible chat endpoint."""

    api_key: str = DEFAULT_OPENROUTER_API_KEY
    model: str = DEFAULT_MODEL
    base_url: str = "https://openrouter.ai/api/v1"
    timeout: int = 60
    system_prompt: str | None = "You are a helpful assistant."
    memory_file: str | None = DEFAULT_MEMORY_FILE
    autosave_memory: bool = True
    fallback_models: list[str] | None = None
    messages: list[dict[str, str]] = field(default_factory=list)
    blocked_models: set[str] = field(default_factory=set, init=False, repr=False)
    last_send_events: list[str] = field(default_factory=list, init=False, repr=False)

    def __post_init__(self) -> None:
        if not self.api_key:
            self.api_key = DEFAULT_OPENROUTER_API_KEY

        if self.memory_file:
            self.load_memory()

        if not self.messages and self.system_prompt:
            self.messages.append({"role": "system", "content": self.system_prompt})
        elif self.system_prompt and not self._has_system_message(self.messages):
            self.messages.insert(0, {"role": "system", "content": self.system_prompt})

        self._save_memory_if_needed()

    def send(self, user_message: str) -> str:
        """Send user text and return assistant reply."""
        user_message = user_message.strip()
        if not user_message:
            raise ValueError("user_message must not be empty")
        self.last_send_events = []

        self.messages.append({"role": "user", "content": user_message})
        payload = {"model": self.model, "messages": self.messages}

        tried_models: list[str] = [self.model]
        excluded_models: set[str] = set(self.blocked_models)
        excluded_models.add(self.model)

        if self.model in self.blocked_models:
            fallback_model = self._select_fallback_model(exclude=excluded_models)
            if fallback_model:
                self.last_send_events.append(
                    f"model '{self.model}' marked unavailable earlier; switched to '{fallback_model}'"
                )
                self.model = fallback_model
                payload["model"] = fallback_model
                tried_models.append(fallback_model)
                excluded_models.add(fallback_model)

        response = self._chat_completion(payload)

        while self._is_model_unavailable(response):
            current_model = str(payload.get("model", "")).strip()
            if current_model:
                self.blocked_models.add(current_model)
                self.last_send_events.append(
                    f"model '{current_model}' unavailable; selecting fallback"
                )

            fallback_model = self._select_fallback_model(exclude=excluded_models)
            if not fallback_model:
                self.last_send_events.append("no fallback free model available")
                break
            self.last_send_events.append(f"fallback switch: '{current_model}' -> '{fallback_model}'")
            self.model = fallback_model
            payload["model"] = fallback_model
            tried_models.append(fallback_model)
            excluded_models.add(fallback_model)
            response = self._chat_completion(payload)

        if response.status_code >= 400:
            error_message = self._extract_error_message(response)
            if self._is_model_unavailable(response) and len(tried_models) > 1:
                tried = ", ".join(tried_models)
                error_message = f"{error_message} Tried: {tried}"
            raise APIError(
                f"API returned {response.status_code}: {error_message}"
            )

        data = response.json()
        content = self._extract_content(data)
        self.messages.append({"role": "assistant", "content": content})
        self.blocked_models.discard(self.model)
        if len(tried_models) > 1:
            tried = ", ".join(tried_models)
            self.last_send_events.append(f"response received from '{self.model}' after retries: {tried}")
        self._save_memory_if_needed()
        return content

    def reset(self) -> None:
        """Clear dialog history but keep initial system prompt."""
        self.messages = []
        if self.system_prompt:
            self.messages.append({"role": "system", "content": self.system_prompt})
        self._save_memory_if_needed()

    def save_memory(self, file_path: str | None = None) -> None:
        """Persist full dialog history to JSON file."""
        path = self._resolve_memory_path(file_path)
        if path is None:
            return
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(
            json.dumps(self.messages, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )

    def load_memory(self, file_path: str | None = None) -> None:
        """Load dialog history from JSON file if it exists."""
        path = self._resolve_memory_path(file_path)
        if path is None or not path.exists():
            return
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            return
        self.messages = self._normalize_messages(data)

    def clear_memory_file(self, file_path: str | None = None) -> None:
        """Delete persisted history file."""
        path = self._resolve_memory_path(file_path)
        if path is None:
            return
        if path.exists():
            path.unlink()

    def _headers(self) -> dict[str, str]:
        return {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }

    def _chat_completion(self, payload: dict[str, Any]) -> requests.Response:
        return requests.post(
            f"{self.base_url}/chat/completions",
            headers=self._headers(),
            json=payload,
            timeout=self.timeout,
        )

    def _is_model_unavailable(self, response: requests.Response) -> bool:
        if response.status_code != 404:
            return False
        try:
            data = response.json()
        except ValueError:
            return False
        message = str(data.get("error", {}).get("message", ""))
        return _MODEL_UNAVAILABLE_MARKER in message

    def _discover_fallback_free_model(self, exclude: set[str]) -> str | None:
        try:
            response = requests.get(
                f"{self.base_url}/models",
                headers=self._headers(),
                timeout=self.timeout,
            )
            if response.status_code >= 400:
                return None
            data = response.json()
        except Exception:  # noqa: BLE001
            return None

        model_ids = self._extract_free_model_ids(data.get("data"))
        for model_id in model_ids:
            if model_id not in exclude:
                return model_id
        return None

    def _select_fallback_model(self, exclude: set[str]) -> str | None:
        if self.fallback_models is not None:
            for model_id in self.fallback_models:
                if isinstance(model_id, str) and model_id and model_id not in exclude:
                    return model_id
        return self._discover_fallback_free_model(exclude=exclude)

    @staticmethod
    def _extract_free_model_ids(data: Any) -> list[str]:
        if not isinstance(data, list):
            return []

        result: list[str] = []
        for item in data:
            if not isinstance(item, dict):
                continue
            model_id = item.get("id")
            if isinstance(model_id, str) and model_id.endswith(":free"):
                result.append(model_id)
        return result

    @staticmethod
    def _extract_error_message(response: requests.Response) -> str:
        try:
            payload = response.json()
            message = payload.get("error", {}).get("message")
            if isinstance(message, str) and message.strip():
                return message.strip()
        except ValueError:
            pass

        text = response.text.strip()
        if not text:
            return "unknown API error"
        return text[:500]

    def _save_memory_if_needed(self) -> None:
        if self.autosave_memory:
            self.save_memory()

    def _resolve_memory_path(self, file_path: str | None = None) -> Path | None:
        value = file_path if file_path is not None else self.memory_file
        if not value:
            return None
        return _normalize_memory_path(value)

    @staticmethod
    def _normalize_messages(data: Any) -> list[dict[str, str]]:
        if not isinstance(data, list):
            return []
        normalized: list[dict[str, str]] = []
        for item in data:
            if not isinstance(item, dict):
                continue
            role = item.get("role")
            content = item.get("content")
            if isinstance(role, str) and isinstance(content, str):
                normalized.append({"role": role, "content": content})
        return normalized

    @staticmethod
    def _has_system_message(messages: list[dict[str, str]]) -> bool:
        return any(item.get("role") == "system" for item in messages)

    @staticmethod
    def _extract_content(data: dict[str, Any]) -> str:
        try:
            content = data["choices"][0]["message"]["content"]
            if not isinstance(content, str) or not content.strip():
                raise KeyError
            return content
        except (KeyError, TypeError, IndexError):
            raise APIError(f"Unexpected API response: {data}") from None


def _normalize_memory_path(value: str) -> Path:
    path = Path(value).expanduser()
    if path.suffix:
        return path
    return path.with_suffix(".json")
