"""
Centralized LLM Service.

When ``oci_call`` is enabled in ``initial_config.txt`` (plus OIC URL and env Basic auth),
all chat and session text generation in this module uses the OIC Anthropic REST integration
instead of OpenAI or local Qwen. Embeddings are unavailable in that mode (``create_embedding`` raises).

IS_QWEN_LLM (is_qwen_llm): true = Qwen only (no OpenAI fallback); false = OpenAI only;
auto = for summary, @web compare, @askme analysis/chart, and continuation talkback scoped calls: Qwen first, then OpenAI fallback.
Other LLM calls (non-scoped) use OpenAI — unless ``oci_call`` is on (OIC), or ``use_claude`` is on with ``oci_call`` off (Anthropic API).
Embeddings always use OpenAI when an OpenAI key is configured.

TODO(timeout): the 5-minute outbound timeout policy enforced in ``app.services.api_call``
(see ``api_request_timeout_seconds()`` and ``_format_timeout_message``) is intentionally
NOT applied to LLM calls in this module. OIC currently uses ``httpx.Timeout(120s)`` /
``httpx.Timeout(180s, connect=15s)`` and OpenAI/Anthropic use their SDK defaults. A
follow-up should align these with the global API timeout policy and return the same
canonical ``"Request timed out after N seconds"`` string on failure.
"""
from typing import List, Optional, Union, Dict, Any, Tuple
import copy
import json
import logging
import os
import time
import threading

import httpx
from anthropic import NotFoundError
from openai import OpenAI

from app.core.config import get_initial_config, settings

logger = logging.getLogger(__name__)

_GLOBAL_USAGE_LOCK = threading.Lock()
_GLOBAL_USAGE_TOTALS: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}


