"""
Azure exam (AZ-104, AZ-305, …) Vietnamese translation glossary and post-processing.

Term detection: Microsoft/Azure names are usually Title Case, acronyms (SKU, VM), or
Azure-prefixed phrases — keep them in English in vi fields.
"""
from __future__ import annotations

import re
from typing import Callable

# ---------------------------------------------------------------------------
# Static baseline (always protect during machine translate)
# ---------------------------------------------------------------------------
PROTECT_TERMS: list[str] = [
    "Azure Active Directory",
    "Azure AD Connect",
    "Azure AD-joined",
    "Azure AD Conditional Access",
    "Azure AD",
    "Azure Key Vault",
    "Azure Resource Manager",
    "Azure Storage",
    "Azure portal",
    "Azure subscription",
    "Azure Virtual Network",
    "Azure Virtual WAN",
    "Azure Firewall",
    "Azure Bastion",
    "Azure Backup",
    "Azure Site Recovery",
    "Azure Monitor",
    "Azure Policy",
    "Azure Blueprints",
    "Azure Kubernetes Service",
    "Azure Container Registry",
    "Azure container registry",
    "Azure Container Instances",
    "Azure Functions",
    "Azure App Service",
    "Azure SQL",
    "Azure Cosmos DB",
    "Azure Files",
    "Azure Blob Storage",
    "Azure Load Balancer",
    "Azure Application Gateway",
    "Azure Front Door",
    "Azure Traffic Manager",
    "Azure DNS",
    "Azure Private Link",
    "Azure Private Endpoint",
    "Azure ExpressRoute",
    "Azure VPN Gateway",
    "Azure DDoS Protection",
    "Microsoft Entra ID",
    "Microsoft Defender for Cloud",
    "Microsoft Sentinel",
    "Storage Account Key Operator Service Role",
    "Storage Account Key",
    "Reader and Data Access",
    "Recovery Services vault",
    "Log Analytics workspace",
    "Application Insights",
    "Resource Group",
    "Virtual Machine",
    "Virtual Network",
    "Network Security Group",
    "User Defined Route",
    "Route Table",
    "Public IP",
    "Network Interface",
    "Availability Set",
    "Availability Zone",
    "Update Domain",
    "Fault Domain",
    "Scale Set",
    "Managed Disk",
    "Storage Account",
    "Shared Access Signature",
    "Role-Based Access Control",
    "Conditional Access",
    "Multi-Factor Authentication",
    "Privileged Identity Management",
    "Identity Protection",
    "Global Administrators",
    "Service Principal",
    "Managed Identity",
    "Key Vault",
    "ARM template",
    "Trusted Microsoft services",
    "Selected networks",
    "Service endpoint",
    "Private endpoint",
    "Point-to-Site",
    "Site-to-Site",
    "backend pool",
    "geo-replication",
    "scope map",
    "Admin user",
    "cache rule",
    "automation task",
    "blade",
    "NSG",
    "UDR",
    "NIC",
    "RA-GRS",
    "GRS",
    "LRS",
    "ZRS",
    "RPO",
    "RTO",
    # AZ-305 identity / governance
    "Azure AD Access Reviews",
    "access review",
    "Entitlement Management",
    "Azure AD Entitlement Management",
    "Privileged Identity Management",
    "Azure AD Privileged Identity Management",
    "Identity Protection",
    "Azure AD Join",
    "pass-through authentication",
    "self-service",
    # AZ-305 automation / ops
    "Azure Automation runbook",
    "runbook",
    "cmdlet",
    "Get-AzRoleAssignment",
    "Get-AzureADUserAppRoleAssignment",
    # AZ-305 network / monitor
    "Azure Network Watcher",
    "Network Watcher",
    "IP flow verify",
    "Traffic Analytics",
    "Connection Monitor",
    "Azure Monitor",
    "Log Analytics",
    "Activity Log",
    "diagnostic settings",
    "Azure Event Hub",
    "Event Hub",
    # AZ-305 compute / DR / identity for apps
    "Azure Logic Apps",
    "user-assigned managed identity",
    "system-assigned managed identity",
    "user-assigned",
    "system-assigned",
    "landing zone",
    "Azure Landing Zone",
    "Azure VMware Solution",
    "Azure Site Recovery",
    "Azure Backup",
    "Availability Set",
    "VM Availability Set",
    "Azure VM Availability Set",
    "Azure Migrate",
    "Azure Arc",
    "Azure Kubernetes Service",
    "AKS",
    "Azure Firewall Manager",
    "Azure Front Door",
    "Application Gateway",
    "WAF",
    "DDoS Protection",
    "Private Link",
    "hub-spoke",
    "virtual WAN",
    "ExpressRoute",
    "VPN gateway",
    "recommend",
]

