# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Public and authenticated routes for UI language sets (DB-backed i18n).

Architecture:
- xx = base set (meta): key = German plaintext, value = UI context for AI
- All languages (incl. de) are AI-generated translations from xx
- AI translation pipeline uses context from xx to disambiguate translations
"""

from __future__ import annotations

import asyncio
import json
import logging
import math
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Set

from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Request, UploadFile, status
from fastapi.responses import Response
from pydantic import BaseModel, Field

from modules.auth import getCurrentUser, requireSysAdminRole
from modules.connectors.connectorDbPostgre import _get_cached_connector
from modules.datamodels.datamodelAi import (
    AiCallOptions,
    AiCallRequest,
    AiCallResponse,
    OperationTypeEnum,
    PriorityEnum,
)
from modules.datamodels.datamodelUiLanguage import I18nEntry, UiLanguageSet
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelFeatures import Feature
from modules.datamodels.datamodelNotification import NotificationType
from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface
from modules.routes.routeNotifications import _createNotification
from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import _loadCache as _reloadI18nCache, apiRouteContext
from modules.shared.timeUtils import getUtcTimestamp

routeApiMsg = apiRouteContext("routeI18n")

logger = logging.getLogger(__name__)

router = APIRouter(
    prefix="/api/i18n",
    tags=["i18n"],
    responses={404: {"description": "Not found"}},
)

_MIN_AI_BILLING_ESTIMATE_CHF = 0.01
_TRANSLATE_BATCH_SIZE = 80
_TRANSLATE_BATCH_PAUSE_S = 2.0
_TRANSLATE_RATE_LIMIT_MAX_RETRIES = 3

_PROTECTED_CODES = frozenset({"xx"})

# In-memory set of language codes currently being updated (sync / create).
_UPDATING_CODES: Set[str] = set()

# ---------------------------------------------------------------------------
# ISO 639-1 label map (used when creating a language without explicit label)
# ---------------------------------------------------------------------------

_ISO_LABELS: Dict[str, str] = {
    "de": "Deutsch", "gsw": "Schweizerdeutsch", "en": "English", "fr": "Français", "it": "Italiano",
    "es": "Español", "pt": "Português", "nl": "Nederlands", "pl": "Polski",
    "cs": "Čeština", "sk": "Slovenčina", "sv": "Svenska", "no": "Norsk",
    "da": "Dansk", "fi": "Suomi", "hu": "Magyar", "ro": "Română",
    "bg": "Български", "hr": "Hrvatski", "sl": "Slovenščina", "et": "Eesti",
    "lv": "Latviešu", "lt": "Lietuvių", "el": "Ελληνικά", "tr": "Türkçe",
    "ru": "Русский", "uk": "Українська", "ar": "العربية", "he": "עברית",
    "zh": "中文", "ja": "日本語", "ko": "한국어", "hi": "हिन्दी",
    "th": "ไทย", "vi": "Tiếng Việt", "id": "Bahasa Indonesia", "ms": "Bahasa Melayu",
    "tl": "Filipino", "sw": "Kiswahili", "af": "Afrikaans", "sq": "Shqip",
    "am": "አማርኛ", "hy": "Հայերեն", "az": "Azərbaycan", "eu": "Euskara",
    "be": "Беларуская", "bn": "বাংলা", "bs": "Bosanski", "ca": "Català",
    "cy": "Cymraeg", "eo": "Esperanto", "fa": "فارسی", "ga": "Gaeilge",
    "gl": "Galego", "gu": "ગુજરાતી", "ha": "Hausa", "is": "Íslenska",
    "jv": "Basa Jawa", "ka": "ქართული", "kk": "Қазақ", "km": "ខ្មែរ",
    "kn": "ಕನ್ನಡ", "ku": "Kurdî", "ky": "Кыргызча", "la": "Latina",
    "lb": "Lëtzebuergesch", "lo": "ລາວ", "mk": "Македонски", "ml": "മലയാളം",
    "mn": "Монгол", "mr": "मराठी", "mt": "Malti", "my": "မြန်မာ",
    "ne": "नेपाली", "or": "ଓଡ଼ିଆ", "pa": "ਪੰਜਾਬੀ", "ps": "پښتو",
    "si": "සිංහල", "so": "Soomaali", "sr": "Српски", "su": "Basa Sunda",
    "ta": "தமிழ்", "te": "తెలుగు", "tg": "Тоҷикӣ", "tk": "Türkmen",
    "ur": "اردو", "uz": "Oʻzbek", "yo": "Yorùbá", "zu": "isiZulu",
}


# ---------------------------------------------------------------------------
# DB helpers
# ---------------------------------------------------------------------------

def _publicMgmtDb():
    return _get_cached_connector(
        dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
        dbDatabase="poweron_management",
        dbUser=APP_CONFIG.get("DB_USER"),
        dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
        dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
        userId="__i18n_public__",
    )


def _rowEntries(row: dict) -> List[dict]:
    """Read entries from a DB row, supporting both new (entries) and legacy (keys) format."""
    entries = row.get("entries")
    if isinstance(entries, list) and entries:
        return entries
    keys = row.get("keys")
    if isinstance(keys, dict) and keys:
        return [{"context": "ui", "key": k, "value": v} for k, v in keys.items()]
    return []


def _entriesToKeyValueMap(entries: List[dict]) -> Dict[str, str]:
    """Convert entries list to a flat key->value map (for frontend consumption)."""
    return {e["key"]: e.get("value", "") for e in entries if e.get("key")}


def _row_to_public(row: dict) -> dict:
    entries = _rowEntries(row)
    return {
        "code": row["id"],
        "label": row.get("label"),
        "status": row.get("status"),
        "entries": entries,
    }


def _loadMasterXxEntries(db) -> List[dict]:
    """Load the xx base set entries from DB."""
    rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"})
    if not rows:
        return []
    return _rowEntries(rows[0])


def _userMemberMandateIds(currentUser: User) -> List[str]:
    from modules.interfaces.interfaceDbApp import getRootInterface

    root = getRootInterface()
    memberships = root.getUserMandates(str(currentUser.id))
    out = []
    for um in memberships:
        mid = getattr(um, "mandateId", None) or (
            um.get("mandateId") if isinstance(um, dict) else None
        )
        if mid:
            out.append(str(mid))
    return list(dict.fromkeys(out))


def _mandatePassesAiPoolBilling(currentUser: User, mandateId: str, userId: str) -> bool:
    from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface

    bi = getBillingInterface(currentUser, mandateId)
    res = bi.checkBalance(mandateId, userId, _MIN_AI_BILLING_ESTIMATE_CHF)
    return bool(res.allowed)


# ---------------------------------------------------------------------------
# AI Translation helpers
# ---------------------------------------------------------------------------

_aiObjectsSingleton = None


async def _getAiObjects():
    global _aiObjectsSingleton
    if _aiObjectsSingleton is None:
        from modules.interfaces.interfaceAiObjects import AiObjects
        _aiObjectsSingleton = await AiObjects.create()
    return _aiObjectsSingleton


def _makeBillingCallback(currentUser: User, mandateId: str):
    from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService

    billingService = getBillingService(currentUser, mandateId)

    def _cb(response: AiCallResponse) -> None:
        if not response or getattr(response, "errorCount", 0) > 0:
            return
        basePriceCHF = getattr(response, "priceCHF", 0.0)
        if not basePriceCHF or basePriceCHF <= 0:
            return
        provider = getattr(response, "provider", None) or "unknown"
        modelName = getattr(response, "modelName", None) or "unknown"
        try:
            billingService.recordUsage(
                priceCHF=basePriceCHF,
                aicoreProvider=provider,
                aicoreModel=modelName,
                description=f"i18n translation ({modelName})",
                processingTime=getattr(response, "processingTime", None),
                bytesSent=getattr(response, "bytesSent", None),
                bytesReceived=getattr(response, "bytesReceived", None),
            )
        except Exception as e:
            logger.error("i18n billing callback failed: %s", e)

    return _cb


async def _translateBatch(
    keysToTranslate: Dict[str, str],
    targetLanguageLabel: str,
    targetCode: str,
    billingCallback=None,
) -> Dict[str, str]:
    """Translate German keys into targetLanguageLabel.

    keysToTranslate: { germanKey: uiContext }
    Returns: { germanKey: translatedValue }
    """
    if not keysToTranslate:
        return {}

    aiObjects = await _getAiObjects()
    allKeys = list(keysToTranslate.items())
    totalBatches = math.ceil(len(allKeys) / _TRANSLATE_BATCH_SIZE)
    result: Dict[str, str] = {}

    for batchIdx in range(totalBatches):
        chunk = allKeys[batchIdx * _TRANSLATE_BATCH_SIZE : (batchIdx + 1) * _TRANSLATE_BATCH_SIZE]
        payload = [{"key": k, "context": v} for k, v in chunk]
        jsonPayload = json.dumps(payload, ensure_ascii=False)

        systemPrompt = (
            f"Du bist ein professioneller Übersetzer für Software-UI-Texte. "
            f"Du erhältst ein JSON-Array mit Objekten: {{\"key\": \"deutscher Text\", \"context\": \"UI-Kontext\"}}. "
            f"Der Kontext beschreibt, wo der Text in der Anwendung verwendet wird (Datei, Komponente). "
            f"Übersetze jeden «key» ins {targetLanguageLabel} (ISO {targetCode}). "
            f"Behalte Platzhalter wie {{variable}} exakt bei. "
            f"Antworte NUR mit einem JSON-Objekt — Keys = deutsche Originaltexte, Values = Übersetzungen. "
            f"Kein Markdown, kein Kommentar."
        )

        aiRequest = AiCallRequest(
            prompt=f"Übersetze diese UI-Labels:\n{jsonPayload}",
            context=systemPrompt,
            options=AiCallOptions(
                operationType=OperationTypeEnum.DATA_GENERATE,
                priority=PriorityEnum.BALANCED,
                compressPrompt=False,
                compressContext=False,
                resultFormat="json",
                temperature=0.2,
            ),
        )

        if billingCallback:
            aiObjects.billingCallback = billingCallback

        batchDone = False
        for retryAttempt in range(_TRANSLATE_RATE_LIMIT_MAX_RETRIES):
            try:
                response = await aiObjects.callWithTextContext(aiRequest)
                if response and response.content:
                    raw = response.content.strip()
                    if raw.startswith("```"):
                        raw = re.sub(r"^```[a-z]*\n?", "", raw)
                        raw = re.sub(r"\n?```$", "", raw)
                    parsed = json.loads(raw)
                    if isinstance(parsed, dict):
                        result.update(parsed)
                    else:
                        logger.warning("i18n AI batch %d/%d returned non-dict", batchIdx + 1, totalBatches)
                else:
                    logger.warning("i18n AI batch %d/%d empty response", batchIdx + 1, totalBatches)
                batchDone = True
                break
            except json.JSONDecodeError as je:
                logger.error("i18n AI batch %d/%d JSON parse error: %s", batchIdx + 1, totalBatches, je)
                batchDone = True
                break
            except Exception as e:
                errStr = str(e)
                if "rate_limit" in errStr.lower() or "429" in errStr or "Rate limit" in errStr:
                    waitSec = _TRANSLATE_BATCH_PAUSE_S * (2 ** retryAttempt)
                    logger.warning(
                        "i18n AI batch %d/%d rate-limited (attempt %d/%d), waiting %.1fs",
                        batchIdx + 1, totalBatches, retryAttempt + 1,
                        _TRANSLATE_RATE_LIMIT_MAX_RETRIES, waitSec,
                    )
                    await asyncio.sleep(waitSec)
                    continue
                logger.error("i18n AI batch %d/%d failed: %s", batchIdx + 1, totalBatches, e)
                batchDone = True
                break
            finally:
                aiObjects.billingCallback = None

        if not batchDone:
            logger.error("i18n AI batch %d/%d exhausted rate-limit retries", batchIdx + 1, totalBatches)

        if batchIdx < totalBatches - 1:
            await asyncio.sleep(_TRANSLATE_BATCH_PAUSE_S)

    _matchCapitalization(keysToTranslate, result)
    return result


def _matchCapitalization(originals: Dict[str, str], translations: Dict[str, str]) -> None:
    """Ensure translations preserve the capitalisation pattern of the original key."""
    for key, translated in translations.items():
        if not key or not translated:
            continue
        if key[0].isupper() and translated[0].islower():
            translations[key] = translated[0].upper() + translated[1:]
        elif key[0].islower() and translated[0].isupper():
            translations[key] = translated[0].lower() + translated[1:]


def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str:
    userId = str(currentUser.id)
    memberIds = _userMemberMandateIds(currentUser)
    if not memberIds:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=routeApiMsg("Mindestens eine Mandats-Mitgliedschaft ist für die AI-Nutzung erforderlich."),
        )

    headerRaw = (
        request.headers.get("X-Mandate-Id") or request.headers.get("x-mandate-id") or ""
    ).strip()
    if headerRaw:
        if headerRaw not in memberIds:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=routeApiMsg("X-Mandate-Id ist kein Mandat Ihrer Mitgliedschaft."),
            )
        if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId):
            return headerRaw
    for mid in memberIds:
        if _mandatePassesAiPoolBilling(currentUser, mid, userId):
            return mid
    raise HTTPException(
        status_code=status.HTTP_402_PAYMENT_REQUIRED,
        detail=routeApiMsg("Nicht genügend AI-Guthaben (Mandats-Pool) für diese Aktion."),
    )


# ---------------------------------------------------------------------------
# xx-Master sync from frontend codebase (local dev fallback)
# ---------------------------------------------------------------------------

_REPO_ROOT = Path(__file__).resolve().parents[3]
_FRONTEND_SRC = _REPO_ROOT / "frontend_nyla" / "src"

_T_CALL_RE = re.compile(r"""\bt\(\s*'((?:\\.|[^'])+)'\s*(?:,|\))""")


