"""
OpenAI Provider - Cloud LLM translation provider.

Extends TranslationProvider base class with robust error handling,
retry logic, and health monitoring for OpenAI API.

Features:
- Cloud LLM translation via OpenAI Chat Completions API
- Custom system prompt support
- Specific error codes for all OpenAI API errors
- Retry logic with exponential backoff for transient errors
- Timeout configuration (faster than local Ollama)
- Health check with caching
- Structlog-compatible logging (no document content in logs)
"""

import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

from core.logging import get_logger

logger = get_logger(__name__)

# structlog is the project's logger backend (see core/logging.py), but detect
# it rather than hardcoding True so the stdlib-logging fallback branches below
# are reachable if structlog is ever absent.
try:
    import structlog  # noqa: F401

    _HAS_STRUCTLOG = True
except ImportError:  # pragma: no cover - structlog is a hard project dep
    _HAS_STRUCTLOG = False


def _log_info(event: str, **kwargs):
    """Log info with structlog or standard logging compatibility."""
    if _HAS_STRUCTLOG:
        logger.info(event, **kwargs)
    else:
        msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
        logger.info(msg)


def _log_warning(event: str, **kwargs):
    """Log warning with structlog or standard logging compatibility."""
    if _HAS_STRUCTLOG:
        logger.warning(event, **kwargs)
    else:
        msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
        logger.warning(msg)


def _log_error(event: str, **kwargs):
    """Log error with structlog or standard logging compatibility."""
    if _HAS_STRUCTLOG:
        logger.error(event, **kwargs)
    else:
        msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
        logger.error(msg)


import requests
from requests.exceptions import Timeout, ConnectionError as RequestsConnectionError

from .base import TranslationProvider
from .schemas import (
    ProviderHealthStatus,
    TranslationRequest,
    TranslationResponse,
)

# Error codes
OPENAI_RATE_LIMITED = "OPENAI_RATE_LIMITED"
OPENAI_INVALID_KEY = "OPENAI_INVALID_KEY"
OPENAI_QUOTA_EXCEEDED = "OPENAI_QUOTA_EXCEEDED"
OPENAI_TIMEOUT = "OPENAI_TIMEOUT"
OPENAI_SERVICE_ERROR = "OPENAI_SERVICE_ERROR"
OPENAI_CONTEXT_TOO_LONG = "OPENAI_CONTEXT_TOO_LONG"

_RETRYABLE_ERRORS = {OPENAI_RATE_LIMITED, OPENAI_TIMEOUT, OPENAI_SERVICE_ERROR}


class OpenAIProviderError(Exception):
    """Exception raised for OpenAI API errors."""

    def __init__(
        self, code: str, message: str, details: Optional[Dict[str, Any]] = None
    ):
        self.code = code
        self.message = message
        self.details = details or {}
        super().__init__(message)

    def to_dict(self) -> Dict[str, Any]:
        """Convert error to dictionary format."""
        result = {
            "error": self.code,
            "message": self.message,
        }
        if self.details:
            result["details"] = self.details
        return result


DEFAULT_TRANSLATION_PROMPT = """You are a professional translator. Translate the following text from {source_lang} to {target_lang}.

Rules:
- Translate ONLY the text, do not add explanations or notes
- Preserve the original formatting, line breaks, and structure
- Maintain the original tone and style
- Translate technical terms, jargon, and labels using the standard target-language equivalent
- Chart elements (titles, axis labels, legend entries, category labels, series names) MUST always be translated, even when they look like a title or a proper noun
- Translate month and weekday abbreviations (Jan → janvier, Mon → lundi) to the target language
- Keep ONLY real proper nouns unchanged: people's names, place names, company names (Google, Microsoft), and product names (GitHub, HuggingFace)
- Do not invent content; if a term is an acronym with no translation (API, URL, HTTP, JSON), keep it as-is"""


def _build_system_prompt(
    source_lang: str, target_lang: str, custom_prompt: Optional[str] = None
) -> str:
    """Build system prompt for translation.

    The base translation instructions are ALWAYS present — a custom prompt
    (glossary, tone, context) is appended as additional directives, never a
    replacement. Previously a glossary-only custom prompt produced a system
    prompt with no translation instruction at all.
    """
    base = DEFAULT_TRANSLATION_PROMPT.format(
        source_lang=source_lang, target_lang=target_lang
    )
    if custom_prompt and custom_prompt.strip():
        return f"{base}\n\nADDITIONAL CONTEXT AND INSTRUCTIONS:\n{custom_prompt.strip()}"
    return base