# Sentence starters — single word at clause start is usually not a product term
_SENTENCE_STARTERS = frozenset(
    {
        "You",
        "Your",
        "The",
        "This",
        "That",
        "These",
        "Those",
        "After",
        "Before",
        "When",
        "While",
        "Which",
        "What",
        "Where",
        "Who",
        "How",
        "Does",
        "Do",
        "Did",
        "Is",
        "Are",
        "Was",
        "Were",
        "Can",
        "Could",
        "Should",
        "Would",
        "Will",
        "Must",
        "May",
        "Might",
        "If",
        "In",
        "On",
        "At",
        "To",
        "From",
        "For",
        "With",
        "Without",
        "By",
        "As",
        "An",
        "A",
        "No",
        "Yes",
        "Not",
        "Each",
        "Every",
        "All",
        "Any",
        "Some",
        "One",
        "Two",
        "Three",
        "Four",
        "Five",
        "Six",
        "Seven",
        "Eight",
        "Nine",
        "Ten",
        "However",
        "Therefore",
        "Also",
        "Additionally",
        "Reference",
        "References",
        "Note",
        "Solution",
        "Box",
        "Incorrect",
        "Correct",
        "Ideally",
        "During",
        "Given",
        "Select",
        "Create",
        "Delete",
        "Modify",
        "Enable",
        "Disable",
        "Assign",
        "Configure",
        "Establish",
    }
)

# Lowercase connectors allowed inside a Title Case product phrase
_IN_PHRASE_CONNECTORS = frozenset(
    {"and", "or", "for", "of", "the", "to", "in", "with", "from", "a", "an", "by", "on", "as"}
)

# Words that signal RBAC / portal / SKU terminology when capitalized
_TERM_SIGNAL_WORDS = re.compile(
    r"\b(?:SKU|Role|blade|Operator|Contributor|Reader|Owner|Administrator|"
    r"Access|Key|Vault|Registry|Firewall|Gateway|Load|Balancer|Subnet|"
    r"Peering|Replication|Backup|Restore|Snapshot|Disk|Blob|Container|"
    r"Endpoint|Peering|VPN|ExpressRoute|Bastion|Policy|Identity|Tenant|"
    r"Subscription|Deployment|Redeploy|Failover|Standard|Premium|Basic|"
    r"Review|Entitlement|Runbook|Cmdlet|Watcher|Analytics|Flow|"
    r"Diagnostic|Migrate|Recovery|"
    r"Microsoft|Azure)\b",
    re.I,
)

# Word: Title Case, ACRONYM, or name with digits (VM1, User1, VpnGw1)
_WORD = re.compile(
    r"[A-Z][a-zA-Z0-9]*|[A-Z]{2,}[a-zA-Z0-9]*|\b[a-z]+[0-9]+\b|[A-Z][a-z]*[0-9]+\b"
)

_SKU_PHRASE = re.compile(
    r"\b((?:Standard|Premium|Basic|Free)\s+SKU"
    r"(?:\s+Azure)?"
    r"(?:\s+[a-z][a-zA-Z]*)*)",
    re.I,
)