def _scanCodebaseKeys() -> List[dict]:
    """Local dev fallback: scan frontend src for t() calls. Returns entries with context='ui'."""
    keys: Set[str] = set()
    if not _FRONTEND_SRC.is_dir():
        logger.warning("i18n codebase scan: %s not found", _FRONTEND_SRC)
        return []

    for ext in ("*.tsx", "*.ts"):
        for filepath in _FRONTEND_SRC.rglob(ext):
            try:
                content = filepath.read_text(encoding="utf-8", errors="replace")
            except OSError:
                continue
            for m in _T_CALL_RE.finditer(content):
                raw = m.group(1)
                raw = raw.replace("\\'", "'")
                if raw:
                    keys.add(raw)
    return [{"context": "ui", "key": k, "value": ""} for k in sorted(keys)]


async def _readOptionalEntriesFromBody(request: Request) -> Optional[List[dict]]:
    """Read entries from request body. Accepts {entries: [{context, key, value}, ...]}."""
    body = await request.body()
    if not body or not body.strip():
        return None
    try:
        data = json.loads(body.decode("utf-8"))
    except (json.JSONDecodeError, UnicodeDecodeError):
        return None
    if not isinstance(data, dict) or "entries" not in data:
        return None
    entries = data.get("entries")
    if not isinstance(entries, list):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=routeApiMsg("Feld «entries» muss ein JSON-Array sein."),
        )
    result = []
    for e in entries:
        if not isinstance(e, dict) or not e.get("key"):
            continue
        result.append({
            "context": str(e.get("context", "ui")),
            "key": str(e["key"]),
            "value": str(e.get("value", "")),
        })
    return result if result else None