def _get_language_name(code: str) -> str:
    """Convert language code to full name for better LLM understanding."""
    from core.languages import language_name

    return language_name(code)


class OpenAITranslationProvider(TranslationProvider):
    """
    OpenAI LLM implementation for cloud translation.

    Features:
    - Uses OpenAI Chat Completions API
    - Custom system prompt support for translation context
    - Thread-safe HTTP client
    - Robust error handling with specific error codes
    - Retry logic with exponential backoff
    - Configurable timeout (default 60s for cloud API)
    - Health check with result caching
    """

    def __init__(
        self,
        api_key: str,
        model: str = "gpt-4o-mini",
        timeout: int = 60,
        max_retries: int = 3,
        retry_delay: float = 1.0,
        base_url: str = "https://api.openai.com/v1",
        health_check_timeout: int = 5,
    ):
        """
        Initialize OpenAI provider.

        Args:
            api_key: OpenAI API key
            model: Model name to use (default: gpt-4o-mini)
            timeout: Request timeout in seconds (default: 60)
            max_retries: Maximum retry attempts for transient errors (default: 3)
            retry_delay: Initial retry delay in seconds (default: 1.0)
            base_url: OpenAI API base URL (default: https://api.openai.com/v1)
            health_check_timeout: Timeout for health check requests in seconds (default: 5)
        """
        if not api_key or not api_key.strip():
            raise ValueError("OpenAI API key cannot be empty")

        self._api_key = api_key
        self._model = model
        self._base_url = base_url.rstrip("/")
        self._provider_name = "openai"
        self._timeout = timeout
        self._max_retries = max_retries
        self._retry_delay = retry_delay
        self._health_check_timeout = health_check_timeout
        self._health_cache: Dict[str, Any] = {}
        self._health_cache_ttl = 60
        self._health_cache_lock = threading.Lock()

    def _make_api_request(self, text: str, system_prompt: str) -> tuple:
        """
        Make API request to OpenAI.

        Returns:
            Tuple of (translated_content, usage_dict). usage_dict may be empty.

        Raises:
            OpenAIProviderError: For any API errors with specific codes
        """
        if not text or not text.strip():
            return text, {}

        # Check text length (rough estimate: 1 token ~= 4 chars)
        if len(text) > 16000:  # ~4000 tokens
            raise OpenAIProviderError(
                code=OPENAI_CONTEXT_TOO_LONG,
                message="Texte trop long pour le modèle (max ~4000 tokens).",
                details={"text_length": len(text), "max_tokens": 4000},
            )

        url = f"{self._base_url}/chat/completions"
        headers = {
            "Authorization": f"Bearer {self._api_key}",
            "Content-Type": "application/json",
        }
        payload = {
            "model": self._model,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": text},
            ],
            "temperature": 0.3,
            "max_tokens": 4096,
        }

        try:
            response = requests.post(
                url,
                headers=headers,
                json=payload,
                timeout=self._timeout,
            )

            # Handle specific HTTP status codes
            if response.status_code == 401:
                raise OpenAIProviderError(
                    code=OPENAI_INVALID_KEY,
                    message="Clé API OpenAI invalide. Vérifiez votre configuration.",
                    details={"status_code": 401},
                )

            if response.status_code == 429:
                try:
                    error_data = response.json().get("error", {}) or {}
                except Exception:
                    error_data = {}
                error_code = error_data.get("code", "")

                # Check for rate limit vs quota exceeded
                if error_code == "insufficient_quota":
                    raise OpenAIProviderError(
                        code=OPENAI_QUOTA_EXCEEDED,
                        message="Quota OpenAI épuisé. Vérifiez votre facturation.",
                        details={"status_code": 429, "error_code": error_code},
                    )
                else:
                    # Rate limit
                    retry_after = response.headers.get("retry-after", "20")
                    raise OpenAIProviderError(
                        code=OPENAI_RATE_LIMITED,
                        message=f"Limite de requêtes OpenAI atteinte. Réessayez dans {retry_after}s.",
                        details={
                            "status_code": 429,
                            "retry_after_seconds": int(retry_after)
                            if retry_after.isdigit()
                            else 20,
                        },
                    )

            if response.status_code == 400:
                try:
                    error_data = response.json().get("error", {}) or {}
                except Exception:
                    error_data = {}
                error_code = error_data.get("code", "")

                if error_code == "context_length_exceeded":
                    raise OpenAIProviderError(
                        code=OPENAI_CONTEXT_TOO_LONG,
                        message="Texte trop long pour le modèle (max ~4000 tokens).",
                        details={"status_code": 400, "error_code": error_code},
                    )

            if response.status_code >= 500:
                raise OpenAIProviderError(
                    code=OPENAI_SERVICE_ERROR,
                    message="Service OpenAI temporairement indisponible.",
                    details={"status_code": response.status_code},
                )

            if response.status_code != 200:
                error_text = response.text[:200] if response.text else "Unknown error"
                raise OpenAIProviderError(
                    code=OPENAI_SERVICE_ERROR,
                    message=f"Erreur OpenAI: {error_text}",
                    details={"status_code": response.status_code},
                )

            data = response.json()
            choices = data.get("choices", [])

            if not choices:
                raise OpenAIProviderError(
                    code=OPENAI_SERVICE_ERROR,
                    message="Erreur OpenAI: réponse vide",
                    details={"response": str(data)[:200]},
                )

            content = choices[0].get("message", {}).get("content", "")

            if not content:
                raise OpenAIProviderError(
                    code=OPENAI_SERVICE_ERROR,
                    message="Erreur OpenAI: réponse vide",
                    details={"response": str(data)[:200]},
                )

            usage = data.get("usage", {})
            return content.strip(), usage

        except Timeout:
            raise OpenAIProviderError(
                code=OPENAI_TIMEOUT,
                message="Délai d'attente OpenAI dépassé. Le service est lent.",
                details={"timeout_seconds": self._timeout},
            )
        except RequestsConnectionError:
            raise OpenAIProviderError(
                code=OPENAI_SERVICE_ERROR,
                message="Service OpenAI temporairement indisponible.",
                details={"error": "Connection failed"},
            )
        except OpenAIProviderError:
            raise
        except Exception as e:
            error_str = str(e).lower()
            if "connection" in error_str or "refused" in error_str:
                raise OpenAIProviderError(
                    code=OPENAI_SERVICE_ERROR,
                    message="Service OpenAI temporairement indisponible.",
                    details={"original_error": str(e)[:100]},
                )
            raise OpenAIProviderError(
                code=OPENAI_SERVICE_ERROR,
                message=f"Erreur OpenAI: {str(e)[:100]}",
                details={"original_error": str(e)[:100]},
            )

    def get_name(self) -> str:
        """Return provider name."""
        return self._provider_name

    def is_available(self) -> bool:
        """
        Check if OpenAI API is available.

        Uses cached result if available and not expired.
        """
        current_time = time.time()

        with self._health_cache_lock:
            if "is_available" in self._health_cache:
                cached = self._health_cache["is_available"]
                if current_time - cached["timestamp"] < self._health_cache_ttl:
                    return cached["value"]

        try:
            url = f"{self._base_url}/models"
            headers = {"Authorization": f"Bearer {self._api_key}"}
            response = requests.get(
                url, headers=headers, timeout=self._health_check_timeout
            )
            available = response.status_code == 200
        except Exception as e:
            _log_warning("openai_availability_check_failed", error=str(e)[:100])
            available = False

        with self._health_cache_lock:
            self._health_cache["is_available"] = {
                "value": available,
                "timestamp": current_time,
            }

        return available

    def translate_text(self, request: TranslationRequest) -> TranslationResponse:
        """
        Translate a single text string using OpenAI LLM.

        Supports custom system prompt via request.metadata["custom_prompt"].

        Args:
            request: TranslationRequest with text and language info

        Returns:
            TranslationResponse with translated text
        """
        text = request.text
        target_language = request.target_language
        source_language = request.source_language or "auto"

        if not text or not text.strip():
            return TranslationResponse(
                translated_text=text,
                provider_name=self._provider_name,
                from_cache=False,
            )

        source_lang_name = _get_language_name(source_language)
        target_lang_name = _get_language_name(target_language)

        custom_prompt = None
        if request.metadata:
            custom_prompt = request.metadata.get("custom_prompt")

        system_prompt = _build_system_prompt(
            source_lang_name, target_lang_name, custom_prompt
        )

        last_error: Optional[OpenAIProviderError] = None
        retries = 0

        while retries <= self._max_retries:
            try:
                start_time = time.time()
                result, usage = self._make_api_request(text, system_prompt)
                latency = time.time() - start_time

                log_kw: Dict[str, Any] = {
                    "chars": len(text),
                    "source_lang": source_language,
                    "target_lang": target_language,
                    "model": self._model,
                    "latency_ms": round(latency * 1000, 2),
                    "retries": retries,
                }
                if usage and isinstance(usage.get("total_tokens"), (int, float)):
                    log_kw["tokens_used"] = usage.get("total_tokens")
                _log_info("openai_translation_success", **log_kw)

                return TranslationResponse(
                    translated_text=result,
                    provider_name=self._provider_name,
                    from_cache=False,
                    source_language=source_language,
                )

            except OpenAIProviderError as e:
                last_error = e

                if e.code not in _RETRYABLE_ERRORS:
                    break

                retries += 1
                if retries <= self._max_retries:
                    delay = self._retry_delay * (2 ** (retries - 1))
                    _log_info(
                        "openai_translation_retry",
                        attempt=retries,
                        delay_s=round(delay, 2),
                        error_code=e.code,
                        text_length=len(text),
                        source_lang=source_language,
                        target_lang=target_language,
                    )
                    time.sleep(delay)

            except Exception as e:
                last_error = OpenAIProviderError(
                    code=OPENAI_SERVICE_ERROR,
                    message=f"Erreur OpenAI: {str(e)[:100]}",
                    details={"original_error": str(e)[:100]},
                )
                retries += 1
                if retries <= self._max_retries:
                    delay = self._retry_delay * (2 ** (retries - 1))
                    time.sleep(delay)

        if last_error:
            _log_error(
                "openai_translation_failed",
                error_code=last_error.code,
                text_length=len(text),
                source_lang=source_language,
                target_lang=target_language,
                retries=retries,
            )
            return TranslationResponse(
                translated_text=text,
                provider_name=self._provider_name,
                from_cache=False,
                error=last_error.message,
                error_code=last_error.code,
                error_details=last_error.details,
            )

        return TranslationResponse(
            translated_text=text,
            provider_name=self._provider_name,
            from_cache=False,
            error="Unknown error",
            error_code=OPENAI_SERVICE_ERROR,
        )

    def _make_batch_api_request(
        self, texts: List[str], system_prompt: str
    ) -> Optional[List[str]]:
        """Translate a whole chunk in ONE request via a numbered JSON list.

        The user message is a JSON array; the model must answer with a JSON
        array of the same length. Returns None when the answer cannot be
        parsed confidently — callers then fall back to per-item calls
        (correctness over latency).
        """
        import json as _json

        numbered = _json.dumps(
            [{"id": i, "text": t} for i, t in enumerate(texts)],
            ensure_ascii=False,
        )
        batch_system = (
            system_prompt
            + "\n\nBATCH MODE: the user message is a JSON array of items with "
            "unique ids. Answer with ONLY a JSON array of objects "
            '[{"id": <same id>, "translation": "<translated text>"}], same '
            "length and same ids, in the same order. Translate every item; "
            "keep ids unchanged; no comments, no markdown fence."
        )

        try:
            content, _usage = self._make_api_request(numbered, batch_system)
            raw = content.strip()
            # Strip an optional markdown fence
            if raw.startswith("```"):
                raw = raw.strip("`")
                if raw.lower().startswith("json"):
                    raw = raw[4:]
                raw = raw.strip()
            parsed = _json.loads(raw)
            if not isinstance(parsed, list) or len(parsed) != len(texts):
                return None
            out: List[str] = [""] * len(texts)
            for item in parsed:
                if not isinstance(item, dict):
                    return None
                idx = item.get("id")
                translation = item.get("translation")
                if not isinstance(idx, int) or not 0 <= idx < len(texts):
                    return None
                if not isinstance(translation, str) or not translation.strip():
                    return None
                out[idx] = translation.strip()
            return out
        except OpenAIProviderError:
            raise
        except Exception:
            return None

    def translate_batch(
        self, requests: List[TranslationRequest]
    ) -> List[TranslationResponse]:
        """
        Translate multiple texts.

        Chunks arrive from the translators as ~15 texts. When every request
        shares the same language pair and metadata, they are sent in ONE
        call (numbered JSON list — ~15× fewer requests, better contextual
        consistency across neighbouring segments). Any parse/API doubt falls
        back to the per-item path so a batch failure never corrupts output.
        """
        if not requests:
            return []

        same_pair = len({(r.source_language, r.target_language) for r in requests}) == 1
        same_meta = len(
            {tuple(sorted((r.metadata or {}).items())) for r in requests}
        ) == 1

        if same_pair and same_meta and len(requests) > 1:
            try:
                source_lang_name = _get_language_name(
                    requests[0].source_language or "auto"
                ) or "the source language (auto-detect)"
                target_lang_name = _get_language_name(requests[0].target_language)
                custom_prompt = None
                if requests[0].metadata:
                    custom_prompt = requests[0].metadata.get("custom_prompt")
                system_prompt = _build_system_prompt(
                    source_lang_name, target_lang_name, custom_prompt
                )
                texts = [r.text for r in requests]
                translations = self._make_batch_api_request(texts, system_prompt)
                if translations is not None:
                    _log_info(
                        "openai_batch_translation_success",
                        items=len(requests),
                        model=self._model,
                    )
                    return [
                        TranslationResponse(
                            translated_text=t,
                            provider_name=self._provider_name,
                            from_cache=False,
                        )
                        for t in translations
                    ]
                _log_warning(
                    "openai_batch_translation_fallback",
                    reason="unparseable_response",
                    items=len(requests),
                )
            except Exception as e:
                _log_warning(
                    "openai_batch_translation_fallback",
                    reason=type(e).__name__,
                    items=len(requests),
                )

        return [self.translate_text(req) for req in requests]

    def health_check(self) -> ProviderHealthStatus:
        """
        Return health status details for the provider.

        Includes cached result for efficiency.

        Returns:
            ProviderHealthStatus with availability, latency, and model information
        """
        current_time = time.time()

        with self._health_cache_lock:
            if "health_check" in self._health_cache:
                cached = self._health_cache["health_check"]
                if current_time - cached["timestamp"] < self._health_cache_ttl:
                    return cached["value"]

        start_time = time.time()
        last_check_iso = datetime.now(timezone.utc).isoformat()

        try:
            url = f"{self._base_url}/models"
            headers = {"Authorization": f"Bearer {self._api_key}"}
            response = requests.get(
                url, headers=headers, timeout=self._health_check_timeout
            )
            latency_ms = (time.time() - start_time) * 1000
            available = response.status_code == 200

            error_msg = None
            model_available = None
            if available:
                try:
                    models_data = response.json().get("data", [])
                    model_ids = [m.get("id", "") for m in models_data]
                    model_available = self._model in model_ids or any(
                        self._model in mid for mid in model_ids
                    )
                except Exception:
                    model_available = None
            else:
                if response.status_code == 401:
                    error_msg = "Invalid API key"
                else:
                    error_msg = f"OpenAI API returned {response.status_code}"

            status = ProviderHealthStatus(
                name=self._provider_name,
                available=available,
                latency_ms=round(latency_ms, 2),
                error=error_msg,
                last_check=last_check_iso,
                model=self._model,
                model_available=model_available,
            )
        except Exception as e:
            latency_ms = (time.time() - start_time) * 1000
            status = ProviderHealthStatus(
                name=self._provider_name,
                available=False,
                latency_ms=round(latency_ms, 2),
                error=str(e)[:100],
                last_check=last_check_iso,
                model=self._model,
                model_available=False,
            )

        with self._health_cache_lock:
            self._health_cache["health_check"] = {
                "value": status,
                "timestamp": current_time,
            }

        return status