_AZURE_LOWER_PHRASE = re.compile(
    r"\b(Azure\s+(?:[a-z][a-zA-Z]*)(?:\s+[a-z][a-zA-Z]*){0,4})",
)

# Built-in role / feature names often split across lines in PDF text
_ROLE_LINE = re.compile(
    r"Storage Account Key\s+Operator Service Role\d*",
    re.I,
)


def _normalize_phrase(s: str) -> str:
    return re.sub(r"\s+", " ", s.strip())


def _is_sentence_start(text: str, pos: int) -> bool:
    before = text[:pos].rstrip()
    return not before or before[-1] in ".!?\n:;"


def _capitalized_word_count(words: list[str]) -> int:
    return sum(
        1
        for w in words
        if w not in _IN_PHRASE_CONNECTORS
        and (w[0].isupper() or w.isupper() or re.search(r"\d", w))
    )


def _is_likely_product_phrase(phrase: str, words: list[str], *, at_sentence_start: bool) -> bool:
    if len(phrase) < 3:
        return False
    if _TERM_SIGNAL_WORDS.search(phrase):
        return True
    caps = _capitalized_word_count(words)
    if caps >= 2:
        return True
    if any(w.startswith("Azure") or w.startswith("Microsoft") for w in words):
        return True
    if re.search(r"\bSKU\b", phrase, re.I):
        return True
    # Single capitalized word: only if not a common sentence starter
    if caps == 1 and len(words) == 1:
        if at_sentence_start and words[0] in _SENTENCE_STARTERS:
            return False
        if words[0] in _SENTENCE_STARTERS:
            return False
        # Keep short acronyms / resource names
        if words[0].isupper() and len(words[0]) >= 2:
            return True
        if re.search(r"\d", words[0]):
            return True
    return False


def extract_capitalized_terms(text: str | None) -> list[str]:
    """
    Detect English phrases that should stay untranslated in vi (Title Case, SKU, Azure names).

    Rules (aligned with Microsoft exam wording):
    - 2+ consecutive Title Case words (Storage Account Key)
    - Tier + SKU (+ optional Azure + resource): Standard SKU Azure container registry
    - Azure + lowercase resource name: Azure container registry
    - Acronyms / names with digits: VpnGw1, ContReg1, User1
  - Known multi-line role names from PDF extraction
    """
    if not text or not str(text).strip():
        return []
    raw = str(text).replace("\n", " ")
    found: set[str] = set()

    for m in _ROLE_LINE.finditer(raw):
        found.add(_normalize_phrase(m.group(0)))

    for m in _SKU_PHRASE.finditer(raw):
        phrase = _normalize_phrase(m.group(1))
        # Title-case tier word
        phrase = re.sub(
            r"^(standard|premium|basic|free)\b",
            lambda x: x.group(1).capitalize(),
            phrase,
            flags=re.I,
        )
        phrase = re.sub(r"\bsku\b", "SKU", phrase, flags=re.I)
        found.add(phrase)

    for m in _AZURE_LOWER_PHRASE.finditer(raw):
        found.add(_normalize_phrase(m.group(1)))

    # Scan token stream for Title Case runs
    tokens: list[tuple[str, int]] = []
    for m in _WORD.finditer(raw):
        tokens.append((m.group(0), m.start()))

    i = 0
    while i < len(tokens):
        start = i
        chunk: list[str] = []
        while i < len(tokens):
            w = tokens[i][0]
            wl = w.lower()
            if not chunk:
                if w in _SENTENCE_STARTERS and _is_sentence_start(raw, tokens[i][1]):
                    i += 1
                    break
                chunk.append(w)
                i += 1
                continue
            if wl in _IN_PHRASE_CONNECTORS:
                chunk.append(w)
                i += 1
                continue
            if w[0].isupper() or w.isupper() or re.search(r"\d", w):
                chunk.append(w)
                i += 1
                continue
            break
        if chunk:
            phrase = _normalize_phrase(" ".join(chunk))
            words = phrase.split()
            at_start = _is_sentence_start(raw, tokens[start][1])
            if _is_likely_product_phrase(phrase, words, at_sentence_start=at_start):
                found.add(phrase)
        if i == start:
            i += 1

    # Drop substrings (keep longest): "Storage Account" dropped if "Storage Account Key" exists
    ordered = sorted(found, key=lambda s: (-len(s), s))
    kept: list[str] = []
    for term in ordered:
        if any(term != k and term.lower() in k.lower() for k in kept):
            continue
        kept.append(term)
    return kept


