import logging
import os

import httpx

from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse

from config import LLAMA_SWAP_URL
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
from services.routing_policy import load_policy

log = logging.getLogger(__name__)

router = APIRouter(prefix="/v1")

# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
# (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab.
_LANG_DIRECTIVE = os.environ.get(
    "MC_GATEWAY_LANG_DIRECTIVE",
    "Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, "
    "Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. "
    "Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")


# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
_BILD_BESCHREIB_PROMPT = os.environ.get(
    "MC_CODER_IMAGE_PROMPT",
    "Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, "
    "Code, Zahlen/Daten, UI-Elemente, Layout, Farben und alles Auffaellige. "
    "Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.")


# Lucy-Voice-Schnellspur (16.07.): Lucys Sprach-Turns tragen diesen Marker im System-Prompt.
# Er schaltet das Qwen-Denken für GENAU diese Requests ab (gemessen direkt am Hirn: 9,9 s -> 0,8 s
# bis zur Antwort; reasoning_content 2500 Zeichen -> 0) — die „Hirn"-Latenz der Voice-Turns war
# fast vollständig Reasoning-Generierung VOR dem ersten sprechbaren Wort. Der Marker wird VOR dem
# Modell aus allen Messages entfernt (konstanter Text -> Prefix-Cache bleibt stabil). Requests ohne
# Marker (Crons, Telegram, Werkstatt) bleiben unangetastet. Leerer Env-Wert schaltet die Spur ab.
_NO_THINK_MARKER = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]")


def _apply_no_think_marker(body: dict) -> None:
    """Marker aus allen Message-Inhalten strippen; war er da, Thinking abschalten
    (sofern der Client chat_template_kwargs nicht selbst gesetzt hat)."""
    if not _NO_THINK_MARKER:
        return
    found = False
    for m in body.get("messages") or []:
        if not isinstance(m, dict):
            continue
        c = m.get("content")
        if isinstance(c, str) and _NO_THINK_MARKER in c:
            m["content"] = c.replace(_NO_THINK_MARKER, "").strip()
            found = True
        elif isinstance(c, list):
            for part in c:
                if (isinstance(part, dict) and isinstance(part.get("text"), str)
                        and _NO_THINK_MARKER in part["text"]):
                    part["text"] = part["text"].replace(_NO_THINK_MARKER, "").strip()
                    found = True
    if found and "chat_template_kwargs" not in body:
        body["chat_template_kwargs"] = {"enable_thinking": False}


def _ist_coder_alias(alias: str) -> bool:
    """Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der
    stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten."""
    return "coder" in (alias or "").lower()


async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool:
    """Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell.

    Idee aus einem verwaisten, nie verdrahteten Patch in der Hermes-Quelle
    (gateway/platforms/api_server.py, 07/2026) — bei der Projekt-Review 15.07.
    regelkonform hierher umgezogen (Archiv: docs/archiv/). True = alle Bilder
    ersetzt; False = eine Analyse scheiterte, Aufrufer nutzt die normale
    Vision-Umleitung als Fallback.
    """
    fundstellen: list[tuple[dict, int, dict]] = []
    for m in body.get("messages") or []:
        if not isinstance(m, dict) or not isinstance(m.get("content"), list):
            continue
        for i, part in enumerate(m["content"]):
            if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
                fundstellen.append((m, i, part))
    for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
        frage = {
            "model": vision_alias,
            "stream": False,
            "max_tokens": 700,
            "messages": [{"role": "user",
                          "content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
        }
        # Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach
        # Sekunden trotz timeout=240 — llama-swap kappt beim Modell-Swap gern alte
        # Keep-Alive-Sockets, der Pool reicht sie trotzdem wieder aus) + 1 Retry.
        text = ""
        for versuch in (1, 2):
            try:
                async with httpx.AsyncClient(timeout=240.0) as c:
                    r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
                if r.status_code == 200:
                    text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
                if text.strip():
                    break
                log.warning("Bild-Weiche v2 (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
                            versuch, r.status_code, r.text)
            except Exception:
                log.warning("Bild-Weiche v2 (Versuch %s): Vision-Aufruf scheiterte", versuch, exc_info=True)
        if not text.strip():
            return False
        msg["content"][idx] = {"type": "text", "text": (
            f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n"
            f"{text.strip()}]")}
    return True


def _inject_language(body: dict, alias: str) -> None:
    """Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates
    erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys
    Persona (SOUL.md) regelt die Sprache selbst."""
    if not _LANG_DIRECTIVE or alias == "hermes":
        return
    msgs = body.get("messages")
    if not isinstance(msgs, list):
        return
    first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None)
    if first_sys is None:
        msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE})
    elif isinstance(first_sys.get("content"), str):
        first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE
    elif isinstance(first_sys.get("content"), list):
        first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})