def _syncXxMaster(db, userId: Optional[str], incomingEntries: List[dict]) -> Dict[str, Any]:
    """Synchronise the xx base set with incoming UI entries.

    Only touches entries whose context is "ui". Gateway entries (api.*, table.*)
    written by _syncRegistryToDb at boot are preserved untouched.
    """
    if not incomingEntries:
        logger.warning("i18n xx-sync: no entries — aborting")
        return {"added": [], "removed": [], "entriesCount": 0, "error": "No entries to sync"}

    rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"})
    if not rows:
        now = getUtcTimestamp()
        rec = {
            "id": "xx",
            "label": "Basisset (Meta)",
            "entries": incomingEntries,
            "status": "complete",
            "isDefault": True,
            "sysCreatedAt": now,
            "sysCreatedBy": userId,
            "sysModifiedAt": now,
            "sysModifiedBy": userId,
        }
        db.recordCreate(UiLanguageSet, rec)
        allKeys = [e["key"] for e in incomingEntries]
        logger.info("i18n xx-master created: %d entries", len(incomingEntries))
        return {"added": allKeys, "removed": [], "entriesCount": len(incomingEntries)}

    row = dict(rows[0])
    curEntries = _rowEntries(row)

    gatewayEntries = [e for e in curEntries if e.get("context", "ui") != "ui"]
    curUiByKey = {e["key"]: e for e in curEntries if e.get("context", "ui") == "ui"}
    incomingByKey = {e["key"]: e for e in incomingEntries}

    incomingKeys = set(incomingByKey.keys())
    dbUiKeys = set(curUiByKey.keys())

    added = sorted(incomingKeys - dbUiKeys)
    removed = sorted(dbUiKeys - incomingKeys)

    newUiEntries = [
        {"context": e["context"], "key": e["key"], "value": e["value"]}
        for e in incomingEntries
    ]

    if not added and not removed and all(
        curUiByKey.get(e["key"], {}).get("value") == e["value"]
        and curUiByKey.get(e["key"], {}).get("context") == e["context"]
        for e in incomingEntries
    ):
        total = len(newUiEntries) + len(gatewayEntries)
        return {"added": [], "removed": [], "entriesCount": total}

    mergedEntries = gatewayEntries + newUiEntries

    now = getUtcTimestamp()
    row["entries"] = mergedEntries
    if "keys" in row:
        del row["keys"]
    row["sysModifiedAt"] = now
    row["sysModifiedBy"] = userId
    db.recordModify(UiLanguageSet, "xx", row)

    logger.info(
        "i18n xx-master sync: +%d added, -%d removed (ui=%d, gateway=%d, total=%d)",
        len(added), len(removed), len(newUiEntries), len(gatewayEntries), len(mergedEntries),
    )
    return {"added": added, "removed": removed, "entriesCount": len(mergedEntries)}