def collect_terms_for_text(text: str) -> list[str]:
    """Static glossary + capitalization-derived terms (longest first)."""
    dynamic = extract_capitalized_terms(text)
    combined = list(dict.fromkeys([*dynamic, *PROTECT_TERMS]))
    return sorted(combined, key=len, reverse=True)


# EN-aware fixes when vi lost a capitalized term from en
_EN_VI_TERM_RESTORE: list[tuple[str, str, str]] = [
    # (en_pattern, vi_wrong_pattern, replacement_from_en_or_literal)
    (
        r"Standard SKU Azure container registry",
        r"sổ đăng ký vùng chứa SKU Azure tiêu chuẩn|SKU Azure tiêu chuẩn[^.]*container|vùng chứa SKU Azure tiêu chuẩn",
        "Standard SKU Azure container registry",
    ),
    (
        r"Storage Account Key\s+Operator Service Role",
        r"Khóa storage account Vai trò dịch vụ (?:của nhà điều hành|Operator)[^\n.]*",
        "Storage Account Key Operator Service Role",
    ),
    (
        r"Reader and Data Access",
        r"Người đọc và Truy cập Dữ liệu|vai trò Người đọc và Truy cập Dữ liệu",
        "Reader and Data Access role",
    ),
    (
        r"Premium SKU",
        r"SKU cao cấp|sku cao cấp|tính năng SKU cao cấp",
        "Premium SKU",
    ),
    (
        r"Standard SKU",
        r"SKU tiêu chuẩn|sku tiêu chuẩn|SKU Tiêu chuẩn|nằm trên SKU tiêu chuẩn",
        "Standard SKU",
    ),
    (
        r"Basic SKU",
        r"SKU cơ bản|sku cơ bản|SKU Cơ bản",
        "Basic SKU",
    ),
    (
        r"Standard SKU public IP",
        r"IP công cộng SKU tiêu chuẩn|địa chỉ IP công cộng SKU tiêu chuẩn",
        "Standard SKU public IP address",
    ),
    (
        r"Basic SKU public IP",
        r"IP công cộng SKU cơ bản|địa chỉ IP công cộng SKU cơ bản",
        "Basic SKU public IP address",
    ),
    (
        r"registry is on the Standard SKU",
        r"đăng ký Standard SKU|đăng ký đang ở Standard SKU|của bạn đăng ký Standard SKU",
        "registry đang ở Standard SKU",
    ),
    (
        r"geo-replication",
        r"sao chép địa lý|sao chép theo địa lý",
        "geo-replication",
    ),
    (
        r"Premium SKU feature",
        r"Premium SKU và|tính năng Premium SKU",
        "tính năng Premium SKU",
    ),
    (
        r"backend pool",
        r"nhóm phụ trợ|pool phụ trợ",
        "backend pool",
    ),
    (
        r"scope map",
        r"bản đồ phạm vi",
        "scope map",
    ),
    (
        r"Admin user",
        r"người dùng quản trị\.",
        "Admin user",
    ),
    (
        r"cache rule",
        r"quy tắc bộ đệm",
        "cache rule",
    ),
    (
        r"automation task",
        r"nhiệm vụ tự động hóa",
        "automation task",
    ),
    # --- AZ-305 identity ---
    (
        r"access review",
        r"bản đánh giá quyền truy cập|đánh giá quyền truy cập(?!\s+hàng)",
        "access review",
    ),
    (
        r"Azure AD Access Reviews",
        r"Đánh giá quyền truy cập Azure AD|access review Azure AD",
        "Azure AD Access Reviews",
    ),
    (
        r"Azure Automation runbook",
        r"runbook tự động hóa Azure|runbook Azure Automation",
        "Azure Automation runbook",
    ),
    (
        r"Privileged Identity Management",
        r"Quản lý danh tính đặc quyền(?:\s+\(PIM\))?(?:\s+của Azure AD)?",
        "Privileged Identity Management (PIM)",
    ),
    (
        r"Entitlement Management",
        r"Quản lý quyền lợi(?:\s+Azure AD)?",
        "Entitlement Management",
    ),
    (
        r"Identity Protection",
        r"Bảo vệ danh tính(?:\s+Azure AD)?",
        "Identity Protection",
    ),
    (
        r"Azure Automation runbook",
        r"[Ss]ổ tay chạy(?:\s+tự động hóa)?\s+Azure(?:\s+Automation)?",
        "Azure Automation runbook",
    ),
    (
        r"runbook",
        r"[Ss]ổ tay chạy",
        "runbook",
    ),
    (
        r"Get-AzRoleAssignment|Get-AzureADUserAppRoleAssignment|cmdlet",
        r"lệnh ghép ngắn\s+",
        "",
    ),
    (
        r"IP flow verify",
        r"Xác minh luồng IP",
        "IP flow verify",
    ),
    (
        r"Traffic Analytics",
        r"Phân tích lưu lượng(?:\s+truy cập)?(?!\s+Traffic)",
        "Traffic Analytics",
    ),
    (
        r"user-assigned managed identity",
        r"[Dd]anh tính được quản lý do người dùng chỉ định",
        "user-assigned managed identity",
    ),
    (
        r"system-assigned managed identity",
        r"[Dd]anh tính được quản lý do hệ thống chỉ định",
        "system-assigned managed identity",
    ),
    (
        r"managed identity",
        r"danh tính được quản lý",
        "managed identity",
    ),
    (
        r"Activity Log",
        r"[Nn]hật ký hoạt động",
        "Activity Log",
    ),
    (
        r"Azure Logic Apps",
        r"Ứng dụng Azure Logic|phiên bản Ứng dụng Azure Logic",
        "Azure Logic Apps",
    ),
    (
        r"Azure Key Vault",
        r"kho khóa Azure",
        "Azure Key Vault",
    ),
    (
        r"VM insights",
        r"thông tin chuyên sâu về VM|chuyên sâu về VM",
        "VM insights",
    ),
    (
        r"Azure Monitor Agent",
        r"[Tt]ác nhân Giám sát Azure",
        "Azure Monitor Agent",
    ),
    (
        r"Dependency Agent",
        r"[Tt]ác nhân phụ thuộc",
        "Dependency Agent",
    ),
]