@router.get("/models")
async def models(request: Request):
    client = request.app.state.gw_client  # geteilter Keep-Alive-Client (siehe app.py lifespan)
    r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
    data = r.json()
    # Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
    # angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
    # mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste.
    # Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie
    # budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende
    # max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.).
    # Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf,
    # Clients fragen aber genau damit an → als eigene Einträge einblenden.
    ctx_map: dict[str, int] = {}
    alias_entries: list[dict] = []
    aliased_raw: set[str] = set()  # rohe Modellnamen, die ein Klarnamen-Alias schon zeigt
    try:
        from services import llamaswap
        for m in llamaswap.list_models():
            ctx = m.get("ctx")
            # --parallel teilt den Server-Kontext HART auf die Slots auf — ohne die
            # Division budgetieren Clients (v. a. Hermes) gegen 131k, real sind 65k/Slot:
            # Kompaktierung feuert nie, llama-server kappt Prompt/Antwort → abgerissene
            # Tool-Calls + Retry-Schleifen (Log 07.07., Session bei ~52k Tokens).
            slots = m.get("parallel_slots") or 1
            if ctx and slots > 1:
                ctx = ctx // slots
            if ctx:
                for api_id in m.get("api_ids", []):
                    ctx_map[api_id] = ctx
            aliases = m.get("aliases", [])
            if aliases:
                aliased_raw.add(m["name"])
            for alias in aliases:
                entry = {"id": alias, "object": "model", "owned_by": "mc2-alias",
                         "description": f"Alias für {m['name']}"}
                if ctx:
                    entry["context_length"] = ctx
                alias_entries.append(entry)
    except Exception:
        pass
    if isinstance(data, dict) and isinstance(data.get("data"), list):
        for entry in data["data"]:
            if (ctx := ctx_map.get(entry.get("id"))):
                entry.setdefault("context_length", ctx)
        # Rohe Doppel-IDs ausblenden, wenn ein Klarnamen-Alias sie schon zeigt → in der
        # Liste erscheinen nur die freundlichen Rollen-Namen. Routing per roher ID bleibt.
        raw = [e for e in data["data"] if e.get("id") not in aliased_raw]
        data["data"] = alias_entries + raw
    return JSONResponse(data, status_code=r.status_code)


async def _proxy(path: str, request: Request):
    body = await request.json()
    _apply_no_think_marker(body)   # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
    requested = str(body.get("model") or "auto")
    if requested.lower() in ("auto", "chat", "coding"):
        lane = requested.lower()
        alias, reason = choose_for_lane(lane, body)
        body["model"] = alias
        routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane}
    else:
        alias = requested
        routed = {"x-mc-routed-to": requested}

    pol = load_policy()
    client = request.app.state.gw_client  # geteilter Keep-Alive-Client (siehe app.py lifespan)
    # Bild-Weiche (Faden 11): Requests mit Bild-Anhang automatisch ans Vision-Modell umleiten,
    # sofern das Ziel nicht ohnehin bildfähig ist. Das MTP-Hirn (fast/hermes) kann keine Bilder —
    # so bleibt Lucy schnell, ohne dass jemand manuell das Modell wechselt (Entscheid Weg A).
    # Coder-Sonderweg (15.07.): Coder-Ziele behalten den Request — die Bilder werden vorab vom
    # Vision-Modell BESCHRIEBEN und als Text injiziert (Screenshot-Debugging bleibt beim Coder).
    vision_alias = pol.get("vision")
    if vision_alias and alias not in VISION_CAPABLE and has_image(body):
        if _ist_coder_alias(alias) and await _bilder_fuer_coder_beschreiben(body, client, vision_alias):
            routed = {"x-mc-routed-to": alias,
                      "x-mc-route-reason": "Bild beschrieben (Vision) -> bleibt beim Coder",
                      "x-mc-lane": routed.get("x-mc-lane", "-")}
        else:
            alias = vision_alias
            body["model"] = alias
            # HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.).
            routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild erkannt -> Vision-Modell",
                      "x-mc-lane": routed.get("x-mc-lane", "-")}
    # fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
    if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
        body["chat_template_kwargs"] = {"enable_thinking": False}
    _inject_language(body, alias)
    url = f"{LLAMA_SWAP_URL}{path}"
    if body.get("stream"):
        req = client.build_request("POST", url, json=body, timeout=None)
        r = await client.send(req, stream=True)
        if r.status_code != 200:
            await r.aread()
            try:
                resp_json = r.json()
            except Exception:
                resp_json = {"error": {"message": r.text, "type": "upstream_error"}}
            return JSONResponse(resp_json, status_code=r.status_code, headers=routed)

        async def gen():
            try:
                async for chunk in r.aiter_raw():
                    record_stream_chunk(chunk, alias, body.get("max_tokens"))
                    yield chunk
            finally:
                await r.aclose()
        return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)

    r = await client.post(url, json=body, timeout=600.0)
    resp_json = r.json()
    if isinstance(resp_json, dict):
        record_usage(resp_json.get("usage"), alias)
        if any(isinstance(c, dict) and c.get("finish_reason") == "length"
               for c in resp_json.get("choices") or []):
            warn_truncation(alias, body.get("max_tokens"))
    return JSONResponse(resp_json, status_code=r.status_code, headers=routed)


@router.post("/chat/completions")
async def chat_completions(request: Request):
    return await _proxy("/v1/chat/completions", request)


@router.post("/completions")
async def completions(request: Request):
    return await _proxy("/v1/completions", request)