# --- Public -----------------------------------------------------------------


@router.get("/codes")
async def list_language_codes():
    db = _publicMgmtDb()
    rows = db.getRecordset(UiLanguageSet)
    out = []
    for r in rows:
        entries = _rowEntries(r)
        uiCount = sum(1 for e in entries if e.get("context", "ui") == "ui")
        gatewayCount = len(entries) - uiCount
        code = r["id"]
        out.append(
            {
                "code": code,
                "label": r.get("label"),
                "status": r.get("status"),
                "isDefault": bool(r.get("isDefault")),
                "entriesCount": len(entries),
                "uiCount": uiCount,
                "gatewayCount": gatewayCount,
                "updating": code in _UPDATING_CODES,
            }
        )
    return sorted(out, key=lambda x: (not x.get("isDefault"), x["code"]))


@router.get("/sets/{code}")
async def get_language_set(code: str):
    db = _publicMgmtDb()
    rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
    if not rows:
        raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
    return _row_to_public(rows[0])


# --- Auth user --------------------------------------------------------------


class CreateLanguageBody(BaseModel):
    code: str = Field(..., min_length=2, max_length=10)
    label: Optional[str] = Field(default=None, max_length=80)


def _validate_iso2_code(code: str) -> str:
    c = code.strip().lower()
    if not re.fullmatch(r"[a-z]{2,3}", c):
        raise HTTPException(
            status_code=400, detail=routeApiMsg("Nur ISO-639 Sprachcodes (2–3 Buchstaben) erlaubt.")
        )
    return c