# (pattern, replacement) — post-process Vietnamese text
_POST_REPLACEMENTS: list[tuple[str, str]] = [
    (r"Azure Active Directory \(Azure AD\) tenant \(Azure AD\)", "Azure AD tenant"),
    (r"\(Azure AD\) tenant \(Azure AD\)", "Azure AD tenant"),
    (r"được sử dụng để triển khai nó", "được dùng để deploy"),
    (r"được triển khai\.", "được deploy."),
    (r"triển khai được thực hiện", "deployment được thực hiện"),
    (r"vùng dàn dựng", "staging slot"),
    (r"vùng dàn", "deployment slot"),
    (r"khe triển khai", "deployment slot"),
    (r"gói Dịch vụ ứng dụng", "App Service plan"),
    (r"Dịch vụ ứng dụng", "App Service"),
    (r"cài đặt Ứng dụng", "Application settings"),
    (r"miền tùy chỉnh", "custom domain"),
    (r"Tăng quy mô:", "Scale up:"),
    (r"Mở rộng quy mô:", "Scale out:"),
    (r"cấp Tiêu chuẩn", "tier Standard"),
    (r"cấp Cao cấp", "tier Premium"),
    (r"cấp Riêng biệt", "tier Isolated"),
    (r"✑\s*SKU:\s*Tiêu chuẩn", "✑ SKU: Standard"),
    (r"✑\s*SKU:\s*Cơ bản", "✑ SKU: Basic"),
    (r"✑\s*Loại:\s*Nội bộ", "✑ Type: Internal"),
    (r"Đ\.CÓ\.", "A. Có."),
    (r"Kiểm soát truy cập CẤP", "grant control"),
    (r"PHIÊN Kiểm soát truy cập", "session control"),
    (r"điều khiển phiên", "session control"),
    (r"Mẫu ARM", "ARM template"),
    (r"tài khoản Azure Storage", "Azure Storage account"),
    (r"đã được triển khai bằng", "đã được deploy bằng"),
    (r"sẽ Không thể", "sẽ không thể"),
    (r"cấp nguồn cho nó\s+bật lại", "bật lại VM"),
    (r"lưỡi\s+", "blade "),
    (r"\blưỡi\b", "blade"),
    (r"triển khai lại", "redeploy"),
    (r"sang nút mới", "sang node mới"),
    (r"\bnút mới\b", "node mới"),
    (r"gói đăng ký", "subscription"),
    (r"đăng ký Azure", "Azure subscription"),
    (r"\bĐăng ký\b", "subscription"),
    (r"Trình quản lý tài nguyên Azure", "Azure Resource Manager"),
    (r"\bmáy ảo\b", "VM"),
    (r"nhóm tài nguyên", "resource group"),
    (r"tài khoản lưu trữ", "storage account"),
    (r"vùng chứa blob", "blob container"),
    (r"chia sẻ tệp", "file share"),
    (r"Xác thực đa yếu tố", "Multi-Factor Authentication (MFA)"),
    (r"Quản trị viên toàn cầu", "Global Administrators"),
    (r"kiểm soát phiên", "session control"),
    (r"quyền kiểm soát cấp phép", "grant control"),
    (r"cổng Azure", "Azure portal"),
    (r"Bộ sẵn có", "Availability Set"),
    (r"Miền cập nhật", "Update Domains"),
    (r"\bKHÔNG\b", "Không"),
    (r"Tường lửa Azure", "Azure Firewall"),
    (r"bộ cân bằng tải", "load balancer"),
    (r"bản đánh giá quyền truy cập", "access review"),
    (r"đánh giá quyền truy cập", "access review"),
    (r"Quản lý danh tính đặc quyền", "Privileged Identity Management"),
    (r"Quản lý quyền lợi Azure AD", "Azure AD Entitlement Management"),
    (r"Quản lý quyền lợi", "Entitlement Management"),
    (r"[Ss]ổ tay chạy", "runbook"),
    (r"lệnh ghép ngắn\s+", ""),
    (r"Xác minh luồng IP", "IP flow verify"),
    (r"Nhật ký hoạt động", "Activity Log"),
    (r"kho khóa Azure", "Azure Key Vault"),
    (r"Ứng dụng(\d+)", r"Application\1"),
    (r"Ứng dụng Azure Logic", "Azure Logic Apps"),
    (r"danh tính được quản lý do người dùng chỉ định", "user-assigned managed identity"),
    (r"danh tính được quản lý do hệ thống chỉ định", "system-assigned managed identity"),
    (r"Bộ khả dụng của máy ảo Azure", "Azure VM Availability Set"),
    (r"Bảo vệ danh tính", "Identity Protection"),
    (r"Cấu hình tham gia Azure AD", "Azure AD Join"),
    (r"Tham gia Azure AD", "Azure AD Join"),
    (r"phân công vai trò", "role assignment"),
    (r"access review Azure AD", "Azure AD Access Reviews"),
    (r"runbook tự động hóa Azure", "Azure Automation runbook"),
    (r"định cấu hình", "cấu hình"),
    (r"Người dùng1", "User1"),
    (r"Người dùng(\d+)", r"User\1"),
    (r"^MỘT$", "A"),
    (r"^HAI$", "B"),
    (r"^BA$", "C"),
    (r"^BỐN$", "D"),
]

