"""
candy.client — Client HTTP Cedric8-Thinking v7 (v20)
=====================================================
Nouveautés v20 :
  - Retry automatique avec backoff exponentiel
  - Timeout configurable par profil
  - Cache de réponses (mode offline fallback)
  - Support async natif (ask_async / stream_async / batch_async)
"""

import httpx
import asyncio
import json
import time
import hashlib
from typing import Optional, Generator, AsyncGenerator
from pathlib import Path

CANDY_JSON_URL = "https://client.hubworld.net/candy.json"

# ── Cache offline ─────────────────────────────────────────────────────────────
_CACHE_DIR = Path.home() / ".candy" / "cache"

def _cache_key(personality: str, prompt: str, profile) -> str:
    raw = f"{personality}|{prompt}|{getattr(profile,'lang','EN')}|{getattr(profile,'style','default')}"
    return hashlib.md5(raw.encode()).hexdigest()

def _cache_get(key: str) -> Optional[dict]:
    path = _CACHE_DIR / f"{key}.json"
    if path.exists():
        try:
            return json.loads(path.read_text(encoding="utf-8"))
        except Exception:
            return None
    return None

def _cache_set(key: str, data: dict):
    try:
        _CACHE_DIR.mkdir(parents=True, exist_ok=True)
        (_CACHE_DIR / f"{key}.json").write_text(
            json.dumps(data, ensure_ascii=False), encoding="utf-8"
        )
    except Exception:
        pass

# ── Instructions langue / style ───────────────────────────────────────────────
LANG_INSTRUCTIONS = {
    "FR": "Tu dois OBLIGATOIREMENT répondre en français. Chaque mot de ta réponse doit être en français.",
    "EN": "You MUST respond in English. Every single word of your response must be in English.",
    "ES": "DEBES responder en español. Cada palabra de tu respuesta debe estar en español.",
    "DE": "Du MUSST auf Deutsch antworten. Jedes Wort deiner Antwort muss auf Deutsch sein.",
    "IT": "DEVI rispondere in italiano. Ogni parola della tua risposta deve essere in italiano.",
    "PT": "DEVE responder em português. Cada palavra da sua resposta deve estar em português.",
    "ZH": "你必须用中文回答。你回答中的每个字都必须是中文。",
    "JA": "必ず日本語で回答してください。回答のすべての言葉は日本語でなければなりません。",
    "AR": "يجب عليك الإجابة باللغة العربية. كل كلمة في إجابتك يجب أن تكون بالعربية.",
    "RU": "Ты ОБЯЗАН отвечать на русском языке. Каждое слово твоего ответа должно быть на русском.",
    "NL": "Je MOET in het Nederlands antwoorden. Elk woord van je antwoord moet in het Nederlands zijn.",
    "PL": "MUSISZ odpowiadać po polsku. Każde słowo twojej odpowiedzi musi być po polsku.",
    "SV": "Du MÅSTE svara på svenska. Varje ord i ditt svar måste vara på svenska.",
    "TR": "TÜRKÇE yanıt vermelisin. Cevabındaki her kelime Türkçe olmalıdır.",
    "KO": "반드시 한국어로 대답해야 합니다. 답변의 모든 단어는 한국어여야 합니다.",
    "HI": "आपको हिंदी में जवाब देना होगा। आपके जवाब का हर शब्द हिंदी में होना चाहिए।",
    "VI": "Bạn PHẢI trả lời bằng tiếng Việt. Mỗi từ trong câu trả lời phải bằng tiếng Việt.",
    "ID": "Anda HARUS menjawab dalam bahasa Indonesia. Setiap kata harus dalam bahasa Indonesia.",
    "CS": "MUSÍŠ odpovídat česky. Každé slovo tvé odpovědi musí být česky.",
    "RO": "TREBUIE să răspunzi în română. Fiecare cuvânt al răspunsului tău trebuie să fie în română.",
}

STYLE_INSTRUCTIONS = {
    "default":   "",
    "concise":   "Be concise and direct. No filler. Get straight to the point.",
    "detailed":  "Be thorough. Include explanations, nuances, and concrete examples.",
    "bullet":    "Structure your entire response as bullet points.",
    "academic":  "Use a formal, structured, academic tone with clear sections.",
    "casual":    "Use a casual, friendly, conversational tone.",
    "technical": "Use precise technical language. Assume deep expertise. Be exact.",
    "eli5":      "Explain as if to a 5-year-old. Simple words, short sentences, analogies.",
}