async def _translateTextMultilingualFields(db, langCode: str, langLabel: str, billingCb=None) -> int:
    """Batch-translate all TextMultilingual fields (Role.description, Feature.label) for a new language."""
    textsToTranslate: Dict[str, str] = {}

    roles = db.getRecordset(Role)
    for r in roles:
        desc = r.get("description") if isinstance(r, dict) else getattr(r, "description", None)
        if isinstance(desc, dict):
            sourceText = desc.get("xx", "")
            if sourceText and not desc.get(langCode):
                textsToTranslate[f"role:{r.get('id') if isinstance(r, dict) else r.id}:description"] = sourceText

    features = db.getRecordset(Feature)
    for f in features:
        lbl = f.get("label") if isinstance(f, dict) else getattr(f, "label", None)
        if isinstance(lbl, dict):
            sourceText = lbl.get("xx", "")
            if sourceText and not lbl.get(langCode):
                textsToTranslate[f"feature:{f.get('code') if isinstance(f, dict) else f.code}:label"] = sourceText

    if not textsToTranslate:
        return 0

    keysForAi = {v: "User-generated content field" for v in textsToTranslate.values()}
    uniqueTexts = list(set(keysForAi.keys()))
    keysForAi = {t: "User-generated content field" for t in uniqueTexts}
    translated = await _translateBatch(keysForAi, langLabel, langCode, billingCallback=billingCb)

    count = 0
    for compositeKey, deText in textsToTranslate.items():
        translatedText = translated.get(deText)
        if not translatedText:
            continue
        parts = compositeKey.split(":")
        if parts[0] == "role":
            roleId = parts[1]
            rows = db.getRecordset(Role, recordFilter={"id": roleId})
            if rows:
                rec = dict(rows[0]) if not isinstance(rows[0], dict) else rows[0]
                desc = rec.get("description", {})
                if isinstance(desc, dict):
                    desc[langCode] = translatedText
                    rec["description"] = desc
                    db.recordModify(Role, roleId, rec)
                    count += 1
        elif parts[0] == "feature":
            featureCode = parts[1]
            rows = db.getRecordset(Feature, recordFilter={"code": featureCode})
            if rows:
                rec = dict(rows[0]) if not isinstance(rows[0], dict) else rows[0]
                lbl = rec.get("label", {})
                if isinstance(lbl, dict):
                    lbl[langCode] = translatedText
                    rec["label"] = lbl
                    db.recordModify(Feature, featureCode, rec)
                    count += 1

    logger.info("TextMultilingual batch translate: %d fields translated to %s", count, langCode)
    return count


def _run_create_language_job(userId: str, code: str, label: str, currentUser: User, mandateId: str) -> None:
    loop = asyncio.new_event_loop()
    try:
        loop.run_until_complete(_run_create_language_job_async(userId, code, label, currentUser, mandateId))
    finally:
        loop.close()