_COMPILED: list[tuple[re.Pattern[str], str]] = [
    (re.compile(p, re.IGNORECASE if p.islower() else 0), r)
    for p, r in _POST_REPLACEMENTS
]


def protect_terms(text: str) -> tuple[str, dict[str, str]]:
    """Replace protected terms (static + Title Case from text) before machine translation."""
    placeholders: dict[str, str] = {}
    out = text
    idx = 0
    for term in collect_terms_for_text(text):
        if not term or term.lower() not in out.lower():
            continue
        pattern = re.compile(re.escape(term), re.IGNORECASE)

        def _sub(m: re.Match[str], _idx: int = idx) -> str:
            key = f"«AZ{_idx}»"
            placeholders[key] = m.group(0)
            return key

        new_out, n = pattern.subn(lambda m, i=idx: _sub(m, i), out)
        if n:
            out = new_out
            idx += n
    return out, placeholders


def restore_terms(text: str, placeholders: dict[str, str]) -> str:
    for key, term in placeholders.items():
        text = text.replace(key, term)
    return text


def enforce_terms_from_en(en: str | None, vi: str) -> str:
    """Restore capitalized EN terms into vi when Google Translate dropped them."""
    if not en or not vi:
        return vi
    out = vi
    for en_pat, vi_wrong, replacement in _EN_VI_TERM_RESTORE:
        if re.search(en_pat, en, re.I):
            out = re.sub(vi_wrong, replacement, out, flags=re.I)

    for term in extract_capitalized_terms(en):
        if len(term) < 4:
            continue
        if term.lower() in out.lower():
            continue
        # Insert exact EN term if vi clearly mistranslated a long Title Case name (3+ words)
        words = [w for w in term.split() if w.lower() not in _IN_PHRASE_CONNECTORS]
        if len(words) < 3:
            continue
        # Cannot safely auto-insert without a wrong-pattern; skip generic 3-word terms
    return out


def improve_vi_text(text: str | None, en: str | None = None) -> str | None:
    if not text or not str(text).strip():
        return text
    out = str(text)
    for pattern, repl in _COMPILED:
        out = pattern.sub(repl, out)
    if en:
        out = enforce_terms_from_en(en, out)
    if "\n" in text:
        lines = [re.sub(r" +", " ", ln).strip() for ln in out.splitlines()]
        out = "\n".join(lines)
    else:
        out = re.sub(r" +", " ", out).strip()
    return out if out != text else text


def improve_bilingual(en: str | None, vi: str | None) -> str | None:
    if not vi:
        return vi
    return improve_vi_text(vi, en=en)


def walk_bilingual_fields(
    obj: object,
    fn: Callable[[str | None, str | None], str | None],
) -> bool:
    changed = False
    if isinstance(obj, dict):
        if "en" in obj and "vi" in obj and obj.get("vi"):
            new_vi = fn(obj.get("en"), obj["vi"])
            if new_vi != obj["vi"]:
                obj["vi"] = new_vi
                changed = True
        for v in obj.values():
            if walk_bilingual_fields(v, fn):
                changed = True
    elif isinstance(obj, list):
        for item in obj:
            if walk_bilingual_fields(item, fn):
                changed = True
    return changed