def _estimate_tokens(text: Any) -> int:
    s = str(text or "")
    if not s:
        return 0
    return max(1, len(s) // 4)


def reset_global_usage_totals() -> None:
    with _GLOBAL_USAGE_LOCK:
        _GLOBAL_USAGE_TOTALS["input_tokens"] = 0
        _GLOBAL_USAGE_TOTALS["output_tokens"] = 0
        _GLOBAL_USAGE_TOTALS["total_tokens"] = 0


def get_global_usage_totals() -> Dict[str, int]:
    with _GLOBAL_USAGE_LOCK:
        return {
            "input_tokens": int(_GLOBAL_USAGE_TOTALS["input_tokens"]),
            "output_tokens": int(_GLOBAL_USAGE_TOTALS["output_tokens"]),
            "total_tokens": int(_GLOBAL_USAGE_TOTALS["total_tokens"]),
        }


def record_external_usage(
    input_tokens: int,
    output_tokens: int,
    provider: str = "external",
    model: str = "",
    generation_name: str = "chat_completion",
    estimated: bool = False,
) -> None:
    in_tok = max(0, int(input_tokens or 0))
    out_tok = max(0, int(output_tokens or 0))
    tot_tok = in_tok + out_tok
    with _GLOBAL_USAGE_LOCK:
        _GLOBAL_USAGE_TOTALS["input_tokens"] += in_tok
        _GLOBAL_USAGE_TOTALS["output_tokens"] += out_tok
        _GLOBAL_USAGE_TOTALS["total_tokens"] += tot_tok
    logger.debug(
        "[usage] provider=%s model=%s generation=%s estimated=%s in=%d out=%d total=%d",
        provider,
        model,
        generation_name,
        estimated,
        in_tok,
        out_tok,
        tot_tok,
    )


class OicAnthropicIntegrationError(Exception):
    """OIC REST integration misconfigured or returned an unusable payload."""


def _normalize_truthy_raw(val: Optional[str]) -> str:
    return (val or "").strip().rstrip(";").strip().lower()


def _initial_config_truthy(val: Optional[str]) -> bool:
    s = _normalize_truthy_raw(val)
    return s in ("true", "1", "yes", "y", "on")


def claude_direct_api_enabled(resolved_claude_key: Optional[str] = None) -> bool:
    """
    True when ``use_claude`` is on in initial_config (or USE_CLAUDE env), ``oci_call`` is off,
    and a Claude API key is available (constructor arg, settings, or ANTHROPIC_API_KEY).
    """
    if _oci_llm_switch_enabled():
        return False
    cfg = get_initial_config()
    uc = cfg.get("use_claude") or cfg.get("USE_CLAUDE") or os.environ.get("USE_CLAUDE", "")
    if not _initial_config_truthy(uc):
        return False
    key = (resolved_claude_key or "").strip()
    if not key:
        key = (getattr(settings, "CLAUDE_API_KEY", None) or "").strip() or (
            os.environ.get("ANTHROPIC_API_KEY") or ""
        ).strip()
    return bool(key)


def get_claude_model_name() -> str:
    cfg = get_initial_config()
    return (
        (cfg.get("CLAUDE_MODEL") or cfg.get("claude_model") or "").strip()
        or (getattr(settings, "CLAUDE_MODEL", None) or "").strip()
        or "claude-sonnet-4-5"
    )


# Tried in order after the configured model if the API returns 404 (snapshot IDs vary by account/API version).
_CLAUDE_MODEL_FALLBACK_CHAIN: Tuple[str, ...] = (
    "claude-sonnet-4-5",
    "claude-3-5-sonnet-20241022",
    "claude-3-haiku-20240307",
)


def _claude_models_to_try(preferred: str) -> List[str]:
    out: List[str] = []
    seen = set()
    for m in ((preferred or "").strip(),) + _CLAUDE_MODEL_FALLBACK_CHAIN:
        if not m or m in seen:
            continue
        seen.add(m)
        out.append(m)
    return out


def get_oic_message_url() -> str:
    """OIC REST message URL: ``initial_config.txt``, then ``Settings.OIC_ANTHROPIC_MESSAGE_URL`` / env."""
    cfg = get_initial_config()
    for key in ("OIC_ANTHROPIC_MESSAGE_URL", "oic_anthropic_message_url"):
        u = (cfg.get(key) or "").strip()
        if u:
            return u
    u2 = (settings.OIC_ANTHROPIC_MESSAGE_URL or "").strip()
    if u2:
        return u2
    return (os.environ.get("OIC_ANTHROPIC_MESSAGE_URL") or os.environ.get("OIC_MESSAGE_URL") or "").strip()


def _oci_llm_switch_enabled() -> bool:
    """True when user turned on OIC for LLM (``initial_config`` or ``OCI_CALL`` / ``OCI_LLM_ENABLED`` env)."""
    cfg = get_initial_config()
    if _initial_config_truthy(cfg.get("oci_call") or cfg.get("OCI_CALL")):
        return True
    if _initial_config_truthy(os.environ.get("OCI_CALL", "")):
        return True
    if _initial_config_truthy(os.environ.get("OCI_LLM_ENABLED", "")):
        return True
    return False


def oic_anthropic_integration_enabled() -> bool:
    """
    True when the OIC-for-LLM switch is on, URL is set (file / settings / env), and HTTP Basic creds exist.
    """
    if not _oci_llm_switch_enabled():
        return False
    if not get_oic_message_url():
        return False
    user = (settings.OIC_HTTP_USERNAME or "").strip()
    pwd = (settings.OIC_HTTP_PASSWORD or "").strip()
    return bool(user and pwd)


def call_oic_anthropic_user_prompt(user_prompt: str, *, timeout_s: float = 120.0) -> str:
    """
    POST ``{"user_prompt": ...}`` to the OIC integration URL with HTTP Basic auth.
    Returns the ``response`` string from JSON. Raises on configuration, HTTP, or missing field errors.
    """
    if not oic_anthropic_integration_enabled():
        raise OicAnthropicIntegrationError(
            "OIC Anthropic integration is not configured (set oci_call and OIC_ANTHROPIC_MESSAGE_URL "
            "in initial_config.txt, and OIC_HTTP_USERNAME / OIC_HTTP_PASSWORD in the environment)."
        )
    url = get_oic_message_url()
    user = (settings.OIC_HTTP_USERNAME or "").strip()
    pwd = (settings.OIC_HTTP_PASSWORD or "").strip()
    prompt = (user_prompt or "").strip()
    if not prompt:
        raise ValueError("user_prompt must be a non-empty string")

    logger.info("OIC Anthropic integration POST %s", url[:80] + ("..." if len(url) > 80 else ""))
    start_time = time.time()
    try:
        with httpx.Client(timeout=httpx.Timeout(timeout_s, connect=30.0)) as client:
            resp = client.post(url, json={"user_prompt": prompt}, auth=(user, pwd))
            resp.raise_for_status()
            data = resp.json()
    except httpx.HTTPStatusError as e:
        logger.error("OIC HTTP error: %s", e)
        raise OicAnthropicIntegrationError(
            f"OIC request failed with HTTP {e.response.status_code}"
        ) from e
    except OicAnthropicIntegrationError:
        raise
    except Exception as e:
        logger.exception("OIC request failed")
        raise OicAnthropicIntegrationError(str(e)) from e

    duration = time.time() - start_time
    logger.info("OIC Anthropic integration finished in %.2fs", duration)

    if not isinstance(data, dict):
        raise OicAnthropicIntegrationError("OIC response JSON must be an object")
    text = data.get("response")
    if text is None:
        raise OicAnthropicIntegrationError("OIC response missing 'response' field")
    return str(text)


def _message_content_to_str(content: Any) -> str:
    """Normalize a single message ``content`` value to plain text for OIC ``user_prompt``."""
    if content is None:
        return ""
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts: List[str] = []
        for block in content:
            if isinstance(block, dict):
                if block.get("type") == "text" and isinstance(block.get("text"), str):
                    parts.append(block["text"])
                elif isinstance(block.get("content"), str):
                    parts.append(block["content"])
                else:
                    parts.append(json.dumps(block, ensure_ascii=False))
            else:
                parts.append(str(block))
        return "\n".join(parts).strip()
    return str(content)


def _messages_to_oic_user_prompt(messages: list) -> str:
    """Serialize chat-style ``messages`` (role/content) into one string for OIC."""
    parts: List[str] = []
    for msg in messages or []:
        if not isinstance(msg, dict):
            parts.append(str(msg))
            continue
        role = (msg.get("role") or "user").upper()
        text = _message_content_to_str(msg.get("content"))
        parts.append(f"{role}:\n{text}")
    out = "\n\n".join(parts).strip()
    return out if out else "(empty)"


def _augment_messages_like_qwen(messages: list, qwen_data: Any) -> list:
    """Apply the same system + data-context shaping as Qwen before building an OIC prompt."""
    local_messages = copy.deepcopy(messages)
    system_instruction = (
        "Do not include any internal reasoning or <thought> tags. "
        "Focus on providing the direct response in the requested format."
    )
    has_system = any(m.get("role") == "system" for m in local_messages)
    if not has_system:
        local_messages.insert(0, {"role": "system", "content": system_instruction})
    else:
        for m in local_messages:
            if m.get("role") == "system":
                if system_instruction not in m.get("content", ""):
                    m["content"] = f"{system_instruction} {m.get('content', '')}".strip()
                break
    if qwen_data:
        system_msg = next((m for m in local_messages if m.get("role") == "system"), None)
        if system_msg:
            system_msg["content"] = (
                f"{system_msg.get('content', '')}\n\nDATA CONTEXT (Rows/Values):\n{qwen_data}"
            ).strip()
        user_msg = next((m for m in reversed(local_messages) if m.get("role") == "user"), None)
        if user_msg:
            user_msg["content"] = (
                "Provide a comprehensive executive summary of the results based on the data rows and columns provided. "
                "Analyze trends, totals, and notable cross-agent comparisons. Avoid listing raw rows; summarize meaningful insights "
                "and business impact. Use bullet points for key findings. "
                "The summary must be between 150 to 200 words strictly."
            )
    return local_messages


def _session_input_to_chat_messages(messages: list) -> list:
    """Best-effort map Responses-style or mixed inputs to role/content dicts."""
    out: List[Dict[str, Any]] = []
    for m in messages or []:
        if isinstance(m, dict) and m.get("role") is not None:
            out.append({"role": m.get("role"), "content": m.get("content")})
        else:
            out.append({"role": "user", "content": str(m)})
    return out


# Qwen for summary, @web, @askme (analysis + chart JSON), and suggested-questions / continuation talkback
# when use_qwen + scope match.
_LLM_SCOPED_FEATURES = frozenset({"summary", "web", "askme", "suggested", "continuation"})
# Subset of scoped features that strictly prefer local Qwen when configured, even if OIC is enabled.
# (summary, suggested-questions, continuation talkback all use local Qwen first when caller passes use_qwen=True.)
_QWEN_PREFERRED_FEATURES = frozenset({"summary", "suggested", "continuation"})


def _qwen_has_config() -> bool:
    """True when the local Qwen (Ollama) endpoint is configured (URL + model)."""
    return bool((settings.QWEN_BASE_URL or "").strip() and (settings.QWEN_MODEL or "").strip())


_qwen_alive_cache = {"status": False, "timestamp": 0}

def _qwen_configured() -> bool:
    """True when Qwen is configured AND the server is actively responding."""
    if not _qwen_has_config():
        return False
    
    now = time.time()
    # Cache the alive status for 30 seconds
    if now - _qwen_alive_cache["timestamp"] < 30:
        return _qwen_alive_cache["status"]
        
    base_url = (settings.QWEN_BASE_URL or "").strip()
    alive = False
    try:
        from urllib.parse import urlparse
        import httpx
        parsed = urlparse(base_url)
        health_url = f"{parsed.scheme}://{parsed.netloc}/"
        with httpx.Client(timeout=1.0) as client:
            resp = client.get(health_url)
            alive = resp.status_code == 200
    except Exception:
        alive = False
        
    _qwen_alive_cache["status"] = alive
    _qwen_alive_cache["timestamp"] = now
    return alive

from app.services.document_nodes import timer

# Ollama /api/chat — align with QWEN_MODEL in config (e.g. qwen3.5:4b).
_OLLAMA_NUM_CTX = 4096
_OLLAMA_NUM_PREDICT_DEFAULT = 2048
_OLLAMA_NUM_THREAD = 4


# ---------------------------------------------------------------------------
# Pricing table  (USD per 1 million tokens, as of April 2026)
# ---------------------------------------------------------------------------
_MODEL_COSTS: Dict[str, Dict[str, float]] = {
    # model name              input $/1M   output $/1M
    "gpt-4.1":               {"input": 2.00,   "output": 8.00},
    "gpt-4.1-mini":          {"input": 0.40,   "output": 1.60},
    "gpt-4.1-nano":          {"input": 0.10,   "output": 0.40},
    "gpt-4o":                {"input": 5.00,   "output": 15.00},
    "gpt-4o-mini":           {"input": 0.15,   "output": 0.60},
    "gpt-4-turbo":           {"input": 10.00,  "output": 30.00},
    "gpt-3.5-turbo":         {"input": 0.50,   "output": 1.50},
    "text-embedding-ada-002":{"input": 0.10,   "output": 0.00},
    "text-embedding-3-small":{"input": 0.02,   "output": 0.00},
    "text-embedding-3-large":{"input": 0.13,   "output": 0.00},
}


def _token_cost(model: str, input_tokens: int, output_tokens: int) -> Dict[str, float]:
    """Return input_cost and output_cost in USD for a model call."""
    rates = _MODEL_COSTS.get(model, {})
    per_m = 1_000_000
    return {
        "input_cost":  (input_tokens  * rates.get("input",  0.0)) / per_m,
        "output_cost": (output_tokens * rates.get("output", 0.0)) / per_m,
    }


class LLMService:
    """Centralized LLM API calls."""

    DEFAULT_MODEL: str = "gpt-4.1"
    EMBEDDING_MODEL: str = "text-embedding-3-large"

    def __init__(self, api_key: str = "", claude_api_key: str = ""):
        key = (api_key or "").strip()
        self._openai_key = key
        self._claude_key = (claude_api_key or "").strip() or (
            getattr(settings, "CLAUDE_API_KEY", None) or ""
        ).strip() or (os.environ.get("ANTHROPIC_API_KEY") or "").strip()
        self._client: Optional[OpenAI] = None
        self._anthropic: Any = None
        self._usage_lock = threading.Lock()
        self._usage_input = 0
        self._usage_output = 0
        self._usage_total = 0
        mode = settings.llm_mode()
        if oic_anthropic_integration_enabled():
            if key:
                self._client = OpenAI(api_key=key)
            return
        use_anthropic = claude_direct_api_enabled(self._claude_key) and bool(self._claude_key)
        if mode == "qwen_only":
            if key:
                self._client = OpenAI(api_key=key)
            if use_anthropic:
                from anthropic import Anthropic

                self._anthropic = Anthropic(api_key=self._claude_key)
            return
        if not key and not use_anthropic:
            raise ValueError(
                "api_key is required for LLMService when IS_QWEN_LLM is not true "
                "(or set use_claude=true in initial_config with a Claude API key)"
            )
        if key:
            self._client = OpenAI(api_key=key)
        if use_anthropic:
            from anthropic import Anthropic

            self._anthropic = Anthropic(api_key=self._claude_key)

    def _record_usage(
        self,
        input_tokens: int,
        output_tokens: int,
        provider: str,
        model: str,
        generation_name: str,
        estimated: bool = False,
    ) -> None:
        in_tok = max(0, int(input_tokens or 0))
        out_tok = max(0, int(output_tokens or 0))
        total_tok = in_tok + out_tok
        with self._usage_lock:
            self._usage_input += in_tok
            self._usage_output += out_tok
            self._usage_total += total_tok
        record_external_usage(
            in_tok,
            out_tok,
            provider=provider,
            model=model,
            generation_name=generation_name,
            estimated=estimated,
        )

    def reset_usage_totals(self) -> None:
        with self._usage_lock:
            self._usage_input = 0
            self._usage_output = 0
            self._usage_total = 0

    def get_usage_totals(self) -> Dict[str, int]:
        with self._usage_lock:
            return {
                "input_tokens": int(self._usage_input),
                "output_tokens": int(self._usage_output),
                "total_tokens": int(self._usage_total),
            }

    def chat_completion(
        self,
        messages: list,
        temperature: float = 0.7,
        max_tokens: Optional[int] = None,
        stop: Optional[Union[str, List[str]]] = None,
        use_qwen: bool = False,
        langfuse_trace: Any = None,
        generation_name: str = "chat_completion",
        **kwargs,
    ) -> str:
        use_qwen = bool(kwargs.pop("use_qwen", use_qwen))
        llm_scoped_feature = kwargs.pop("llm_scoped_feature", None)

        # Summary / suggested / continuation: prefer local Qwen when configured + caller asked for it.
        # On Qwen failure or empty reply, fall back to OIC → OpenAI → Gemini. ``qwen_data`` is stripped
        # for non-Qwen backends; ``messages`` must stay metadata-only (see askme_base / askme_service).
        if (
            use_qwen
            and llm_scoped_feature in _QWEN_PREFERRED_FEATURES
            and _qwen_configured()
        ):
            return self._call_qwen_generate(
                messages,
                temperature,
                max_tokens,
                stop,
                allow_openai_fallback=True,
                **kwargs,
            )

        if oic_anthropic_integration_enabled():
            return self._oic_chat_completion(
                messages,
                temperature,
                max_tokens,
                stop,
                langfuse_trace=langfuse_trace,
                generation_name=generation_name,
                **kwargs,
            )
        scoped = llm_scoped_feature in _LLM_SCOPED_FEATURES
        effective_qwen = use_qwen and scoped
        mode = settings.llm_mode()

        # Non-scoped or openai_only: OpenAI.
        if mode == "openai_only" or not effective_qwen:
            return self._openai_chat(
                messages,
                temperature,
                max_tokens,
                stop,
                langfuse_trace=langfuse_trace,
                generation_name=generation_name,
                **kwargs,
            )

        if mode == "qwen_only":
            return self._call_qwen_generate(
                messages, temperature, max_tokens, stop, allow_openai_fallback=False, **kwargs
            )
        # auto: scoped Qwen then optional OpenAI fallback
        return self._call_qwen_generate(
            messages, temperature, max_tokens, stop, allow_openai_fallback=True, **kwargs
        )

    def _oic_chat_completion(
        self,
        messages: list,
        temperature: float,
        max_tokens: Optional[int],
        stop: Optional[Union[str, List[str]]],
        langfuse_trace: Any = None,
        generation_name: str = "chat_completion",
        **kwargs: Any,
    ) -> str:
        """Route chat to Oracle Integration (Anthropic) when ``oci_call`` is enabled in initial_config.

        Project rule: any LLM other than local Qwen (OIC, OpenAI) must receive only column metadata,
        never full DataFrame rows. ``qwen_data`` (built upstream when ``use_qwen=True``) is therefore
        explicitly dropped here so it can never be appended to the OIC prompt.
        """
        _ = (temperature, max_tokens, stop)
        kwargs.pop("allow_openai_fallback", None)
        kwargs.pop("model", None)
        kwargs.pop("llm_scoped_feature", None)
        kwargs.pop("qwen_data", None)
        kwargs.pop("langfuse_trace", None)
        kwargs.pop("generation_name", None)
        for k in list(kwargs.keys()):
            kwargs.pop(k, None)

        user_prompt = _messages_to_oic_user_prompt(messages)
        generation = None
        if langfuse_trace:
            generation = langfuse_trace.generation(
                name=generation_name,
                model="oic_anthropic",
                model_parameters={},
                input=user_prompt[:8000],
            )

        print("--- LLMService: Routing to OIC Anthropic integration (oci_call) ---")
        with timer(f"llm.{generation_name}.oic"):
            out_text = call_oic_anthropic_user_prompt(user_prompt)
        input_tokens = _estimate_tokens(user_prompt)
        output_tokens = _estimate_tokens(out_text)
        self._record_usage(
            input_tokens,
            output_tokens,
            provider="oic_anthropic",
            model="oic_anthropic",
            generation_name=generation_name,
            estimated=True,
        )

        if generation:
            generation.end(
                output=out_text if out_text else "",
                usage={"input": input_tokens, "output": output_tokens, "total": input_tokens + output_tokens, "unit": "TOKENS"},
                **_token_cost("oic_anthropic", input_tokens, output_tokens),
            )
        return out_text

    def _openai_style_messages_to_anthropic(
        self, messages: list
    ) -> Tuple[Optional[str], List[Dict[str, Any]]]:
        system_parts: List[str] = []
        anth_msgs: List[Dict[str, Any]] = []
        for msg in messages or []:
            if not isinstance(msg, dict):
                anth_msgs.append({"role": "user", "content": _message_content_to_str(msg)})
                continue
            role = (msg.get("role") or "user").lower()
            text = _message_content_to_str(msg.get("content"))
            if role == "system":
                if text:
                    system_parts.append(text)
            elif role in ("user", "assistant"):
                anth_msgs.append({"role": role, "content": text})
            else:
                anth_msgs.append({"role": "user", "content": f"[{role}]\n{text}"})
        system_combined = "\n\n".join(system_parts).strip() if system_parts else None
        if not anth_msgs:
            anth_msgs = [{"role": "user", "content": "(empty)"}]
        return system_combined, anth_msgs

    def _anthropic_response_text(self, msg: Any) -> str:
        parts: List[str] = []
        for block in getattr(msg, "content", []) or []:
            btype = getattr(block, "type", None)
            if btype is None and isinstance(block, dict):
                btype = block.get("type")
            if btype == "text":
                t = getattr(block, "text", None)
                if t is None and isinstance(block, dict):
                    t = block.get("text")
                if t:
                    parts.append(str(t))
        return "".join(parts).strip()

    def _anthropic_chat(
        self,
        messages: list,
        temperature: float,
        max_tokens: Optional[int],
        stop: Optional[Union[str, List[str]]],
        langfuse_trace: Any = None,
        generation_name: str = "chat_completion",
        **kwargs: Any,
    ) -> str:
        if not self._anthropic:
            raise ValueError("Anthropic client not available")
        for _internal_kw in (
            "use_phi3",
            "use_qwen",
            "qwen_data",
            "llm_scoped_feature",
            "allow_openai_fallback",
            "use_session",
            "session_context",
            "force_openai_native",
        ):
            kwargs.pop(_internal_kw, None)
        kwargs.pop("qwen_data", None)
        kwargs.pop("llm_scoped_feature", None)
        kwargs.pop("langfuse_trace", None)
        kwargs.pop("generation_name", None)
        for k in list(kwargs.keys()):
            kwargs.pop(k, None)

        mt = max_tokens if max_tokens is not None else 8192
        mt = max(1, min(int(mt), 16384))
        system_text, anth_msgs = self._openai_style_messages_to_anthropic(messages)
        create_kw: Dict[str, Any] = {
            "max_tokens": mt,
            "messages": anth_msgs,
            "temperature": temperature,
        }
        if system_text:
            create_kw["system"] = system_text
        if stop is not None:
            if isinstance(stop, str):
                create_kw["stop_sequences"] = [stop]
            else:
                create_kw["stop_sequences"] = list(stop)

        preferred = get_claude_model_name()
        resp = None
        used_model = preferred
        last_nf: Optional[BaseException] = None
        with timer(f"llm.{generation_name}.anthropic"):
            for candidate in _claude_models_to_try(preferred):
                create_kw["model"] = candidate
                used_model = candidate
                print(f"--- LLMService: Routing to Anthropic ({candidate}) ---")
                try:
                    resp = self._anthropic.messages.create(**create_kw)
                    break
                except NotFoundError as e:
                    last_nf = e
                    logger.warning(
                        "Anthropic model %r not available for this key (%s); trying next",
                        candidate,
                        e,
                    )
                    continue
        if resp is None:
            if last_nf is not None:
                raise last_nf
            raise RuntimeError("Anthropic messages.create returned no response")

        out_text = self._anthropic_response_text(resp)
        usage_obj = getattr(resp, "usage", None)
        input_tokens = int(getattr(usage_obj, "input_tokens", 0) or 0) if usage_obj else 0
        output_tokens = int(getattr(usage_obj, "output_tokens", 0) or 0) if usage_obj else 0
        self._record_usage(
            input_tokens,
            output_tokens,
            provider="anthropic",
            model=used_model,
            generation_name=generation_name,
            estimated=False,
        )
        if langfuse_trace:
            generation = langfuse_trace.generation(
                name=generation_name,
                model=used_model,
                model_parameters={"temperature": temperature, "max_tokens": mt},
                input=anth_msgs,
            )
            costs = _token_cost(used_model, input_tokens, output_tokens)
            generation.end(
                output=out_text if out_text else "",
                usage={
                    "input": input_tokens,
                    "output": output_tokens,
                    "total": input_tokens + output_tokens,
                    "unit": "TOKENS",
                },
                **costs,
            )
        return out_text

    def _openai_chat(
        self,
        messages: list,
        temperature: float,
        max_tokens: Optional[int],
        stop: Optional[Union[str, List[str]]],
        langfuse_trace: Any = None,
        generation_name: str = "chat_completion",
        **kwargs,
    ) -> str:
        force_openai_native = bool(kwargs.pop("force_openai_native", False))
        if not force_openai_native and oic_anthropic_integration_enabled():
            return self._oic_chat_completion(
                messages,
                temperature,
                max_tokens,
                stop,
                langfuse_trace=langfuse_trace,
                generation_name=generation_name,
                **kwargs,
            )
        if not force_openai_native and self._anthropic is not None:
            return self._anthropic_chat(
                messages,
                temperature,
                max_tokens,
                stop,
                langfuse_trace=langfuse_trace,
                generation_name=generation_name,
                **kwargs,
            )
        if not self._client:
            raise ValueError("OpenAI client not available")
        for _internal_kw in (
            "use_phi3", "use_qwen", "qwen_data", "llm_scoped_feature",
            "allow_openai_fallback", "use_session", "session_context",
        ):
            kwargs.pop(_internal_kw, None)
        if _oci_llm_switch_enabled() and not get_oic_message_url():
            logger.warning(
                "oci_call/OCI_CALL is on but OIC_ANTHROPIC_MESSAGE_URL is empty — check initial_config.txt "
                "(uncomment OIC_ANTHROPIC_MESSAGE_URL) or set OIC_ANTHROPIC_MESSAGE_URL in the environment."
            )
        elif _oci_llm_switch_enabled() and not (
            (settings.OIC_HTTP_USERNAME or "").strip() and (settings.OIC_HTTP_PASSWORD or "").strip()
        ):
            logger.warning(
                "oci_call/OCI_CALL is on but OIC_HTTP_USERNAME / OIC_HTTP_PASSWORD are missing in the environment."
            )
        print(f"--- LLMService: Routing to OpenAI ({self.DEFAULT_MODEL}) ---")
        if "qwen_data" in kwargs:
            kwargs.pop("qwen_data")
        kwargs.pop("llm_scoped_feature", None)
        kwargs.pop("langfuse_trace", None)
        kwargs.pop("generation_name", None)
        create_kwargs: Dict[str, Any] = {
            "model": self.DEFAULT_MODEL,
            "messages": messages,
            "temperature": temperature,
        }
        if max_tokens is not None:
            create_kwargs["max_tokens"] = max_tokens
        if stop is not None: 
            create_kwargs["stop"] = stop
        create_kwargs.update(kwargs)

        generation = None
        if langfuse_trace:
            generation = langfuse_trace.generation(
                name=generation_name,
                model=self.DEFAULT_MODEL,
                model_parameters={"temperature": temperature, "max_tokens": max_tokens},
                input=messages,
            )

        with timer(f"llm.{generation_name}"):
            response = self._client.chat.completions.create(**create_kwargs)
        usage_obj = getattr(response, "usage", None)
        input_tokens = int(getattr(usage_obj, "prompt_tokens", 0) or 0) if usage_obj else 0
        output_tokens = int(getattr(usage_obj, "completion_tokens", 0) or 0) if usage_obj else 0
        self._record_usage(
            input_tokens,
            output_tokens,
            provider="openai",
            model=self.DEFAULT_MODEL,
            generation_name=generation_name,
            estimated=False,
        )

        if generation:
            total_tokens  = int(getattr(usage_obj, "total_tokens", 0) or 0) if usage_obj else 0
            costs = _token_cost(self.DEFAULT_MODEL, input_tokens, output_tokens)
            generation.end(
                output=(response.choices[0].message.content if response.choices else ""),
                usage={
                    "input":  input_tokens,
                    "output": output_tokens,
                    "total":  total_tokens,
                    "unit":   "TOKENS",
                },
                **costs,
            )
            print(
                f"[LANGFUSE] {generation_name}: "
                f"in={input_tokens} out={output_tokens} total={total_tokens} "
                f"cost=${costs['input_cost'] + costs['output_cost']:.6f}"
            )

        choice = response.choices[0]
        if hasattr(choice, "text") and choice.text:
            return choice.text
        return choice.message.content

    def _fallback_after_qwen_failure(
        self,
        messages: list,
        temperature: float,
        max_tokens: Optional[int],
        stop: Optional[Union[str, List[str]]],
        fb_kw: Dict[str, Any],
        *,
        generation_name: str = "chat_completion",
        skip_oic: bool = False,
    ) -> str:
        """
        When Qwen is unavailable or failed: try OIC (if configured), then OpenAI client, then Gemini API key.
        ``skip_oic`` avoids a second OIC attempt after an earlier OIC failure in the same resolution chain.
        """
        from askme_open_agent.gemini_generate import gemini_generate_content, resolve_gemini_api_key

        last_err: Optional[BaseException] = None
        if not skip_oic and oic_anthropic_integration_enabled():
            try:
                return self._oic_chat_completion(
                    messages,
                    temperature,
                    max_tokens,
                    stop,
                    langfuse_trace=None,
                    generation_name=generation_name,
                    **dict(fb_kw),
                )
            except Exception as e:
                last_err = e
                logger.warning("LLM fallback: OIC failed: %s", e)

        oa_kw = dict(fb_kw)
        trace = oa_kw.pop("langfuse_trace", None)
        if self._anthropic is not None:
            try:
                return self._anthropic_chat(
                    messages,
                    temperature,
                    max_tokens,
                    stop,
                    langfuse_trace=trace,
                    generation_name=generation_name,
                    **dict(oa_kw),
                )
            except Exception as e:
                last_err = e
                logger.warning("LLM fallback: Anthropic failed: %s", e)

        if self._client is not None:
            try:
                return self._openai_chat(
                    messages,
                    temperature,
                    max_tokens,
                    stop,
                    langfuse_trace=trace,
                    generation_name=generation_name,
                    force_openai_native=True,
                    **oa_kw,
                )
            except Exception as e:
                last_err = e
                logger.warning("LLM fallback: OpenAI failed: %s", e)

        gkey = resolve_gemini_api_key()
        if gkey:
            try:
                user_blob = _messages_to_oic_user_prompt(messages)
                gemini_text = gemini_generate_content(
                    user_text=user_blob,
                    api_key=gkey,
                    system_instruction=None,
                    google_search=False,
                    force_native_gemini=True,
                )
                self._record_usage(
                    _estimate_tokens(user_blob),
                    _estimate_tokens(gemini_text),
                    provider="gemini",
                    model="gemini_native",
                    generation_name=generation_name,
                    estimated=True,
                )
                return gemini_text
            except Exception as e:
                last_err = e
                logger.warning("LLM fallback: Gemini failed: %s", e)

        if last_err is not None:
            raise last_err
        raise RuntimeError(
            "No LLM fallback is configured (enable OIC, OpenAI API key, use_claude + Claude API key, or GEMINI_API_KEY / gemini_api_key)."
        )

    def _call_qwen_generate(
        self,
        messages: list,
        temperature: float = 0.3,
        max_tokens: Optional[int] = None,
        stop: Optional[Union[str, List[str]]] = None,
        allow_openai_fallback: bool = True,
        **kwargs,
    ) -> str:
        fb_kw = {k: v for k, v in kwargs.items() if k != "qwen_data"}
        # Qwen-first: only fall through to OIC/OpenAI/Gemini when Qwen is NOT configured or down.
        if not _qwen_configured():
            logger.error("Qwen not configured or server is down (QWEN_BASE_URL / QWEN_MODEL)")
            if oic_anthropic_integration_enabled():
                try:
                    return self._oic_chat_completion(
                        messages,
                        temperature,
                        max_tokens,
                        stop,
                        langfuse_trace=None,
                        generation_name="chat_completion",
                        **fb_kw,
                    )
                except Exception as e:
                    if not allow_openai_fallback:
                        raise
                    logger.warning("Qwen unavailable: OIC failed: %s", e)
            elif not allow_openai_fallback:
                raise RuntimeError("Qwen is not configured")
            return self._fallback_after_qwen_failure(
                messages,
                temperature,
                max_tokens,
                stop,
                fb_kw,
                generation_name="chat_completion",
                skip_oic=True,
            )

        local_messages = copy.deepcopy(messages)

        system_instruction = (
            "Do not include any internal reasoning or <thought> tags. "
            "Focus on providing the direct response in the requested format."
        )
        has_system = any(m.get("role") == "system" for m in local_messages)
        if not has_system:
            local_messages.insert(0, {"role": "system", "content": system_instruction})
        else:
            for m in local_messages:
                if m.get("role") == "system":
                    if system_instruction not in m.get("content", ""):
                        m["content"] = f"{system_instruction} {m.get('content', '')}".strip()
                    break

        qwen_data = kwargs.get("qwen_data")
        if qwen_data:
            system_msg = next((m for m in local_messages if m.get("role") == "system"), None)
            if system_msg:
                system_msg["content"] = (
                    f"{system_msg.get('content', '')}\n\nDATA CONTEXT (Rows/Values):\n{qwen_data}"
                ).strip()
            user_msg = next((m for m in reversed(local_messages) if m.get("role") == "user"), None)
            if user_msg:
                user_msg["content"] = (
                    "Provide a comprehensive executive summary of the results based on the data rows and columns provided. "
                    "Analyze trends, totals, and notable cross-agent comparisons. Avoid listing raw rows; summarize meaningful insights "
                    "and business impact. Use bullet points for key findings. "
                    "The summary must be between 150 to 200 words strictly."
                )

        # Ollama /api/chat for Qwen 3.5 — include think: false (disables extended thinking where supported).
        num_predict = _OLLAMA_NUM_PREDICT_DEFAULT if max_tokens is None else max_tokens
        payload: Dict[str, Any] = {
            "model": settings.QWEN_MODEL,
            "messages": local_messages,
            "stream": False,
            "think": False,
            "options": {
                "temperature": temperature,
                "num_ctx": 2048,
                "num_thread": 4,
                "num_predict": 1024,
                "top_k": 20,
                "top_p": 0.9,
                "repeat_penalty": 1.1,
            },
        }
        if max_tokens:
            payload["options"]["num_predict"] = max_tokens
        if stop:
            payload["options"]["stop"] = [stop] if isinstance(stop, str) else stop

        print(f"--- LLMService: Routing to local Qwen ({settings.QWEN_MODEL}) ---")
        logger.info("Qwen request to %s ...", settings.QWEN_BASE_URL)
        start_time = time.time()
        try:
            with httpx.Client(timeout=httpx.Timeout(180.0, connect=15.0)) as client:
                resp = client.post(settings.QWEN_BASE_URL, json=payload)
                resp.raise_for_status()
                result = resp.json()
                response_text = result.get("message", {}).get("content", "")
                duration = time.time() - start_time
                print(f"--- Qwen response received ({len(response_text)} chars in {duration:.2f}s) ---")
                if not response_text and "response" in result:
                    response_text = result["response"]
                    print(f"--- Qwen response: {response_text} ---")
                if (response_text or "").strip():
                    return response_text
                if not allow_openai_fallback:
                    logger.error("Qwen returned empty response (no fallback)")
                    return response_text or ""
                logger.warning(
                    "Qwen returned empty body after %.2fs; trying OIC/OpenAI/Gemini fallback",
                    time.time() - start_time,
                )
                return self._fallback_after_qwen_failure(
                    messages,
                    temperature,
                    max_tokens,
                    stop,
                    fb_kw,
                    generation_name="chat_completion",
                    skip_oic=False,
                )

        except Exception as e:
            duration = time.time() - start_time
            if not allow_openai_fallback:
                logger.error("Qwen failed (no fallback): %s", e)
                raise
            logger.error(
                "Qwen failed after %.2fs, trying OIC/OpenAI/Gemini fallback: %s", duration, e
            )
            try:
                return self._fallback_after_qwen_failure(
                    messages,
                    temperature,
                    max_tokens,
                    stop,
                    fb_kw,
                    generation_name="chat_completion",
                    skip_oic=False,
                )
            except Exception as fb_e:
                logger.exception("All LLM fallbacks failed after Qwen error")
                raise fb_e from e

    def _extract_response_text(self, response: Any) -> str:
        text = getattr(response, "output_text", None)
        if text:
            return text
        out = []
        for item in getattr(response, "output", []) or []:
            for content in getattr(item, "content", []) or []:
                val = getattr(content, "text", None)
                if val:
                    out.append(val)
        return "\n".join(out).strip()

    def chat_with_session(
        self,
        messages: list,
        session_state: Optional[Dict[str, Any]] = None,
        temperature: float = 0.7,
        max_tokens: Optional[int] = None,
        **kwargs,
    ) -> Dict[str, Any]:
        kwargs.pop("use_qwen", None)
        kwargs.pop("llm_scoped_feature", None)
        if oic_anthropic_integration_enabled():
            _ = (session_state, temperature, max_tokens, kwargs)
            chat_msgs = _session_input_to_chat_messages(messages)
            user_prompt = _messages_to_oic_user_prompt(chat_msgs)
            print("--- LLMService: Routing chat_with_session to OIC Anthropic integration (oci_call) ---")
            with timer("llm.responses.create.oic"):
                out_text = call_oic_anthropic_user_prompt(user_prompt)
            return {
                "text": out_text,
                "response_id": None,
                "raw": {"source": "oic_anthropic", "session_chaining": False},
            }
        if self._anthropic is not None:
            chat_msgs = _session_input_to_chat_messages(messages)
            print("--- LLMService: Routing chat_with_session to Anthropic (use_claude) ---")
            with timer("llm.responses.create.anthropic"):
                out_text = self._anthropic_chat(
                    chat_msgs,
                    temperature,
                    max_tokens,
                    kwargs.pop("stop", None),
                    langfuse_trace=None,
                    generation_name="chat_with_session",
                    **kwargs,
                )
            return {
                "text": out_text,
                "response_id": None,
                "raw": {"source": "anthropic_direct", "session_chaining": False},
            }
        if not self._client:
            raise ValueError("OpenAI client required for session mode")
        session_state = session_state or {}
        create_kwargs: Dict[str, Any] = {
            "model": self.DEFAULT_MODEL,
            "input": messages,
            "temperature": temperature,
        }
        prev_id = session_state.get("openai_response_id") or session_state.get("response_id")
        if prev_id:
            create_kwargs["previous_response_id"] = prev_id
        if max_tokens is not None:
            create_kwargs["max_output_tokens"] = max_tokens
        create_kwargs.update(kwargs)

        with timer("llm.responses.create"):
            response = self._client.responses.create(**create_kwargs)
        usage_obj = getattr(response, "usage", None)
        in_tok = int(getattr(usage_obj, "input_tokens", 0) or 0) if usage_obj else 0
        out_tok = int(getattr(usage_obj, "output_tokens", 0) or 0) if usage_obj else 0
        self._record_usage(
            in_tok,
            out_tok,
            provider="openai",
            model=self.DEFAULT_MODEL,
            generation_name="chat_with_session",
            estimated=False,
        )
        return {
            "text": self._extract_response_text(response),
            "response_id": getattr(response, "id", None),
            "raw": response,
        }

    def create_embedding(
        self,
        input_text: Union[str, List[str]],
        langfuse_trace: Any = None,
        generation_name: str = "create_embedding",
    ) -> List[float]:
        """
        Create embedding. Always uses EMBEDDING_MODEL.

        Args:
            input_text: Single string or list of strings.
            langfuse_trace: Langfuse trace/span to attach a generation to.
            generation_name: Name shown in Langfuse.

        Returns:
            Embedding vector(s). For single input returns list of floats.
        """
        if oic_anthropic_integration_enabled():
            raise RuntimeError(
                "Embeddings are not supported when oci_call is enabled (OIC Anthropic flow is text-only). "
                "Disable oci_call in initial_config.txt for embedding-dependent features, or supply an API key "
                "and disable oci_call only where embeddings are required."
            )
        if not self._client:
            raise RuntimeError(
                "OpenAI API key is required for embeddings. "
                "Claude (use_claude) covers chat only; set OPEN_API_KEY / OPENAI_API_KEY or agent api_key for embeddings."
            )
        is_batch = isinstance(input_text, list)
        label = f"llm.{generation_name}({'batch' if is_batch else 'single'})"

        generation = None
        if langfuse_trace:
            preview = f"[{len(input_text)} texts]" if is_batch else (input_text[:120] + "..." if len(input_text) > 120 else input_text)
            generation = langfuse_trace.generation(
                name=generation_name,
                model=self.EMBEDDING_MODEL,
                model_parameters={"batch": is_batch},
                input=preview,
            )

        with timer(label):
            response = self._client.embeddings.create(
                model=self.EMBEDDING_MODEL,
                input=input_text,
            )

        if generation:
            usage_obj = getattr(response, "usage", None)
            total_tokens = getattr(usage_obj, "total_tokens", 0) if usage_obj else 0
            costs = _token_cost(self.EMBEDDING_MODEL, total_tokens, 0)
            generation.end(
                output=f"[{len(response.data)} embedding(s)]",
                usage={
                    "input": total_tokens,
                    "total": total_tokens,
                    "unit":  "TOKENS",
                },
                **costs,
            )
            print(
                f"[LANGFUSE] {generation_name}: "
                f"tokens={total_tokens} cost=${costs['input_cost']:.6f}"
            )

        data = response.data
        if isinstance(input_text, str) and data:
            return data[0].embedding
        return [d.embedding for d in data]