async def _run_create_language_job_async(userId: str, code: str, label: str, currentUser: User, mandateId: str) -> None:
    _UPDATING_CODES.add(code)
    try:
        db = _publicMgmtDb()
        rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
        if not rows:
            return
        xxEntries = _loadMasterXxEntries(db)
        if not xxEntries:
            logger.error("i18n create job: no xx master entries found")
            return

        toTranslate = {e["key"]: e.get("value", "") for e in xxEntries}
        billingCb = _makeBillingCallback(currentUser, mandateId)
        translated = await _translateBatch(toTranslate, label, code, billingCallback=billingCb)

        finalEntries = []
        for e in xxEntries:
            k = e["key"]
            finalEntries.append({
                "context": e["context"],
                "key": k,
                "value": translated.get(k, f"[{k}]"),
            })

        missingCount = sum(1 for e in xxEntries if e["key"] not in translated)
        finalStatus = "complete" if missingCount == 0 else "incomplete"

        now = getUtcTimestamp()
        merged = dict(rows[0])
        merged["entries"] = finalEntries
        if "keys" in merged:
            del merged["keys"]
        merged["status"] = finalStatus
        merged["label"] = label
        merged["sysModifiedAt"] = now
        merged["sysModifiedBy"] = userId
        db.recordModify(UiLanguageSet, code, merged)

        statusHint = "" if finalStatus == "complete" else f" ({missingCount} Keys ohne Übersetzung)"

        tmCount = await _translateTextMultilingualFields(db, code, label, billingCb)

        _createNotification(
            userId,
            NotificationType.SYSTEM,
            title="Sprachset erstellt",
            message=f"Die Sprache «{label}» ({code}) wurde per KI übersetzt{statusHint}. {tmCount} Inhaltsfelder übersetzt.",
        )
        await _reloadI18nCache()
        logger.info("i18n create job done: code=%s, translated=%d/%d, tm_fields=%d", code, len(translated), len(xxEntries), tmCount)
    except Exception as e:
        logger.exception("create language job failed: %s", e)
        _createNotification(
            userId,
            NotificationType.SYSTEM,
            title="Sprachset fehlgeschlagen",
            message=f"Fehler bei «{code}»: {e}",
        )
    finally:
        _UPDATING_CODES.discard(code)


@router.post("/sets")
async def create_language_set(
    request: Request,
    body: CreateLanguageBody,
    background: BackgroundTasks,
    currentUser: User = Depends(getCurrentUser),
):
    mandateId = _resolveMandateIdForAiI18n(request, currentUser)
    code = _validate_iso2_code(body.code)
    if code == "xx":
        raise HTTPException(status_code=400, detail=routeApiMsg("Das Basisset «xx» kann nicht manuell angelegt werden."))

    db = _publicMgmtDb()
    existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
    if existing:
        raise HTTPException(status_code=409, detail=routeApiMsg("Dieses Sprachset existiert bereits."))

    xxEntries = _loadMasterXxEntries(db)
    if not xxEntries:
        raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden. Bitte zuerst UI-Keys einlesen."))

    resolvedLabel = (body.label or "").strip() if body.label else ""
    if not resolvedLabel:
        resolvedLabel = _ISO_LABELS.get(code, code)

    now = getUtcTimestamp()
    uid = str(currentUser.id)
    rec: dict = {
        "id": code,
        "label": resolvedLabel,
        "entries": [],
        "status": "generating",
        "isDefault": False,
        "sysCreatedAt": now,
        "sysCreatedBy": uid,
        "sysModifiedAt": now,
        "sysModifiedBy": uid,
    }
    db.recordCreate(UiLanguageSet, rec)

    background.add_task(_run_create_language_job, uid, code, resolvedLabel, currentUser, mandateId)
    _createNotification(
        uid,
        NotificationType.SYSTEM,
        title="Sprachset wird erzeugt",
        message=f"Die Sprache «{code}» wird im Hintergrund per KI übersetzt.",
    )
    return {"status": "accepted", "code": code}


def _compute_language_sync_diff(db, code: str) -> dict:
    """Return key sync metrics before AI translate (no DB writes)."""
    if code == "xx":
        raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird separat synchronisiert."))
    rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
    if not rows:
        raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
    xxEntries = _loadMasterXxEntries(db)
    if not xxEntries:
        raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden."))
    row = dict(rows[0])
    curEntries = _rowEntries(row)
    masterIds = {_entryId(e) for e in xxEntries}
    currentIds = {_entryId(e) for e in curEntries}
    return {
        "code": code,
        "addedCount": len(masterIds - currentIds),
        "removedCount": len(currentIds - masterIds),
        "masterEntryCount": len(masterIds),
        "currentEntryCount": len(currentIds),
    }


def _entryId(e: dict) -> tuple:
    """Composite identifier for an i18n entry: (key, context)."""
    return (e["key"], e.get("context", "ui"))