TONE_INSTRUCTIONS = {
    "neutral":     "",
    "encouraging": "Be positive, motivating, and encouraging.",
    "strict":      "Be direct and strict. No sugarcoating.",
    "humorous":    "Add appropriate humor to your response.",
    "empathetic":  "Be warm, empathetic, and understanding.",
    "socratic":    "Guide with questions rather than giving direct answers.",
}

FORMAT_INSTRUCTIONS = {
    "text":     "",
    "markdown": "Format using Markdown: ## headers, **bold**, bullet lists, ``` code blocks.",
    "json":     "Return ONLY a valid JSON object. No prose outside the JSON.",
    "html":     "Format using HTML: <h2>, <p>, <ul>, <li>, <code>, <strong>, <em>.",
}

EXPERTISE_INSTRUCTIONS = {
    "beginner":     "The user is a beginner. Explain everything from scratch, define all terms.",
    "intermediate": "The user has basic knowledge. Skip trivial explanations.",
    "expert":       "The user is an expert. Skip basics. Go deep and precise.",
}


class CandyUnavailableError(Exception):
    pass

class CandyQuotaError(Exception):
    pass

class CandyTimeoutError(Exception):
    pass


class CandyClient:

    def __init__(self):
        self._api_url: Optional[str] = None

    # ── URL resolution ────────────────────────────────────────────────────────

    def _resolve_url(self, profile) -> str:
        if profile.cache_url and self._api_url:
            return self._api_url
        try:
            resp = httpx.get(CANDY_JSON_URL, timeout=10)
            resp.raise_for_status()
            data = resp.json()
        except Exception as e:
            raise CandyUnavailableError(f"Cannot reach candy.json: {e}")
        if data.get("is_closed", True):
            raise CandyUnavailableError("Cedric8-Thinking API is offline.")
        link = data.get("link", "").strip().rstrip("/")
        if not link:
            raise CandyUnavailableError("No API link in candy.json.")
        self._api_url = link
        return self._api_url

    async def _resolve_url_async(self, profile) -> str:
        if profile.cache_url and self._api_url:
            return self._api_url
        try:
            async with httpx.AsyncClient() as client:
                resp = await client.get(CANDY_JSON_URL, timeout=10)
                resp.raise_for_status()
                data = resp.json()
        except Exception as e:
            raise CandyUnavailableError(f"Cannot reach candy.json: {e}")
        if data.get("is_closed", True):
            raise CandyUnavailableError("Cedric8-Thinking API is offline.")
        link = data.get("link", "").strip().rstrip("/")
        if not link:
            raise CandyUnavailableError("No API link in candy.json.")
        self._api_url = link
        return self._api_url

    def invalidate_cache(self):
        self._api_url = None

    # ── Prompt building ───────────────────────────────────────────────────────

    def _build_suffix(self, profile) -> str:
        parts = []
        lang_instr = LANG_INSTRUCTIONS.get(
            profile.lang.upper(),
            f"You MUST respond in {profile.lang}. Every word must be in {profile.lang}."
        )
        parts.append(lang_instr)
        for m, k in [
            (STYLE_INSTRUCTIONS,     profile.style),
            (TONE_INSTRUCTIONS,      profile.tone),
            (FORMAT_INSTRUCTIONS,    profile.output_format),
            (EXPERTISE_INSTRUCTIONS, profile.expertise),
        ]:
            v = m.get(k, "")
            if v:
                parts.append(v)
        if not profile.include_examples:
            parts.append("Do not include examples.")
        if profile.safe_mode:
            parts.append("Refuse politely if the request involves harmful or illegal content.")
        if profile.max_tokens < 300:
            parts.append(f"Keep under {profile.max_tokens} tokens. Be extremely brief.")
        elif profile.max_tokens > 2000:
            parts.append(f"Be as detailed as needed (up to {profile.max_tokens} tokens).")
        return "\n".join(parts)

    def _build_prompt_with_lang(self, prompt: str, profile) -> str:
        lang = profile.lang.upper()
        lang_instr = LANG_INSTRUCTIONS.get(lang, f"Respond in {lang}.")
        return f"[INSTRUCTION OBLIGATOIRE: {lang_instr}]\n\n{prompt}"

    def _build_payload(self, personality: str, prompt: str, profile,
                       context=None, stream=False, history=None) -> dict:
        augmented_prompt = self._build_prompt_with_lang(prompt, profile)
        payload = {
            "prompt": augmented_prompt,
            "stream": stream,
            "config": {
                "lang":             profile.lang,
                "max_tokens":       profile.max_tokens,
                "temperature":      profile.temperature,
                "style":            profile.style,
                "output_format":    profile.output_format,
                "tone":             profile.tone,
                "expertise":        profile.expertise,
                "include_examples": profile.include_examples,
                "safe_mode":        profile.safe_mode,
                "system_suffix":    self._build_suffix(profile),
            },
        }
        if context:
            payload["context"] = context
        if history:
            payload["history"] = history
        return payload

    # ── Sync API ──────────────────────────────────────────────────────────────

    def ask(self, personality: str, prompt: str, profile,
            context: Optional[str] = None,
            stream: bool = False,
            history: Optional[list] = None,
            use_cache: bool = False):

        base = self._resolve_url(profile)
        url  = f"{base}/ask/{personality}"
        payload = self._build_payload(personality, prompt, profile,
                                      context=context, stream=stream, history=history)

        if getattr(profile, "verbose", False):
            pname = object.__getattribute__(profile, "_name")
            print(f"\033[90m[candy] POST {url} | profile={pname} | lang={profile.lang} | personality={personality}\033[0m")

        if stream:
            return self._stream(url, payload, profile)

        if use_cache:
            ckey = _cache_key(personality, prompt, profile)
            cached = _cache_get(ckey)
            if cached:
                if getattr(profile, "verbose", False):
                    print("\033[90m[candy] ↩ Réponse depuis le cache\033[0m")
                return cached

        result = self._post_with_retry(url, payload, profile, personality=personality, prompt=prompt)

        if use_cache:
            ckey = _cache_key(personality, prompt, profile)
            _cache_set(ckey, result)

        return result

    def _post_with_retry(self, url: str, payload: dict, profile,
                         personality: str = "", prompt: str = "") -> dict:
        """Retry avec backoff exponentiel : 1.5s → 3s → 6s."""
        max_retries = getattr(profile, "max_retries", 3)
        last_err = None
        for attempt in range(max(1, max_retries)):
            try:
                resp = httpx.post(url, json=payload, timeout=profile.timeout)
                if resp.status_code == 429:
                    raise CandyQuotaError("Daily quota exceeded.")
                if resp.status_code == 404:
                    raise ValueError("Unknown personality.")
                resp.raise_for_status()
                return resp.json()
            except (CandyQuotaError, ValueError):
                raise
            except httpx.TimeoutException:
                last_err = CandyTimeoutError(f"Timeout after {profile.timeout}s.")
            except httpx.ConnectError:
                self._api_url = None
                last_err = CandyUnavailableError("Cannot connect to Cedric8-Thinking.")
                # Offline fallback: cherche dans le cache
                if personality and prompt:
                    ckey = _cache_key(personality, prompt, profile)
                    cached = _cache_get(ckey)
                    if cached:
                        print("\033[93m[candy] ⚠ API hors ligne — réponse depuis le cache offline\033[0m")
                        return cached
            except Exception as e:
                last_err = e
            if attempt < max_retries - 1:
                wait = 1.5 * (2 ** attempt)
                if getattr(profile, "verbose", False):
                    print(f"\033[90m[candy] Retry {attempt+1}/{max_retries-1} dans {wait:.1f}s...\033[0m")
                time.sleep(wait)
        raise last_err

    def _stream(self, url: str, payload: dict, profile) -> Generator[str, None, None]:
        try:
            with httpx.stream("POST", url, json=payload, timeout=profile.timeout) as resp:
                if resp.status_code == 429:
                    raise CandyQuotaError("Daily quota exceeded.")
                resp.raise_for_status()
                for line in resp.iter_lines():
                    if line:
                        try:
                            data = json.loads(line)
                            token = data.get("response", "")
                            if token:
                                yield token
                        except json.JSONDecodeError:
                            pass
        except httpx.TimeoutException:
            raise CandyTimeoutError(f"Stream timeout after {profile.timeout}s.")

    # ── Async API (v20) ───────────────────────────────────────────────────────

    async def ask_async(self, personality: str, prompt: str, profile,
                        context: Optional[str] = None,
                        history: Optional[list] = None,
                        use_cache: bool = False) -> str:
        """
        Version async de ask(). Compatible asyncio / FastAPI.

        Example:
            import asyncio
            from candy import Coding
            result = asyncio.run(Coding.ask_async("Write a bubble sort"))
        """
        if use_cache:
            ckey = _cache_key(personality, prompt, profile)
            cached = _cache_get(ckey)
            if cached:
                return cached.get("response", "")

        base = await self._resolve_url_async(profile)
        url  = f"{base}/ask/{personality}"
        payload = self._build_payload(personality, prompt, profile,
                                      context=context, stream=False, history=history)

        max_retries = getattr(profile, "max_retries", 3)
        last_err = None
        async with httpx.AsyncClient() as client:
            for attempt in range(max(1, max_retries)):
                try:
                    resp = await client.post(url, json=payload, timeout=profile.timeout)
                    if resp.status_code == 429:
                        raise CandyQuotaError("Daily quota exceeded.")
                    resp.raise_for_status()
                    result = resp.json()
                    if use_cache:
                        ckey = _cache_key(personality, prompt, profile)
                        _cache_set(ckey, result)
                    return result.get("response", "")
                except (CandyQuotaError, ValueError):
                    raise
                except Exception as e:
                    last_err = e
                    if attempt < max_retries - 1:
                        await asyncio.sleep(1.5 * (2 ** attempt))
        raise last_err

    async def stream_async(self, personality: str, prompt: str, profile,
                           context: Optional[str] = None) -> AsyncGenerator[str, None]:
        """
        Streaming async token par token.

        Example:
            async for token in Coding.stream_async("Write a sort"):
                print(token, end="", flush=True)
        """
        base = await self._resolve_url_async(profile)
        url  = f"{base}/ask/{personality}"
        payload = self._build_payload(personality, prompt, profile,
                                      context=context, stream=True)
        async with httpx.AsyncClient() as client:
            async with client.stream("POST", url, json=payload, timeout=profile.timeout) as resp:
                if resp.status_code == 429:
                    raise CandyQuotaError("Daily quota exceeded.")
                resp.raise_for_status()
                async for line in resp.aiter_lines():
                    if line:
                        try:
                            data = json.loads(line)
                            token = data.get("response", "")
                            if token:
                                yield token
                        except json.JSONDecodeError:
                            pass

    async def batch_async(self, personality: str, prompts: list, profile,
                          context: Optional[str] = None) -> list:
        """
        Batch async — exécute toutes les requêtes en parallèle.

        Example:
            import asyncio
            from candy import Coding
            results = asyncio.run(Coding.batch_async(["sort", "reverse", "search"]))
        """
        tasks = [
            self.ask_async(personality, p, profile, context=context)
            for p in prompts
        ]
        results = await asyncio.gather(*tasks, return_exceptions=True)
        return [
            r if not isinstance(r, Exception) else f"[ERREUR] {r}"
            for r in results
        ]

    # ── Utilitaires ───────────────────────────────────────────────────────────

    def batch(self, personality: str, prompts: list, profile,
              context: Optional[str] = None) -> list:
        results = []
        for prompt in prompts:
            try:
                result = self.ask(personality, prompt, profile, context=context)
                results.append(result.get("response", ""))
            except Exception as e:
                results.append(f"[ERREUR] {e}")
        return results

    def quota(self, profile) -> dict:
        base = self._resolve_url(profile)
        resp = httpx.get(f"{base}/quota/status", timeout=10)
        resp.raise_for_status()
        return resp.json()

    def personalities(self, profile) -> list:
        base = self._resolve_url(profile)
        resp = httpx.get(f"{base}/personalities", timeout=10)
        resp.raise_for_status()
        return resp.json()["personalities"]

    def ping(self, profile) -> float:
        try:
            base = self._resolve_url(profile)
            t = time.time()
            httpx.get(f"{base}/", timeout=5)
            return round((time.time() - t) * 1000, 1)
        except Exception:
            return -1.0

    def is_online(self, profile) -> bool:
        try:
            self._resolve_url(profile)
            return True
        except Exception:
            return False

    def clear_response_cache(self):
        """Vide le cache de réponses offline."""
        if _CACHE_DIR.exists():
            for f in _CACHE_DIR.glob("*.json"):
                f.unlink(missing_ok=True)
        print("\033[90m[candy] Cache de réponses vidé.\033[0m")


_client = CandyClient()