def register_openai_provider():
    """
    Register the OpenAI provider in the global registry.

    This function should be called during module initialization
    to make the provider available through the registry.
    """
    from .registry import registry

    provider = get_openai_provider()
    registry.register("openai", provider)
    return provider


_provider_instance: Optional[OpenAITranslationProvider] = None
_provider_lock = threading.Lock()


def get_openai_provider() -> OpenAITranslationProvider:
    """Get or create the OpenAI provider instance (reads config from env)."""
    global _provider_instance
    if _provider_instance is None:
        with _provider_lock:
            if _provider_instance is None:
                from .config import ProvidersConfig

                _provider_instance = OpenAITranslationProvider(
                    api_key=ProvidersConfig.OPENAI_API_KEY,
                    model=ProvidersConfig.OPENAI_MODEL,
                    timeout=ProvidersConfig.OPENAI_TIMEOUT,
                    max_retries=ProvidersConfig.OPENAI_MAX_RETRIES,
                    retry_delay=ProvidersConfig.OPENAI_RETRY_DELAY,
                    base_url=ProvidersConfig.OPENAI_BASE_URL,
                    health_check_timeout=ProvidersConfig.OPENAI_HEALTH_CHECK_TIMEOUT,
                )
    return _provider_instance


def reset_openai_provider() -> None:
    """Reset the OpenAI provider singleton (useful when config changes)."""
    global _provider_instance
    with _provider_lock:
        _provider_instance = None