async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: Optional[User] = None) -> dict:
    """Synchronise a language set (incl. de) against the xx base set via AI.

    Entries are identified by (key, context) — the same text can appear
    with different contexts (e.g. "ui" and "api.routeXyz").
    """
    if code == "xx":
        raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird über 'UI-Keys einlesen' aktualisiert."))
    _UPDATING_CODES.add(code)
    try:
        rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
        if not rows:
            raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
        xxEntries = _loadMasterXxEntries(db)
        if not xxEntries:
            raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden."))

        row = dict(rows[0])
        curEntries = _rowEntries(row)
        curById = {_entryId(e): e for e in curEntries}
        xxById = {_entryId(e): e for e in xxEntries}

        masterIds = set(xxById.keys())
        currentIds = set(curById.keys())
        removedIds = currentIds - masterIds
        addedIds = masterIds - currentIds

        translatedCount = 0
        if addedIds:
            toTranslate = {xxById[eid]["key"]: xxById[eid].get("value", "") for eid in addedIds}
            langLabel = row.get("label") or code
            billingCb = None
            if adminUser:
                memberIds = _userMemberMandateIds(adminUser)
                if memberIds:
                    billingCb = _makeBillingCallback(adminUser, memberIds[0])
            try:
                translated = await _translateBatch(toTranslate, langLabel, code, billingCallback=billingCb)
                translatedCount = sum(1 for eid in addedIds if xxById[eid]["key"] in translated)
            except Exception as e:
                logger.error("AI translation during sync failed for %s: %s", code, e)
                translated = {}

            for eid in addedIds:
                xxEntry = xxById[eid]
                curById[eid] = {
                    "context": xxEntry["context"],
                    "key": xxEntry["key"],
                    "value": translated.get(xxEntry["key"], f"[{xxEntry['key']}]"),
                }

        for eid in removedIds:
            del curById[eid]

        for eid in masterIds & currentIds:
            curById[eid]["context"] = xxById[eid]["context"]

        newEntries = sorted(curById.values(), key=lambda e: (e["key"].lower(), e.get("context", "")))

        now = getUtcTimestamp()
        untranslated = len(addedIds) - translatedCount
        row["entries"] = newEntries
        if "keys" in row:
            del row["keys"]
        row["status"] = "complete" if untranslated == 0 else "incomplete"
        row["sysModifiedAt"] = now
        row["sysModifiedBy"] = userId
        db.recordModify(UiLanguageSet, code, row)
        return {
            "code": code,
            "added": sorted({xxById[eid]["key"] for eid in addedIds}),
            "removed": sorted({eid[0] for eid in removedIds}),
            "translated": translatedCount,
            "entriesCount": len(newEntries),
        }
    finally:
        _UPDATING_CODES.discard(code)


@router.put("/sets/sync-xx")
async def sync_xx_master(
    request: Request,
    adminUser: User = Depends(requireSysAdminRole),
):
    """Synchronise the xx base set from the frontend build artefact.

    Expects JSON body: {"entries": [{"context":"ui","key":"…","value":"…"}, …]}
    Falls back to local codebase scan if no body provided (dev mode).
    """
    db = getMgmtInterface(adminUser, mandateId=None).db
    fromBody = await _readOptionalEntriesFromBody(request)
    entries = fromBody if fromBody is not None else _scanCodebaseKeys()
    result = _syncXxMaster(db, str(adminUser.id), entries)
    await _reloadI18nCache()
    return result


@router.get("/sets/{code}/sync-diff")
async def get_language_sync_diff(
    code: str,
    adminUser: User = Depends(requireSysAdminRole),
):
    """How many keys would be added/removed vs xx before running a full sync (SysAdmin)."""
    c = code.strip().lower()
    if c in ("update-all", "sync-xx", "sync-de"):
        raise HTTPException(status_code=400, detail=routeApiMsg("Ungültiger Sprachcode."))
    db = getMgmtInterface(adminUser, mandateId=None).db
    return _compute_language_sync_diff(db, c)


@router.put("/sets/{code}")
async def update_language_set(
    code: str,
    adminUser: User = Depends(requireSysAdminRole),
):
    c = code.strip().lower()
    if c in ("update-all", "sync-xx", "sync-de"):
        raise HTTPException(status_code=400, detail=routeApiMsg("Ungültiger Sprachcode."))
    if c == "xx":
        raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird über 'UI-Keys einlesen' aktualisiert."))
    db = getMgmtInterface(adminUser, mandateId=None).db
    result = await _syncLanguageWithXx(db, c, str(adminUser.id), adminUser=adminUser)
    await _reloadI18nCache()
    return result


@router.delete("/sets/{code}")
async def delete_language_set(
    code: str,
    adminUser: User = Depends(requireSysAdminRole),
):
    c = code.strip().lower()
    if c in _PROTECTED_CODES:
        raise HTTPException(status_code=400, detail=f"Das Set «{c}» darf nicht gelöscht werden.")
    db = getMgmtInterface(adminUser, mandateId=None).db
    ok = db.recordDelete(UiLanguageSet, c)
    if not ok:
        raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
    await _reloadI18nCache()
    return {"deleted": c}


@router.get("/sets/{code}/download", dependencies=[Depends(getCurrentUser)])
async def download_language_set(
    code: str,
    currentUser: User = Depends(getCurrentUser),
):
    db = _publicMgmtDb()
    rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code.strip().lower()})
    if not rows:
        raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
    payload = _row_to_public(rows[0])
    raw = json.dumps(payload, ensure_ascii=False, indent=2)
    return Response(
        content=raw,
        media_type="application/json",
        headers={
            "Content-Disposition": f'attachment; filename="ui-language-{code}.json"'
        },
    )


# --- Export / Import (full DB) -----------------------------------------------


@router.get("/export")
async def export_all_language_sets(
    adminUser: User = Depends(requireSysAdminRole),
):
    db = getMgmtInterface(adminUser, mandateId=None).db
    rows = db.getRecordset(UiLanguageSet)
    payload = []
    for r in rows:
        entries = _rowEntries(r)
        payload.append({
            "id": r["id"],
            "label": r.get("label", ""),
            "entries": entries,
            "status": r.get("status", "complete"),
            "isDefault": bool(r.get("isDefault", False)),
        })
    payload.sort(key=lambda x: (not x.get("isDefault"), x["id"]))
    raw = json.dumps(payload, ensure_ascii=False, indent=2)
    return Response(
        content=raw,
        media_type="application/json",
        headers={
            "Content-Disposition": 'attachment; filename="ui-languages-export.json"'
        },
    )


@router.post("/import")
async def import_language_sets(
    file: UploadFile = File(...),
    adminUser: User = Depends(requireSysAdminRole),
):
    if not file.filename or not file.filename.endswith(".json"):
        raise HTTPException(status_code=400, detail=routeApiMsg("Nur .json-Dateien erlaubt."))

    try:
        raw = await file.read()
        data = json.loads(raw.decode("utf-8"))
    except (json.JSONDecodeError, UnicodeDecodeError) as e:
        raise HTTPException(status_code=400, detail=f"Ungültiges JSON: {e}")

    if not isinstance(data, list):
        raise HTTPException(status_code=400, detail=routeApiMsg("JSON muss ein Array von Sprachsets sein."))

    db = getMgmtInterface(adminUser, mandateId=None).db
    now = getUtcTimestamp()
    uid = str(adminUser.id)
    created = []
    updated = []

    for entry in data:
        if not isinstance(entry, dict):
            continue
        code = str(entry.get("id", "")).strip().lower()
        if not code or len(code) < 2:
            continue

        entries = entry.get("entries")
        if not isinstance(entries, list):
            keys = entry.get("keys")
            if isinstance(keys, dict):
                entries = [{"context": "ui", "key": k, "value": v} for k, v in keys.items()]
            else:
                continue

        label = str(entry.get("label", code))
        entryStatus = str(entry.get("status", "complete"))
        isDefault = bool(entry.get("isDefault", False))

        existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
        if existing:
            row = dict(existing[0])
            row["entries"] = entries
            if "keys" in row:
                del row["keys"]
            row["label"] = label
            row["status"] = entryStatus
            row["isDefault"] = isDefault
            row["sysModifiedAt"] = now
            row["sysModifiedBy"] = uid
            db.recordModify(UiLanguageSet, code, row)
            updated.append(code)
        else:
            rec = {
                "id": code,
                "label": label,
                "entries": entries,
                "status": entryStatus,
                "isDefault": isDefault,
                "sysCreatedAt": now,
                "sysCreatedBy": uid,
                "sysModifiedAt": now,
                "sysModifiedBy": uid,
            }
            db.recordCreate(UiLanguageSet, rec)
            created.append(code)

    logger.info("i18n import: created=%s, updated=%s", created, updated)
    await _reloadI18nCache()
    return {"created": created, "updated": updated, "totalProcessed": len(created) + len(updated)}


# ---------------------------------------------------------------------------
# Phase 7b: translate-field — on-demand translation for TextMultilingual fields
# ---------------------------------------------------------------------------

_TRANSLATE_FIELD_MAX_LEN = 2000


class _TargetLang(BaseModel):
    code: str = Field(..., min_length=2, max_length=10)
    label: str = Field(default="")


class TranslateFieldRequest(BaseModel):
    sourceText: str = Field(..., min_length=1, max_length=_TRANSLATE_FIELD_MAX_LEN)
    sourceLang: str = Field(default="de", min_length=2, max_length=5)
    targetLangs: List[_TargetLang] = Field(..., min_length=1)


@router.post("/translate-field")
async def translateField(
    body: TranslateFieldRequest,
    request: Request,
    currentUser: User = Depends(getCurrentUser),
):
    """Translate a single text into one or more target languages (for TextMultilingual fields)."""
    targets = [t for t in body.targetLangs if t.code != body.sourceLang]
    if not targets:
        return {"translations": {}}

    mandateId = _resolveMandateIdForAiI18n(request, currentUser)
    billingCb = _makeBillingCallback(currentUser, mandateId)

    results: Dict[str, str] = {}
    for target in targets:
        targetLabel = target.label or _ISO_LABELS.get(target.code, target.code)
        keysToTranslate = {body.sourceText: "TextMultilingual field"}
        translated = await _translateBatch(keysToTranslate, targetLabel, target.code, billingCb)
        val = translated.get(body.sourceText, "")
        if val:
            results[target.code] = val

    return {"translations": results}
