import os
import asyncio
import shutil
import uuid
import threading
import tempfile
import subprocess
import shutil
import io
import json
import logging
from logging.handlers import WatchedFileHandler
import traceback
import hashlib
import hmac
import secrets
import subprocess
import base64
import re
import unicodedata
import socket
import ssl
import time
import urllib.error
import urllib.request
from typing import Any
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import quote
import urllib.parse
from datetime import datetime, date, timedelta
from pathlib import Path
from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import RedirectResponse, JSONResponse, StreamingResponse, Response, FileResponse, HTMLResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.sessions import SessionMiddleware
from starlette.requests import ClientDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import and_, case, func, or_, false, distinct, cast, String
from sqlalchemy.orm import Session, aliased, joinedload, selectinload, load_only, contains_eager
from sqlalchemy.exc import IntegrityError
from jinja2 import pass_context

from .database import Base, engine, get_db, SessionLocal
from .fields import ASSET_FIELDS, COMPUTER_FIELDS, DEFAULT_FIELDS, FIELD_DATA_TYPES, SYSTEM_READONLY_FIELDS
from .models import Asset, AssetHistory, Category, CategoryField, StatusOption, SyncRun, User, ChartDefinition, Language, Translation, FieldDefinition, AssetFieldValue, MeshFieldMapping, SoftwarePackage, SoftwarePackageParameter, SoftwareJob, InstalledSoftware, JobEvent, JobDefinition, JobDefinitionRevision, JobDefinitionParameter, JobDefinitionAssetParameter, AssetJobState
from .config import load_config, public_config, save_config
from .meshcentral import synchronize, fetch_device_summaries_for_linking
from .migrations import apply_lightweight_migrations
from .i18n import seed_i18n, translate, dictionary as translation_dictionary, languages as i18n_languages, clear_translation_cache
from .software_control import token_hash, detect_platform, execute_job, build_registry_user_script
from .job_state import backfill_asset_job_states, filter_state_key, sync_asset_job_state
from .presence import mesh_presence_loop
from .version import APP_VERSION
from .privacy import merge_privacy_settings, localized_privacy_settings, normalize_category, append_privacy_audit, privacy_audit_tail, PROTECTED_PRIVACY_CATEGORY_KEYS
from .privacy_retention import check_retention_category, delete_retention_category, append_deletion_audit, deletion_audit_tail, IMPLEMENTED_RETENTION_KEYS
from .backup import (BACKUP_DIR, BACKUP_INTERVAL_HOURS, BACKUP_RETENTION_DAYS, backup_path, create_backup, delete_backup, list_backups, restore_backup, store_uploaded_backup, automatic_backup_loop, system_storage_information)
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill, Alignment

BASE_DIR = Path(__file__).resolve().parent
UPLOAD_DIR = BASE_DIR / "static" / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
STANDARD_IMAGE_DIR = Path(os.getenv("STANDARD_IMAGE_DIR", str(BASE_DIR / "static" / "uploads" / "library")))
STANDARD_IMAGE_DIR.mkdir(parents=True, exist_ok=True)
STANDARD_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
LOG_DIR = Path(os.getenv("SYNC_LOG_DIR", "/app/data/logs/sync"))
LOG_DIR.mkdir(parents=True, exist_ok=True)
APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", "/app/data/logs"))
APP_LOG_DIR.mkdir(parents=True, exist_ok=True)
SOFTWARE_CALLBACK_DEBUG_LOG = APP_LOG_DIR / "software-callback-debug.log"
_SOFTWARE_LOG_LOCK = threading.Lock()


def _optional_form_int(value: Any, *, field_name: str, minimum: int | None = None, maximum: int | None = None) -> int | None:
    """Convert an optional HTML form value to int.

    Browsers submit an empty numeric input as an empty string. FastAPI/Pydantic
    cannot coerce that value directly to ``int | None``, so optional numeric
    form fields are normalized here before validation and persistence.
    """
    if value is None:
        return None
    text = str(value).strip()
    if not text:
        return None
    try:
        parsed = int(text)
    except (TypeError, ValueError) as exc:
        raise HTTPException(400, f"Invalid integer value for {field_name}.") from exc
    if minimum is not None:
        parsed = max(minimum, parsed)
    if maximum is not None:
        parsed = min(maximum, parsed)
    return parsed


def _record_job_event(db: Session, job: SoftwareJob, event_type: str, status: str | None = None, message: str | None = None, created_by: str | None = None) -> JobEvent:
    event = JobEvent(
        job_id=job.id,
        attempt=int(job.attempt_count or 0),
        event_type=event_type,
        status=status if status is not None else job.status,
        message=(message or "")[:4000] or None,
        created_by=created_by,
    )
    db.add(event)
    return event


_SOFTWARE_EXCLUSION_PLATFORMS = {"all", "windows", "linux", "macos", "unknown"}


def _normalize_software_exclusion_rules(value: Any) -> list[dict[str, str]]:
    """Return safe, deduplicated substring rules from configuration or forms."""
    if not isinstance(value, list):
        return []
    normalized: list[dict[str, str]] = []
    seen: set[tuple[str, str]] = set()
    for raw_rule in value[:500]:
        if not isinstance(raw_rule, dict):
            continue
        platform = str(raw_rule.get("platform") or "all").strip().lower()
        if platform not in _SOFTWARE_EXCLUSION_PLATFORMS:
            platform = "all"
        name_contains = re.sub(
            r"\s+",
            " ",
            str(raw_rule.get("name_contains") or raw_rule.get("name") or "").strip(),
        )[:300]
        if not name_contains:
            continue
        identity = (platform, name_contains.casefold())
        if identity in seen:
            continue
        seen.add(identity)
        normalized.append({"platform": platform, "name_contains": name_contains})
    return normalized


def _software_name_is_excluded(
    name: str,
    platform: str,
    rules: list[dict[str, str]],
) -> bool:
    """Match a software name against case-insensitive platform substring rules."""
    name_key = str(name or "").casefold()
    platform_key = str(platform or "unknown").strip().lower() or "unknown"
    for rule in rules:
        rule_platform = rule.get("platform", "all")
        if rule_platform not in {"all", platform_key}:
            continue
        needle = str(rule.get("name_contains") or "").casefold()
        if needle and needle in name_key:
            return True
    return False


def _software_settings() -> dict[str, Any]:
    config = load_config()
    software = dict(config.get("software", {}))
    # Compatibility with the callback field that existed in the general section.
    if not software.get("callback_base_url"):
        software["callback_base_url"] = str(config.get("general", {}).get("callback_base_url", "") or "")
    software["inventory_exclusion_rules"] = _normalize_software_exclusion_rules(
        software.get("inventory_exclusion_rules", [])
    )
    return software


def _software_debug_log(message: str, *, force: bool = False) -> None:
    settings = _software_settings()
    if not force and not bool(settings.get("debug_logging", True)):
        return
    line = f"{datetime.utcnow().isoformat(timespec='milliseconds')}Z | {message.rstrip()}\n"
    try:
        with _SOFTWARE_LOG_LOCK:
            SOFTWARE_CALLBACK_DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True)
            with SOFTWARE_CALLBACK_DEBUG_LOG.open("a", encoding="utf-8") as handle:
                handle.write(line)
            max_lines = max(100, min(int(settings.get("debug_log_max_lines", 1000) or 1000), 10000))
            lines = SOFTWARE_CALLBACK_DEBUG_LOG.read_text(encoding="utf-8", errors="replace").splitlines()
            if len(lines) > max_lines:
                SOFTWARE_CALLBACK_DEBUG_LOG.write_text("\n".join(lines[-max_lines:]) + "\n", encoding="utf-8")
    except Exception:
        logger.exception("Software callback debug log could not be written")


def _software_debug_tail(max_lines: int = 400) -> str:
    try:
        lines = SOFTWARE_CALLBACK_DEBUG_LOG.read_text(encoding="utf-8", errors="replace").splitlines()
        return "\n".join(lines[-max(20, min(max_lines, 2000)):])
    except OSError:
        return ""

logger = logging.getLogger("assetmanager")
logger.setLevel(logging.INFO)
if not logger.handlers:
    file_handler = WatchedFileHandler(APP_LOG_DIR / "errors.log", encoding="utf-8")
    file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
    logger.addHandler(file_handler)

ldap_logger = logging.getLogger("assetmanager.ldap")
ldap_logger.setLevel(logging.DEBUG)
ldap_logger.propagate = False
if not ldap_logger.handlers:
    ldap_file_handler = WatchedFileHandler(APP_LOG_DIR / "ldap.log", encoding="utf-8")
    ldap_file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
    ldap_logger.addHandler(ldap_file_handler)
    ldap_console_handler = logging.StreamHandler()
    ldap_console_handler.setFormatter(logging.Formatter("LDAP | %(levelname)s | %(message)s"))
    ldap_logger.addHandler(ldap_console_handler)

app = FastAPI(title=os.getenv("APP_TITLE", "AssetManager"))
# Dedicated callback-only ASGI application. It deliberately exposes only the
# software callback endpoint and its health endpoint so deployments may publish
# callbacks through a separate port/FQDN without exposing the AssetManager UI.
callback_app = FastAPI(
    title=f"{os.getenv('APP_TITLE', 'AssetManager')} Callback",
    docs_url=None,
    redoc_url=None,
    openapi_url=None,
)
_session_cfg = load_config().get("authentication", {})
_session_env = _session_cfg.get("session_secret_env", "SESSION_SECRET")
_session_secret = os.getenv(_session_env) or os.getenv("SESSION_SECRET") or "assetmanager-change-this-session-secret"
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
templates = Jinja2Templates(directory=BASE_DIR / "templates")
templates.env.globals["application_config"] = load_config

def application_version() -> str:
    """Read VERSION when available and otherwise use the embedded build version."""
    candidates = [
        BASE_DIR.parent / "VERSION",
        BASE_DIR / "VERSION",
    ]
    for candidate in candidates:
        try:
            value = candidate.read_text(encoding="utf-8").strip()
            if value:
                return value
        except OSError:
            continue
    return APP_VERSION


templates.env.globals["application_version"] = application_version

def application_info() -> dict[str, str]:
    """Read persistent application metadata.

    Primary file: /app/config/APPINFO.json (or APPINFO_PATH).
    A legacy APPINFO.json is migrated once when possible.
    """
    defaults = {
        "name": "AssetManager", "author": "", "organization": "",
        "contact": "", "website": "", "repository": "",
        "license": "", "copyright": "", "description": "",
    }
    persistent = Path(os.getenv("APPINFO_PATH", "/app/config/APPINFO.json"))
    legacy_candidates = [
        Path("/app/data/config/APPINFO.json"),  # path used by v0.3.14.2
        BASE_DIR.parent / "APPINFO.json",       # /app/APPINFO.json
        BASE_DIR / "APPINFO.json",
        Path("/APPINFO.json"),                  # legacy file in container root
    ]

    if not persistent.exists():
        for legacy in legacy_candidates:
            try:
                if legacy.exists():
                    persistent.parent.mkdir(parents=True, exist_ok=True)
                    shutil.copy2(legacy, persistent)
                    break
            except OSError:
                continue

    candidates = [persistent, *legacy_candidates]
    for candidate in candidates:
        try:
            payload = json.loads(candidate.read_text(encoding="utf-8"))
            if isinstance(payload, dict):
                return {**defaults, **{str(k): str(v or "") for k, v in payload.items()}}
        except (OSError, ValueError, TypeError):
            continue
    return defaults


templates.env.globals["application_info"] = application_info


def manufacturer_logo_path() -> str:
    """Return the first fixed manufacturer logo found in the static image folder.

    The file is intentionally not configurable through the GUI. Administrators
    can replace it only in the application files using the fixed base name
    ``manufacturer-logo`` and one of the supported image extensions.
    """
    image_dir = BASE_DIR / "static" / "img"
    for extension in (".svg", ".png", ".jpg", ".jpeg", ".webp"):
        candidate = image_dir / f"manufacturer-logo{extension}"
        try:
            if candidate.is_file():
                return f"/static/img/{candidate.name}"
        except OSError:
            continue
    return ""


templates.env.globals["manufacturer_logo_path"] = manufacturer_logo_path


@pass_context
def _template_translate(context, key: str, default: str | None = None, **values):
    """Translate template text without opening a database session for every cell.

    The previous implementation created and closed a SQLAlchemy session for every
    ``t(...)`` call. A job table with hundreds of rows therefore caused thousands
    of avoidable pool checkouts. Translation dictionaries are already cached by
    the i18n module; keep the two dictionaries on the request and format locally.
    """
    request = context.get("request")
    language_code = "en"
    if request:
        user = request.session.get("user") or {}
        language_code = user.get("language_code") or request.session.get("language_code") or load_config().get("general", {}).get("default_language", "en")
        language_code = str(language_code or "en").lower()

        cached = getattr(request.state, "template_translation_dictionaries", None)
        if not cached or cached[0] != language_code:
            db = SessionLocal()
            try:
                own = translation_dictionary(db, language_code)
                english = translation_dictionary(db, "en")
            finally:
                db.close()
            cached = (language_code, own, english)
            request.state.template_translation_dictionaries = cached

        _, own, english = cached
        text_value = own.get(key) or english.get(key) or default or key
        try:
            return text_value.format(**values)
        except (KeyError, ValueError):
            return text_value

    db = SessionLocal()
    try:
        return translate(db, language_code, key, default, **values)
    finally:
        db.close()

def _template_languages():
    db = SessionLocal()
    try:
        return [{"code": item.code, "name": item.name, "native_name": item.native_name} for item in i18n_languages(db)]
    finally:
        db.close()

templates.env.globals["t"] = _template_translate
templates.env.globals["available_languages"] = _template_languages

def _utc_iso(value):
    """Serialize project timestamps as explicit UTC for browser conversion."""
    if value is None:
        return ""
    if isinstance(value, datetime):
        if value.tzinfo is None:
            return value.isoformat(timespec="seconds") + "Z"
        return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
    return str(value)

templates.env.globals["utc_iso"] = _utc_iso


def _translate_request(request: Request, key: str, default: str | None = None, **values) -> str:
    user = _session_user(request) or {}
    language_code = user.get("language_code") or request.session.get("language_code") or load_config().get("general", {}).get("default_language", "en")
    db = SessionLocal()
    try:
        return translate(db, language_code, key, default, **values)
    finally:
        db.close()


@app.get("/custom.css", include_in_schema=False)
def custom_css():
    css = str(load_config().get("general", {}).get("custom_css", "") or "")
    return Response(content=css, media_type="text/css", headers={"Cache-Control": "no-store, max-age=0"})


SYNC_JOBS: dict[str, dict] = {}
SYNC_JOBS_LOCK = threading.Lock()
BACKUP_STOP_EVENT = threading.Event()
BACKUP_THREAD: threading.Thread | None = None
PRESENCE_STOP_EVENT = threading.Event()
PRESENCE_THREAD: threading.Thread | None = None
SOFTWARE_TIMEOUT_STOP_EVENT = threading.Event()
SOFTWARE_TIMEOUT_THREAD: threading.Thread | None = None
SOFTWARE_JOB_TERMINAL_STATES = {"success", "failed", "partial", "timeout", "cancelled"}
SOFTWARE_JOB_AUTOMATIC_RETRY_STATES = {"failed", "partial", "timeout", "sent"}


def _configured_callback_worker_count() -> int:
    try:
        value = int(_software_settings().get("callback_worker_count", 4) or 4)
    except (TypeError, ValueError):
        value = 4
    return max(1, min(value, 8))


SOFTWARE_CALLBACK_WORKER_COUNT = _configured_callback_worker_count()
SOFTWARE_CALLBACK_EXECUTOR = ThreadPoolExecutor(
    max_workers=SOFTWARE_CALLBACK_WORKER_COUNT,
    thread_name_prefix="assetmanager-callback",
)


def _expire_stale_software_jobs(db: Session, job_id: int | None = None) -> int:
    """Mark callback jobs as timed out independently of page views."""
    query = db.query(SoftwareJob).filter(
        SoftwareJob.callback_completed_at.is_(None),
        SoftwareJob.callback_expires_at < datetime.utcnow(),
        SoftwareJob.status.notin_(SOFTWARE_JOB_TERMINAL_STATES),
    )
    if job_id is not None:
        query = query.filter(SoftwareJob.id == job_id)
    rows = query.all()
    if not rows:
        return 0

    language_code = str(load_config().get("general", {}).get("default_language", "de") or "de")
    timeout_message = translate(db, language_code, "software.status.timeout", "Timed out")
    now = datetime.utcnow()
    for job in rows:
        job.status = "timeout"
        job.message = timeout_message
        job.finished_at = now
        _record_job_event(db, job, "timeout", "timeout", timeout_message)
        sync_asset_job_state(db, job, status_at=now)
    db.commit()
    return len(rows)


def _automatic_retry_runtime_settings() -> tuple[bool, int, int, int]:
    settings = _software_settings()
    enabled = bool(settings.get("automatic_retry_enabled", True))
    try:
        max_age_hours = int(settings.get("automatic_retry_max_age_hours", 12) or 12)
    except (TypeError, ValueError):
        max_age_hours = 12
    try:
        retry_interval_seconds = int(settings.get("automatic_retry_interval_seconds", 60) or 60)
    except (TypeError, ValueError):
        retry_interval_seconds = 60
    try:
        max_retries = int(settings.get("automatic_retry_max_retries", 3) or 3)
    except (TypeError, ValueError):
        max_retries = 3
    return (
        enabled,
        max(1, min(max_age_hours, 168)),
        max(10, min(retry_interval_seconds, 86400)),
        max(1, min(max_retries, 10)),
    )


def _job_callback_base_for_background(job: SoftwareJob) -> str:
    parameters = job.parameters if isinstance(job.parameters, dict) else {}
    stored = str(parameters.get("_callback_base_url") or "").strip().rstrip("/")
    if stored:
        return stored
    configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/")
    if configured:
        return configured
    # Compatibility for jobs created before the callback base was stored in
    # the job snapshot. The resolved script contains the complete callback URL.
    match = re.search(
        r"(https?://[^\s'\"<>]+?)/api/software-jobs/\d+/callback(?:\?[^\s'\"<>]*)?",
        str(job.resolved_script or ""),
        flags=re.IGNORECASE,
    )
    return match.group(1).rstrip("/") if match else ""


def _queue_automatic_job_retries(db: Session) -> list[tuple[int, str, str]]:
    enabled, max_age_hours, retry_interval_seconds, max_retries = _automatic_retry_runtime_settings()
    if not enabled:
        return []

    now = datetime.utcnow()
    oldest_created_at = now - timedelta(hours=max_age_hours)
    retry_ready_at = now - timedelta(seconds=retry_interval_seconds)
    candidates = (
        db.query(SoftwareJob)
        .options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package))
        .filter(
            SoftwareJob.status.in_(SOFTWARE_JOB_AUTOMATIC_RETRY_STATES),
            SoftwareJob.created_at >= oldest_created_at,
            func.coalesce(SoftwareJob.finished_at, SoftwareJob.sent_at, SoftwareJob.created_at) <= retry_ready_at,
        )
        .order_by(func.coalesce(SoftwareJob.finished_at, SoftwareJob.sent_at, SoftwareJob.created_at), SoftwareJob.id)
        .limit(100)
        .all()
    )
    if not candidates:
        return []

    candidate_ids = [job.id for job in candidates]
    retry_counts = dict(
        db.query(JobEvent.job_id, func.count(JobEvent.id))
        .filter(
            JobEvent.job_id.in_(candidate_ids),
            JobEvent.event_type == "automatic_retry_requested",
        )
        .group_by(JobEvent.job_id)
        .all()
    )

    queued: list[tuple[int, str, str]] = []
    for job in candidates:
        if int(retry_counts.get(job.id, 0) or 0) >= max_retries:
            continue
        if not job.asset or not job.asset.mesh_node_id:
            continue
        callback_base = _job_callback_base_for_background(job)
        if not callback_base:
            logger.warning(
                "Automatic retry skipped for software job %s because no callback base URL could be determined",
                job.id,
            )
            continue
        token = _prepare_existing_job_restart(db, None, job, automatic=True)
        queued.append((job.id, token, callback_base))

    if queued:
        db.commit()
    return queued


def _software_job_timeout_loop(stop_event: threading.Event) -> None:
    while not stop_event.is_set():
        queued: list[tuple[int, str, str]] = []
        db = SessionLocal()
        try:
            expired = _expire_stale_software_jobs(db)
            if expired:
                logger.info("%s software jobs automatically marked as timed out", expired)
            queued = _queue_automatic_job_retries(db)
        except Exception:
            db.rollback()
            logger.exception("Software job timeout or retry check failed")
        finally:
            db.close()

        for job_id, token, callback_base in queued:
            threading.Thread(
                target=execute_job,
                args=(job_id, token, callback_base),
                daemon=True,
                name=f"assetmanager-auto-retry-{job_id}",
            ).start()
        if queued:
            logger.info("%s software jobs automatically queued again", len(queued))

        _, _, retry_interval_seconds, _ = _automatic_retry_runtime_settings()
        stop_event.wait(min(30, max(5, retry_interval_seconds)))


@app.get('/job-definitions', response_class=HTMLResponse)
def job_definitions_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    search_text = str(request.query_params.get('q') or '').strip()
    active_only = str(request.query_params.get('active_only', '1')).lower() not in {'0', 'false', 'off', 'no'}

    query = db.query(JobDefinition)
    if active_only:
        query = query.filter(JobDefinition.enabled.is_(True))
    if search_text:
        pattern = f"%{search_text}%"
        query = query.filter(or_(JobDefinition.name.ilike(pattern), JobDefinition.description.ilike(pattern)))

    rows = query.order_by(JobDefinition.enabled.desc(), JobDefinition.name.asc()).all()

    referenced_definition_ids = set()
    for job in db.query(SoftwareJob).filter(SoftwareJob.job_type == 'job_definition').all():
        try:
            referenced_definition_ids.add(int((job.parameters or {}).get('definition_id')))
        except (TypeError, ValueError, AttributeError):
            continue

    definition_rows = [
        {
            'item': row,
            'can_delete': (not row.is_system and row.id not in referenced_definition_ids),
            'delete_reason': (
                'system' if row.is_system else
                'referenced' if row.id in referenced_definition_ids else
                None
            ),
        }
        for row in rows
    ]
    return templates.TemplateResponse(
        'job_definitions.html',
        {
            'request': request,
            'definition_rows': definition_rows,
            'active_only': active_only,
            'search_text': search_text,
        },
    )

@app.get('/job-definitions/new', response_class=HTMLResponse)
def job_definition_new(request: Request):
    _require_admin(request)
    return templates.TemplateResponse('job_definition_form.html', {'request':request,'definition':None,'test_result':None,'script_preview':None,'preview_values':None})

@app.get('/job-definitions/{definition_id}/edit', response_class=HTMLResponse)
def job_definition_edit(definition_id:int, request:Request, db:Session=Depends(get_db)):
    _require_admin(request); row=db.get(JobDefinition,definition_id)
    if not row: raise HTTPException(404)
    return templates.TemplateResponse('job_definition_form.html', {'request':request,'definition':row,'test_result':None,'script_preview':None,'preview_values':None})

def _parse_registry_entries_json(raw: str) -> list[dict]:
    try:
        items = json.loads(raw or "[]")
    except (TypeError, ValueError):
        raise HTTPException(400, "Invalid registry entry definition.")
    allowed_actions={"set","delete_value","delete_key"}
    allowed_hives={"HKLM","HKCU","HKCR","HKU","HKCC"}
    allowed_types={"REG_SZ","REG_EXPAND_SZ","REG_MULTI_SZ","REG_DWORD","REG_QWORD","REG_BINARY"}
    result=[]
    for item in items if isinstance(items,list) else []:
        if not isinstance(item,dict): continue
        raw_action=str(item.get("action") or "set")
        # Compatibility for the short-lived 0.5.4.1 permission-only action.
        legacy_permission_action = raw_action == "set_permissions"
        action = "set" if legacy_permission_action else raw_action
        hive=str(item.get("hive") or "HKLM").upper()
        path=str(item.get("path") or "").strip().strip("\\")
        name=str(item.get("name") or "").strip()
        value_type=str(item.get("value_type") or "REG_SZ").upper()
        if action not in allowed_actions or hive not in allowed_hives or not path: continue
        if value_type not in allowed_types: value_type="REG_SZ"
        result.append({
            "action":action,"hive":hive,"path":path,"name":name,
            "value_type":value_type,"value":str(item.get("value") or ""),
            "set_value":False if legacy_permission_action else bool(item.get("set_value", True)),
            "only_if_missing":bool(item.get("only_if_missing")),
            "permissions_enabled":True if legacy_permission_action else bool(item.get("permissions_enabled")),
            "principal":str(item.get("principal") or "S-1-5-32-545").strip(),
            "rights":str(item.get("rights") or "FullControl").strip(),
            "access_type":"Deny" if str(item.get("access_type") or "Allow").lower()=="deny" else "Allow",
            "inherit_subkeys":bool(item.get("inherit_subkeys", True)),
            "replace_existing":bool(item.get("replace_existing", True)),
        })
    return result


def _apply_job_definition_form(row, request, name, description, platform, interpreter, source_type, source_path, inline_script, arguments, timeout_seconds, success_codes, enabled, job_kind='script', registry_entries_json='[]', registry_backup=None):
    kind = job_kind if job_kind in {'script','registry'} else 'script'
    row.name=name.strip();row.description=description.strip() or None;row.job_kind=kind
    row.platform='windows' if kind=='registry' else platform
    row.interpreter='powershell' if kind=='registry' else interpreter
    row.source_type='inline' if kind=='registry' else source_type
    row.source_path=None if kind=='registry' else (source_path.strip() or None)
    row.inline_script=None if kind=='registry' else (inline_script if source_type=='inline' else None)
    row.arguments=None if kind=='registry' else (arguments.strip() or None)
    row.configuration={"registry_entries":_parse_registry_entries_json(registry_entries_json),"registry_backup":registry_backup=='on'} if kind=='registry' else {}
    row.timeout_seconds=max(1,min(int(timeout_seconds or 300),86400));row.success_codes=success_codes.strip() or '0';row.enabled=enabled=='on';row.updated_by=(request.session.get('user') or {}).get('username')

def _parse_job_parameters_json(raw: str) -> list[dict]:
    try:
        items = json.loads(raw or "[]")
    except (TypeError, ValueError):
        raise HTTPException(400, "Invalid job parameter definition.")
    result=[]; seen=set()
    for index,item in enumerate(items if isinstance(items,list) else []):
        if not isinstance(item,dict): continue
        key=re.sub(r"[^A-Za-z0-9_]", "_", str(item.get("parameter_key") or "").strip()).strip("_")
        if not key or key in seen: continue
        seen.add(key)
        result.append({"parameter_key":key,"label":str(item.get("label") or key).strip()[:140],"description":str(item.get("description") or "").strip() or None,"data_type":str(item.get("data_type") or "text") if str(item.get("data_type") or "text") in {"text","number","boolean","path"} else "text","default_value":str(item.get("default_value") or ""),"required":bool(item.get("required")),"sort_order":index})
    return result

def _sync_job_definition_parameters(db: Session, definition: JobDefinition, items: list[dict]) -> None:
    existing={x.parameter_key:x for x in definition.parameters}
    wanted={x["parameter_key"] for x in items}
    for row in list(definition.parameters):
        if row.parameter_key not in wanted:
            db.delete(row)
    for item in items:
        row=existing.get(item["parameter_key"])
        if row is None:
            row=JobDefinitionParameter(definition_id=definition.id, parameter_key=item["parameter_key"]); db.add(row)
        for key,value in item.items(): setattr(row,key,value)
    db.flush()

def _job_effective_parameters(db: Session, definition: JobDefinition, asset: Asset) -> dict[str,str]:
    parameter_ids=[x.id for x in definition.parameters]
    overrides={}
    if parameter_ids:
        overrides={x.definition_parameter_id:x.value_text for x in db.query(JobDefinitionAssetParameter).filter(JobDefinitionAssetParameter.asset_id==asset.id, JobDefinitionAssetParameter.definition_parameter_id.in_(parameter_ids)).all()}
    return {x.parameter_key: (overrides[x.id] if x.id in overrides else (x.default_value or "")) for x in definition.parameters}

@app.post('/job-definitions')
def job_definition_create(request:Request,name:str=Form(...),description:str=Form(''),job_kind:str=Form('script'),platform:str=Form('windows'),interpreter:str=Form('powershell'),source_type:str=Form('inline'),source_path:str=Form(''),inline_script:str=Form(''),arguments:str=Form(''),timeout_seconds:int=Form(300),success_codes:str=Form('0'),enabled:str|None=Form(None),parameters_json:str=Form('[]'),registry_entries_json:str=Form('[]'),registry_backup:str|None=Form(None),db:Session=Depends(get_db)):
    _require_admin(request); row=JobDefinition(created_by=(request.session.get('user') or {}).get('username'))
    _apply_job_definition_form(row,request,name,description,platform,interpreter,source_type,source_path,inline_script,arguments,timeout_seconds,success_codes,enabled,job_kind,registry_entries_json,registry_backup);db.add(row);db.flush();_sync_job_definition_parameters(db,row,_parse_job_parameters_json(parameters_json));db.commit();db.refresh(row)
    db.add(JobDefinitionRevision(definition_id=row.id,revision=row.revision,snapshot=_job_definition_snapshot(row),changed_by=row.updated_by));db.commit()
    return RedirectResponse('/job-definitions?toast_success='+quote(_translate_request(request,'jobdefs.saved','Job definition saved.')),303)

@app.post('/job-definitions/{definition_id}')
def job_definition_update(definition_id:int,request:Request,name:str=Form(...),description:str=Form(''),job_kind:str=Form('script'),platform:str=Form('windows'),interpreter:str=Form('powershell'),source_type:str=Form('inline'),source_path:str=Form(''),inline_script:str=Form(''),arguments:str=Form(''),timeout_seconds:int=Form(300),success_codes:str=Form('0'),enabled:str|None=Form(None),parameters_json:str=Form('[]'),registry_entries_json:str=Form('[]'),registry_backup:str|None=Form(None),db:Session=Depends(get_db)):
    _require_admin(request);row=db.get(JobDefinition,definition_id)
    if not row: raise HTTPException(404)
    row.revision=int(row.revision or 0)+1;_apply_job_definition_form(row,request,name,description,platform,interpreter,source_type,source_path,inline_script,arguments,timeout_seconds,success_codes,enabled,job_kind,registry_entries_json,registry_backup);_sync_job_definition_parameters(db,row,_parse_job_parameters_json(parameters_json));db.flush();db.add(JobDefinitionRevision(definition_id=row.id,revision=row.revision,snapshot=_job_definition_snapshot(row),changed_by=row.updated_by));db.commit()
    return RedirectResponse(f'/job-definitions/{row.id}/edit?toast_success='+quote(_translate_request(request,'jobdefs.saved','Job definition saved.')),303)

@app.post('/job-definitions/{definition_id}/delete')
def job_definition_delete(definition_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    row = db.get(JobDefinition, definition_id)
    if not row:
        raise HTTPException(404)
    if row.is_system:
        return RedirectResponse('/job-definitions?toast_error=' + quote(_translate_request(request, 'jobdefs.delete.system_forbidden', 'System job definitions cannot be deleted.')), 303)

    referenced_jobs = []
    for job in db.query(SoftwareJob).filter(SoftwareJob.job_type == 'job_definition').all():
        try:
            referenced_id = int((job.parameters or {}).get('definition_id'))
        except (TypeError, ValueError):
            continue
        if referenced_id == row.id:
            referenced_jobs.append(job.id)
            if len(referenced_jobs) >= 1:
                break
    if referenced_jobs:
        message = _translate_request(
            request,
            'jobdefs.delete.referenced',
            'This job definition is referenced by existing jobs and cannot be deleted. Deactivate it instead.',
        )
        return RedirectResponse('/job-definitions?toast_error=' + quote(message), 303)

    name = row.name
    db.delete(row)
    db.commit()
    message = _translate_request(request, 'jobdefs.deleted', 'Job definition "{name}" was deleted.').format(name=name)
    return RedirectResponse('/job-definitions?toast_success=' + quote(message), 303)


@app.post('/job-definitions/{definition_id}/test', response_class=HTMLResponse)
def job_definition_test(definition_id:int,request:Request,name:str=Form(...),description:str=Form(''),job_kind:str=Form('script'),platform:str=Form('windows'),interpreter:str=Form('powershell'),source_type:str=Form('inline'),source_path:str=Form(''),inline_script:str=Form(''),arguments:str=Form(''),timeout_seconds:int=Form(300),success_codes:str=Form('0'),enabled:str|None=Form(None),parameters_json:str=Form('[]'),registry_entries_json:str=Form('[]'),registry_backup:str|None=Form(None),db:Session=Depends(get_db)):
    _require_admin(request);row=db.get(JobDefinition,definition_id)
    if not row: raise HTTPException(404)
    _apply_job_definition_form(row,request,name,description,platform,interpreter,source_type,source_path,inline_script,arguments,timeout_seconds,success_codes,enabled,job_kind,registry_entries_json,registry_backup)
    _sync_job_definition_parameters(db,row,_parse_job_parameters_json(parameters_json))
    result=_validate_job_definition(row, request)
    preview=''
    if row.source_type=='inline':
        preview_interpreter='powershell' if row.job_kind=='registry' else row.interpreter
        preview_source=build_registry_user_script(row.configuration) if row.job_kind=='registry' else (row.inline_script or '')
        wrapper=_generate_job_script(preview_interpreter,'wrapper')
        start_marker,end_marker=JOB_SCRIPT_MARKERS[preview_interpreter]
        block=start_marker+'\n\n'+end_marker
        indent={'powershell':'  ','python':'    ','bash':'','cmd':''}[preview_interpreter]
        commands='\n'.join(indent+line if line.strip() else line for line in preview_source.splitlines())
        preview=wrapper.replace(block,start_marker+'\n'+commands+'\n'+end_marker,1)
        values=_job_preview_values()
        for parameter in row.parameters: values[parameter.parameter_key]=parameter.default_value or ''
        preview=_replace_job_placeholders(preview,values)
    db.rollback()
    return templates.TemplateResponse('job_definition_form.html', {'request':request,'definition':row,'test_result':result,'script_preview':preview,'preview_values':_job_preview_values()})

JOB_SCRIPT_TEMPLATE_DIR = BASE_DIR / "templates" / "jobs"
JOB_SCRIPT_TEMPLATE_FILES = {
    "powershell": ("powershell_wrapper.ps1", "powershell_test.txt"),
    "python": ("python_wrapper.py.txt", "python_test.txt"),
    "bash": ("bash_wrapper.sh", "bash_test.txt"),
    "cmd": ("cmd_wrapper.cmd", "cmd_test.txt"),
}
JOB_SCRIPT_MARKERS = {
    "powershell": ("  # --- Eigene Befehle hier einfügen ---", "  # --- Ende eigene Befehle ---"),
    "python": ("    # --- Eigene Befehle hier einfügen ---", "    # --- Ende eigene Befehle ---"),
    "bash": ("# --- Eigene Befehle hier einfügen ---", "# --- Ende eigene Befehle ---"),
    "cmd": ("rem --- Eigene Befehle hier einfügen ---", "rem --- Ende eigene Befehle ---"),
}

def _read_job_script_template(filename: str) -> str:
    path = (JOB_SCRIPT_TEMPLATE_DIR / filename).resolve()
    root = JOB_SCRIPT_TEMPLATE_DIR.resolve()
    if root not in path.parents or not path.is_file():
        raise HTTPException(500, "Job script template is missing.")
    return path.read_text(encoding="utf-8").replace("\r\n", "\n")

def _generate_job_script(interpreter: str, action: str) -> str:
    interpreter = str(interpreter or "").strip().lower()
    action = str(action or "").strip().lower()
    if interpreter not in JOB_SCRIPT_TEMPLATE_FILES:
        raise HTTPException(400, "Unsupported interpreter.")
    if action not in {"wrapper", "test"}:
        raise HTTPException(400, "Unsupported generator action.")
    wrapper_file, test_file = JOB_SCRIPT_TEMPLATE_FILES[interpreter]
    wrapper = _read_job_script_template(wrapper_file)
    if action == "wrapper":
        return wrapper
    commands = _read_job_script_template(test_file).rstrip("\n")
    start_marker, end_marker = JOB_SCRIPT_MARKERS[interpreter]
    marker_block = start_marker + "\n\n" + end_marker
    replacement = start_marker + "\n" + commands + "\n" + end_marker
    if marker_block not in wrapper:
        raise HTTPException(500, "Job script template markers are invalid.")
    return wrapper.replace(marker_block, replacement, 1)

@app.post('/api/job-definitions/script-generator')
async def job_definition_script_generator(request: Request):
    _require_admin(request)
    try:
        payload = await request.json()
    except Exception as exc:
        raise HTTPException(400, "Invalid JSON request.") from exc
    interpreter = str((payload or {}).get("interpreter") or "")
    action = str((payload or {}).get("action") or "")
    logger.info(
        "Job script generator requested | user=%s interpreter=%s action=%s",
        (_session_user(request) or {}).get("username"),
        interpreter,
        action,
    )
    script = _generate_job_script(interpreter, action)
    logger.info(
        "Job script generator completed | interpreter=%s action=%s bytes=%s",
        interpreter,
        action,
        len(script.encode("utf-8")),
    )
    return JSONResponse({"script": script, "interpreter": interpreter, "action": action})


@app.on_event("startup")
def start_backup_scheduler():
    global BACKUP_THREAD
    storage = system_storage_information()
    backup_status = storage["backups"]
    logger.info(
        "Backup directory: %s | exists=%s | writable=%s | mount=%s",
        backup_status["path"], backup_status["exists"], backup_status["writable"], backup_status["is_mount"],
    )
    if not backup_status["writable"]:
        logger.error("Backup directory is not writable: %s", backup_status.get("error") or backup_status["path"])
    if BACKUP_THREAD is None or not BACKUP_THREAD.is_alive():
        BACKUP_STOP_EVENT.clear()
        BACKUP_THREAD = threading.Thread(target=automatic_backup_loop, args=(BACKUP_STOP_EVENT,), daemon=True, name="assetmanager-backup")
        BACKUP_THREAD.start()


@app.on_event("shutdown")
def stop_backup_scheduler():
    BACKUP_STOP_EVENT.set()


def _hash_password(password: str) -> str:
    salt = secrets.token_bytes(16)
    iterations = 310_000
    digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
    return f"pbkdf2_sha256${iterations}${salt.hex()}${digest.hex()}"


def _verify_password(password: str, encoded: str | None) -> bool:
    if not encoded:
        return False
    try:
        algorithm, iterations_text, salt_hex, digest_hex = encoded.split("$", 3)
        if algorithm != "pbkdf2_sha256":
            return False
        candidate = hashlib.pbkdf2_hmac(
            "sha256", password.encode("utf-8"), bytes.fromhex(salt_hex), int(iterations_text)
        )
        return hmac.compare_digest(candidate.hex(), digest_hex)
    except (ValueError, TypeError):
        return False


def _ensure_local_emergency_admin(db: Session) -> None:
    """Create or protect the configured local break-glass administrator.

    The password from the environment is used only when the account is first
    created or when an existing local account has no password hash. Existing
    password hashes are never overwritten during container startup.
    """
    username = str(os.getenv("LOCAL_ADMIN_USERNAME", "") or "").strip()
    password = str(os.getenv("LOCAL_ADMIN_PASSWORD", "") or "")

    if not username and not password:
        return
    if not username or not password:
        logger.error(
            "Lokaler Notfall-Administrator wurde nicht eingerichtet: "
            "LOCAL_ADMIN_USERNAME und LOCAL_ADMIN_PASSWORD müssen gemeinsam gesetzt sein."
        )
        return
    if len(username) > 120:
        logger.error("LOCAL_ADMIN_USERNAME ist länger als 120 Zeichen; Konto wurde nicht eingerichtet.")
        return
    if len(password) < 12:
        logger.error("LOCAL_ADMIN_PASSWORD muss mindestens 12 Zeichen lang sein; Konto wurde nicht eingerichtet.")
        return

    matches = db.query(User).filter(func.lower(User.username) == username.casefold()).all()
    if len(matches) > 1:
        logger.error(
            "Lokaler Notfall-Administrator konnte nicht eingerichtet werden: "
            "Der Benutzername %r existiert mehrfach mit unterschiedlicher Schreibweise.",
            username,
        )
        return
    user = matches[0] if matches else None
    if user and user.auth_source != "local":
        logger.error(
            "Lokaler Notfall-Administrator konnte nicht eingerichtet werden: "
            "Benutzername %r gehört bereits zu einem %s-Konto.",
            username,
            user.auth_source,
        )
        return

    created = False
    password_initialized = False
    if not user:
        user = User(
            username=username,
            display_name="Lokaler Notfall-Administrator",
            password_hash=_hash_password(password),
            auth_source="local",
            is_active=True,
            is_admin=True,
            is_protected=True,
            allowed_mesh_groups=[],
            asset_access_scopes=["all"],
            language_code=load_config().get("general", {}).get("default_language", "de"),
        )
        db.add(user)
        created = True
        password_initialized = True
    else:
        user.is_active = True
        user.is_admin = True
        user.is_protected = True
        user.auth_source = "local"
        if not user.display_name:
            user.display_name = "Lokaler Notfall-Administrator"
        if not user.password_hash:
            user.password_hash = _hash_password(password)
            password_initialized = True

    db.commit()
    logger.info(
        "Lokaler Notfall-Administrator %r ist aktiv und geschützt "
        "(neu=%s, Passwort initialisiert=%s).",
        user.username,
        created,
        password_initialized,
    )


def _session_user(request: Request) -> dict | None:
    value = request.session.get("user")
    return value if isinstance(value, dict) else None


def _changed_by(request: Request) -> str | None:
    user = _session_user(request)
    return (user.get("display_name") or user.get("username")) if user else None


def _login_user(request: Request, user: User) -> None:
    request.session["user"] = {
        "id": user.id,
        "username": user.username,
        "display_name": user.display_name or user.username,
        "email": user.email or "",
        "auth_source": user.auth_source,
        "is_admin": bool(user.is_admin),
        "is_protected": bool(getattr(user, "is_protected", False)),
        "allowed_mesh_groups": user.allowed_mesh_groups or [],
        "asset_access_scopes": user.asset_access_scopes or [],
        "profile_department": user.profile_department or "",
        "profile_location": user.profile_location or "",
        "language_code": user.language_code or "en",
        "persist_browser_state": bool(getattr(user, "persist_browser_state", False)),
        "toast_position": getattr(user, "toast_position", "top-right") or "top-right",
        "toast_duration_seconds": int(getattr(user, "toast_duration_seconds", 6) or 6),
        "theme_mode": getattr(user, "theme_mode", "light") if getattr(user, "theme_mode", "light") in {"light", "dark"} else "light",
    }



def _is_admin(request: Request) -> bool:
    user = _session_user(request)
    return bool(user and user.get("is_admin"))


def _require_admin(request: Request) -> None:
    if not _is_admin(request):
        raise HTTPException(403, "Diese Funktion ist nur für Administratoren verfügbar.")


ASSET_ACCESS_SCOPES = {"self", "department", "location", "all"}


def _allowed_groups(request: Request) -> list[str] | None:
    user = _session_user(request)
    if not user or user.get("is_admin"):
        return None
    return [str(x).strip() for x in (user.get("allowed_mesh_groups") or []) if str(x).strip()]


def _asset_access_conditions(request: Request):
    """Build additive (OR-linked) asset visibility conditions for a non-admin user."""
    user = _session_user(request)
    if not user or user.get("is_admin"):
        return None

    scopes = {str(value).strip().lower() for value in (user.get("asset_access_scopes") or [])}
    scopes &= ASSET_ACCESS_SCOPES
    if "all" in scopes:
        return None

    conditions = []
    username = str(user.get("username") or "").strip()
    department = str(user.get("profile_department") or "").strip()
    location = str(user.get("profile_location") or "").strip()

    if "self" in scopes and username:
        conditions.append(func.lower(func.trim(Asset.assigned_to)) == username.casefold())
    if "department" in scopes and department:
        conditions.append(func.lower(func.trim(Asset.department)) == department.casefold())
    if "location" in scopes and location:
        conditions.append(func.lower(func.trim(Asset.location)) == location.casefold())

    groups = _allowed_groups(request) or []
    if groups:
        conditions.append(Asset.mesh_group.in_(groups))

    return conditions


def _apply_asset_access(query, request: Request):
    conditions = _asset_access_conditions(request)
    if conditions is None:
        return query
    return query.filter(or_(*conditions) if conditions else false())


def _get_visible_asset(db: Session, request: Request, asset_id: int) -> Asset:
    query = _apply_asset_access(db.query(Asset), request)
    asset = query.filter(Asset.id == asset_id).first()
    if not asset:
        raise HTTPException(404, "Asset nicht gefunden oder keine Berechtigung")
    return asset


def _action_status(db: Session, action: str) -> StatusOption | None:
    column = StatusOption.use_for_issue if action == "issue" else StatusOption.use_for_return
    return db.query(StatusOption).filter(StatusOption.active.is_(True), column.is_(True)).first()


def _ldap_debug_enabled(auth_config: dict) -> bool:
    return bool(auth_config.get("ldap", {}).get("debug_logging", False))


def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict | None:
    from ldap3 import ALL, Connection, Server, SUBTREE
    from ldap3.core.exceptions import LDAPException
    from ldap3.utils.conv import escape_filter_chars

    ldap = auth_config.get("ldap", {})
    debug_logging = _ldap_debug_enabled(auth_config)
    server_name = str(ldap.get("server", "")).strip()
    base_dn = str(ldap.get("user_base_dn", "")).strip()
    use_ssl = bool(ldap.get("use_ssl", False))
    start_tls = bool(ldap.get("start_tls", False))
    port = int(ldap.get("port") or (636 if use_ssl else 389))
    bind_dn = str(ldap.get("bind_dn", "")).strip()
    env_name = str(ldap.get("bind_password_env", "LDAP_BIND_PASSWORD")).strip()
    bind_password = os.getenv(env_name, "") if env_name else ""
    if not bind_password:
        bind_password = str(ldap.get("bind_password", ""))

    ldap_logger.info("LDAP login lookup started: username=%r", username)
    if debug_logging:
        ldap_logger.debug(
            "LDAP connection settings: server=%s port=%s ssl=%s start_tls=%s base_dn=%r bind_dn=%r password_env=%r configured=%s",
            server_name, port, use_ssl, start_tls, base_dn, bind_dn, env_name, bool(bind_password),
        )

    if not server_name:
        ldap_logger.error("LDAP authentication unavailable: server is not configured")
        return None
    if not base_dn:
        ldap_logger.error("LDAP authentication unavailable: user base DN is not configured")
        return None
    if not password:
        ldap_logger.warning("LDAP login rejected: username=%r reason=empty-password", username)
        return None
    if bind_dn and not bind_password:
        ldap_logger.error("LDAP authentication unavailable: service-account password is missing")
        if debug_logging:
            ldap_logger.debug("Expected LDAP bind-password environment variable: %r", env_name)
        return None

    server = Server(server_name, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=10)
    search_connection = None
    user_connection = None
    try:
        search_connection = Connection(
            server,
            user=bind_dn or None,
            password=bind_password or None,
            auto_bind=False,
            receive_timeout=15,
            raise_exceptions=False,
        )
        if start_tls and not use_ssl:
            search_connection.open()
            if debug_logging:
                ldap_logger.debug("LDAP TCP connection opened")
            if not search_connection.start_tls():
                ldap_logger.error("LDAP authentication unavailable: StartTLS failed")
                if debug_logging:
                    ldap_logger.debug("StartTLS details: result=%r last_error=%r", search_connection.result, search_connection.last_error)
                return None
            if debug_logging:
                ldap_logger.debug("LDAP StartTLS completed")

        if not search_connection.bind():
            ldap_logger.error("LDAP authentication unavailable: service-account bind failed")
            if debug_logging:
                ldap_logger.debug("Service-account bind details: result=%r last_error=%r", search_connection.result, search_connection.last_error)
            return None
        if debug_logging:
            ldap_logger.debug("LDAP service-account bind completed")

        raw_filter = str(ldap.get("user_filter", "(sAMAccountName={username})"))
        escaped_username = escape_filter_chars(username)
        user_filter = raw_filter.replace("{username}", escaped_username).replace("{{username}}", escaped_username)
        display_attr = str(ldap.get("display_name_attribute", "displayName"))
        email_attr = str(ldap.get("email_attribute", "mail"))
        attributes = list(dict.fromkeys([display_attr, email_attr, "distinguishedName"]))
        if debug_logging:
            ldap_logger.debug("LDAP user search: base_dn=%r filter=%r attributes=%r", base_dn, user_filter, attributes)

        search_ok = search_connection.search(
            base_dn,
            user_filter,
            search_scope=SUBTREE,
            attributes=attributes,
        )
        entry_count = len(search_connection.entries)
        if debug_logging:
            ldap_logger.debug(
                "LDAP user search details: success=%s entries=%s result=%r last_error=%r",
                search_ok, entry_count, search_connection.result, search_connection.last_error,
            )
        if entry_count != 1:
            ldap_logger.info("LDAP user lookup completed: username=%r found=no", username)
            if debug_logging and entry_count > 1:
                ldap_logger.debug("LDAP user lookup returned multiple entries: username=%r entries=%s", username, entry_count)
            return None

        ldap_logger.info("LDAP user lookup completed: username=%r found=yes", username)
        entry = search_connection.entries[0]
        user_dn = entry.entry_dn
        display_name = str(getattr(entry, display_attr, "") or username)
        email = str(getattr(entry, email_attr, "") or "")
        if debug_logging:
            ldap_logger.debug("LDAP user details: dn=%r display_name=%r email=%r", user_dn, display_name, email)

        user_connection = Connection(
            server,
            user=user_dn,
            password=password,
            auto_bind=False,
            receive_timeout=15,
            raise_exceptions=False,
        )
        if start_tls and not use_ssl:
            user_connection.open()
            if not user_connection.start_tls():
                ldap_logger.info("LDAP login completed: username=%r success=no", username)
                if debug_logging:
                    ldap_logger.debug("User StartTLS details: result=%r last_error=%r", user_connection.result, user_connection.last_error)
                return None
        if not user_connection.bind():
            ldap_logger.info("LDAP login completed: username=%r success=no", username)
            if debug_logging:
                ldap_logger.debug("User bind details: dn=%r result=%r last_error=%r", user_dn, user_connection.result, user_connection.last_error)
            return None

        ldap_logger.info("LDAP login completed: username=%r success=yes", username)
        return {"username": username, "display_name": display_name, "email": email}
    except LDAPException as exc:
        ldap_logger.error("LDAP login failed because of an LDAP error: username=%r", username)
        if debug_logging:
            ldap_logger.exception("LDAP exception during login for username=%r: %s", username, exc)
        return None
    except Exception as exc:
        ldap_logger.error("LDAP login failed because of an unexpected error: username=%r", username)
        if debug_logging:
            ldap_logger.exception("Unexpected LDAP login error for username=%r: %s", username, exc)
        return None
    finally:
        for connection in (user_connection, search_connection):
            if connection is not None:
                try:
                    connection.unbind()
                except Exception:
                    pass


@app.middleware("http")
async def authentication_gate(request: Request, call_next):
    mode = str(load_config().get("authentication", {}).get("mode", "none"))
    public_paths = {"/login", "/logout"}
    is_agent_callback = (request.url.path.startswith("/api/software-jobs/") and request.url.path.endswith("/callback")) or request.url.path == "/api/software-callback/health"
    if mode in {"local", "ldap"} and not request.session.get("user") and request.url.path not in public_paths and not request.url.path.startswith("/static/") and not is_agent_callback:
        if request.method == "GET":
            return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303)
        return JSONResponse({"detail": "Anmeldung erforderlich"}, status_code=401)
    return await call_next(request)


app.add_middleware(
    SessionMiddleware,
    secret_key=_session_secret,
    same_site="lax",
    https_only=False,
)


def _is_browser_request(request: Request) -> bool:
    accept = request.headers.get("accept", "")
    return "text/html" in accept or request.method != "GET"


def _toast_redirect(request: Request, message: str, level: str = "error") -> RedirectResponse:
    referer = request.headers.get("referer")
    target = referer if referer and referer.startswith(str(request.base_url).rstrip("/")) else "/"
    separator = "&" if "?" in target else "?"
    return RedirectResponse(f"{target}{separator}toast_{level}={quote(message)}", status_code=303)


@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
    message = str(exc.detail) if exc.detail else f"HTTP-Fehler {exc.status_code}"
    logger.error("HTTP %s | %s %s | %s", exc.status_code, request.method, request.url.path, message)
    if _is_browser_request(request):
        return _toast_redirect(request, message)
    return JSONResponse({"detail": message}, status_code=exc.status_code)


@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
    logger.error(
        "Unerwarteter Fehler | %s %s | %s\n%s",
        request.method,
        request.url.path,
        exc,
        traceback.format_exc(),
    )
    if _is_browser_request(request):
        return _toast_redirect(request, "Ein unerwarteter Fehler ist aufgetreten. Details wurden protokolliert.")
    return JSONResponse({"detail": "Interner Serverfehler"}, status_code=500)

def _job_log(job_id: str, level: str, message: str) -> None:
    entry = {
        "time": datetime.now().strftime("%H:%M:%S"),
        "level": level,
        "message": str(message),
    }
    with SYNC_JOBS_LOCK:
        job = SYNC_JOBS.get(job_id)
        if job is not None:
            job["logs"].append(entry)
            job["logs"] = job["logs"][-10000:]
    try:
        with (LOG_DIR / f"{job_id}.jsonl").open("a", encoding="utf-8") as handle:
            import json
            handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
    except OSError:
        pass

def _run_sync_job(job_id: str, dry_run: bool = False) -> None:
    db = SessionLocal()
    try:
        result = synchronize(db, lambda level, message: _job_log(job_id, level, message), dry_run=dry_run)
        with SYNC_JOBS_LOCK:
            job = SYNC_JOBS[job_id]
            job["status"] = result.run.status
            job["run_id"] = result.run.id if not dry_run else None
            job["finished"] = True
    except Exception as exc:
        _job_log(job_id, "error", f"Unerwarteter Fehler: {exc}")
        with SYNC_JOBS_LOCK:
            job = SYNC_JOBS[job_id]
            job["status"] = "error"
            job["finished"] = True
    finally:
        db.close()



def _seed_field_definitions(db: Session) -> None:
    existing = {item.field_name: item for item in db.query(FieldDefinition).all()}
    for order, (field_name, label) in enumerate(ASSET_FIELDS.items()):
        item = existing.get(field_name)
        if not item:
            item = FieldDefinition(
                field_name=field_name, label=label, description=None,
                data_type=("boolean" if field_name == "mesh_online" else "integer" if field_name in {"mesh_mtype", "mesh_icon"} else "long_text" if field_name in {"notes","storage_details","memory_details","gpu_name","antivirus"} else "date" if field_name in {"purchase_date","warranty_until","assigned_on"} else "status" if field_name == "status" else "ip" if field_name == "ip_address" else "short_text"),
                readonly=field_name in SYSTEM_READONLY_FIELDS, is_system=True,
                translation_key=f"field.{field_name}", sort_order=order,
                excel_import=field_name not in SYSTEM_READONLY_FIELDS,
                bulk_edit=field_name not in SYSTEM_READONLY_FIELDS,
                is_default=field_name in DEFAULT_FIELDS,
            )
            db.add(item); db.flush(); existing[field_name] = item
        elif field_name in SYSTEM_READONLY_FIELDS:
            item.readonly = True; item.excel_import = False; item.bulk_edit = False
    db.flush()
    for link in db.query(CategoryField).all():
        definition = existing.get(link.field_name)
        if definition and link.field_definition_id != definition.id:
            link.field_definition_id = definition.id
    # Every category gets every available field; new links are inactive.
    categories = db.query(Category).all()
    definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all()
    for category in categories:
        linked = {x.field_definition_id for x in category.visible_fields if x.field_definition_id}
        linked_names = {x.field_name for x in category.visible_fields}
        next_order = max((x.sort_order for x in category.visible_fields), default=-1) + 1
        for definition in definitions:
            if definition.id in linked or definition.field_name in linked_names:
                continue
            db.add(CategoryField(category_id=category.id, field_name=definition.field_name, label=definition.label, field_definition_id=definition.id, active=False, required=False, show_in_list=False, readonly=definition.readonly, sort_order=next_order))
            next_order += 1
    db.commit()



DEFAULT_MESH_FIELD_MAPPINGS = [
    # source_path, target, transform, multi mode, separator, enabled, priority, description
    ("node._id|node.nodeid|node.nodeId|node.id|_id|nodeid|nodeId|id", "mesh_node_id", "string", "first", "\n", True, 10, "Eindeutige MeshCentral Node-ID"),
    ("node.name|node.hostname|node.rname|name|hostname|rname", "name", "string", "first", "\n", True, 20, "Gerätename aus MeshCentral"),
    ("node.hostname|node.name|node.rname|hostname|name|rname", "hostname", "string", "first", "\n", True, 30, "Computername/Hostname"),
    ("node.osdesc|node.os|node.operatingSystem|osdesc|os|operatingSystem", "operating_system", "string", "first", "\n", True, 40, "Betriebssystembeschreibung"),
    ("sys.hardware.windows.osinfo.BuildNumber|sys.hardware.windows.osinfo.Version", "os_patch_level", "string", "first", "\n", True, 50, "Windows Build- oder Versionsnummer"),
    ("net.netif2", "ip_address", "active_ipv4_address", "first", "\n", True, 60, "IPv4-Adresse des ersten aktiven Nicht-Loopback-Adapters"),
    ("net.netif2", "mac_address", "active_ipv4_mac", "first", "\n", True, 70, "MAC-Adresse desselben aktiven IPv4-Adapters"),
    ("sys.hardware.identifiers.board_vendor|sys.hardware.identifiers.bios_vendor", "manufacturer", "string", "first", "\n", True, 80, "System- oder BIOS-Hersteller"),
    ("sys.hardware.identifiers.product_name", "model", "string", "first", "\n", True, 90, "Produkt-/Modellbezeichnung"),
    ("sys.hardware.identifiers.bios_serial|sys.hardware.windows.bios.SerialNumber|node.serial_number|node.serialNumber", "serial_number", "normalize_serial", "first", "\n", True, 100, "BIOS-/Geräteseriennummer"),
    ("sys.hardware.identifiers.cpu_name|sys.hardware.windows.cpu[].Name", "cpu", "string", "first", "\n", True, 110, "CPU-Bezeichnung"),
    ("sys.hardware.windows.memory", "memory_gb", "sum_memory_gb", "first", "\n", True, 120, "Summe der Speichermodule in GiB"),
    ("sys.hardware.windows.memory", "memory_details", "format_memory_modules", "first", "\n", True, 130, "Formatierte Liste der Speichermodule"),
    ("sys.hardware.windows.drives", "storage_gb", "sum_storage_gb", "first", "\n", True, 140, "Gesamtkapazität der Datenträger in GB"),
    ("sys.hardware.windows.drives", "storage_details", "format_drives", "first", "\n", True, 150, "Formatierte Liste der Datenträger"),
    ("sys.hardware.windows.gpu", "gpu_name", "gpu_names", "first", "\n", True, 160, "Namen der erkannten Grafikkarten"),
    ("sys.hardware.tpm.SpecVersion", "tpm_version", "string", "first", "\n", True, 170, "TPM-Spezifikationsversion"),
    ("node.av|av", "antivirus", "active_antivirus", "first", "\n", True, 180, "Aktive Antivirenprodukte"),
    ("node.groupname|groupname", "mesh_group", "string", "first", "\n", True, 190, "MeshCentral-Gerätegruppe"),
    ("node.mtype|mtype", "mesh_mtype", "integer", "first", "\n", True, 200, "MeshCentral Gruppentyp (mtype)"),
    ("node.icon|icon", "mesh_icon", "integer", "first", "\n", True, 205, "MeshCentral Geräte-Icon (platformType)"),
]


def _seed_mesh_field_mappings(db: Session) -> None:
    """Create missing defaults without overwriting persisted user settings."""
    rows = db.query(MeshFieldMapping).order_by(MeshFieldMapping.id).all()
    by_target: dict[str, list[MeshFieldMapping]] = {}
    for row in rows:
        by_target.setdefault(row.target_field_name, []).append(row)

    # Repair the historical system mapping that mixed group mtype and device icon.
    # Existing user-created mappings are left untouched.
    for row in by_target.get("mesh_mtype", []):
        if row.is_system and row.source_path == "node.mtype|node.icon|mtype|icon":
            row.source_path = "node.mtype|mtype"
            row.description = "MeshCentral Gruppentyp (mtype)"
    db.flush()

    # Import legacy config mappings only when that exact database mapping does
    # not already exist. Existing database rows remain the source of truth.
    config_mappings = load_config().get("meshcentral", {}).get("field_mappings", []) or []
    for index, item in enumerate(config_mappings):
        if not isinstance(item, dict):
            continue
        target = str(item.get("target_field") or item.get("target_field_name") or "").strip()
        source = str(item.get("source_path") or "").strip()
        if not target or not source:
            continue
        if any(row.source_path == source and row.target_field_name == target for row in rows):
            continue
        row = MeshFieldMapping(
            source_path=source,
            target_field_name=target,
            transform=str(item.get("transform") or "raw"),
            multi_value_mode=str(item.get("multi_value_mode") or "first"),
            separator=str(item.get("separator") or "\\n"),
            enabled=bool(item.get("enabled", True)),
            priority=int(item.get("priority") or (1000 + index)),
            description=item.get("description"),
            update_rule=str(item.get("update_rule") or "fill_empty"),
            is_system=False,
        )
        db.add(row)
        rows.append(row)
        by_target.setdefault(target, []).append(row)

    legacy_rules = load_config().get("meshcentral", {}).get("field_rules", {}) or {}

    for source, target, transform, multi, separator, enabled, priority, description in DEFAULT_MESH_FIELD_MAPPINGS:
        candidates = by_target.get(target, [])
        system_row = next((row for row in candidates if bool(getattr(row, "is_system", False))), None)

        if system_row is None:
            # A legacy database may contain exactly one mapping for the target.
            # Adopt it as the system row without changing its configured values.
            system_row = candidates[0] if candidates else None

        if system_row is None:
            system_row = MeshFieldMapping(
                source_path=source,
                target_field_name=target,
                transform=transform,
                multi_value_mode=multi,
                separator=separator,
                enabled=enabled,
                priority=priority,
                description=description,
                update_rule=str(legacy_rules.get(target) or "fill_empty"),
                is_system=True,
            )
            db.add(system_row)
            by_target.setdefault(target, []).append(system_row)
            continue

        # Only fill genuinely missing values. Never reset a user's persisted
        # "who wins" rule, enabled state, source path, transform or priority.
        system_row.is_system = True
        if not str(system_row.source_path or "").strip():
            system_row.source_path = source
        if not str(system_row.transform or "").strip():
            system_row.transform = transform
        if not str(system_row.multi_value_mode or "").strip():
            system_row.multi_value_mode = multi
        if system_row.separator is None:
            system_row.separator = separator
        if system_row.priority is None:
            system_row.priority = priority
        if not str(system_row.description or "").strip():
            system_row.description = description
        if not str(system_row.update_rule or "").strip():
            configured_rule = legacy_rules.get(target)
            system_row.update_rule = configured_rule if configured_rule in {"fill_empty", "meshcentral_wins", "local_wins"} else "fill_empty"

    db.commit()

def _definition_label(field: CategoryField) -> str:
    return field.definition.label if field.definition else field.label


def _definition_readonly(field: CategoryField) -> bool:
    return bool(field.definition.readonly) if field.definition else bool(getattr(field, "readonly", False))


def _custom_value_get(asset: Asset, definition: FieldDefinition, db: Session):
    row = db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == asset.id, AssetFieldValue.field_definition_id == definition.id).first()
    if not row: return None
    return row.value_json if definition.data_type == "json" else row.value_boolean if definition.data_type == "boolean" else row.value_datetime if definition.data_type == "datetime" else row.value_date if definition.data_type == "date" else row.value_number if definition.data_type in {"integer","decimal"} else row.value_text


def _custom_value_set(asset: Asset, definition: FieldDefinition, raw_value, db: Session) -> None:
    row = db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == asset.id, AssetFieldValue.field_definition_id == definition.id).first()
    if not row:
        row = AssetFieldValue(asset_id=asset.id, field_definition_id=definition.id); db.add(row)
    row.value_text=row.value_number=row.value_date=None; row.value_boolean=None; row.value_datetime=None; row.value_json=None
    value = None if raw_value in (None, "") else raw_value
    if definition.data_type == "boolean": row.value_boolean = str(value).lower() in {"1","true","on","yes"} if value is not None else None
    elif definition.data_type in {"integer","decimal"}: row.value_number = str(value) if value is not None else None
    elif definition.data_type == "date": row.value_date = str(value) if value is not None else None
    elif definition.data_type == "datetime": row.value_datetime = datetime.fromisoformat(str(value)) if value else None
    elif definition.data_type == "json":
        import json; row.value_json = json.loads(str(value)) if value else None
    else: row.value_text = str(value) if value is not None else None


templates.env.globals["field_label"] = _definition_label
templates.env.globals["field_readonly"] = _definition_readonly



SOFTWARE_INVENTORY_WINDOWS_SCRIPT = r'''
$paths = @(
  'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
  'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$software = Get-ItemProperty $paths -ErrorAction SilentlyContinue |
  Where-Object { $_.DisplayName } |
  ForEach-Object {
    [PSCustomObject]@{
      name = [string]$_.DisplayName
      version = [string]$_.DisplayVersion
      publisher = [string]$_.Publisher
      install_date = [string]$_.InstallDate
      architecture = $(if ($_.PSPath -like '*WOW6432Node*') { 'x86' } else { 'x64' })
    }
  } | Sort-Object name, version -Unique
Write-JobLog ("Software inventory completed; entries=" + $software.Count)
$JobResult = @{ software = $software }
'''

SOFTWARE_INVENTORY_LINUX_SCRIPT = r'''
import os
import subprocess

items = []
if os.path.exists("/usr/bin/dpkg-query"):
    output = subprocess.check_output(
        ["dpkg-query", "-W", "-f=${binary:Package}\t${Version}\t${Maintainer}\n"],
        text=True,
        errors="replace",
    )
    for line in output.splitlines():
        parts = line.split("\t")
        items.append({
            "name": parts[0],
            "version": parts[1] if len(parts) > 1 else "",
            "publisher": parts[2] if len(parts) > 2 else "",
            "architecture": "",
            "install_date": None,
        })
elif os.path.exists("/usr/bin/rpm"):
    output = subprocess.check_output(
        ["rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}-%{RELEASE}\t%{VENDOR}\t%{ARCH}\n"],
        text=True,
        errors="replace",
    )
    for line in output.splitlines():
        parts = line.split("\t")
        items.append({
            "name": parts[0],
            "version": parts[1] if len(parts) > 1 else "",
            "publisher": parts[2] if len(parts) > 2 else "",
            "architecture": parts[3] if len(parts) > 3 else "",
            "install_date": None,
        })
elif os.path.exists("/sbin/apk") or os.path.exists("/usr/sbin/apk"):
    output = subprocess.check_output(["apk", "info", "-v"], text=True, errors="replace")
    for line in output.splitlines():
        items.append({"name": line, "version": "", "publisher": "", "architecture": "", "install_date": None})
print("Software inventory completed; entries=" + str(len(items)))
job_result = {"software": items}
'''

def _seed_job_definitions(db: Session) -> None:
    defaults = [
        dict(name='Software-Inventur Windows', system_key='software_inventory_windows', platform='windows', interpreter='powershell', inline_script=SOFTWARE_INVENTORY_WINDOWS_SCRIPT),
        dict(name='Software-Inventur Linux', system_key='software_inventory_linux', platform='linux', interpreter='python', inline_script=SOFTWARE_INVENTORY_LINUX_SCRIPT),
    ]
    for data in defaults:
        row=db.query(JobDefinition).filter(JobDefinition.system_key==data['system_key']).first()
        if row is None:
            row=JobDefinition(description='Systemdefinition für die Softwareinventur. Enthält ausschließlich den fachlichen Skriptcode; der technische Rahmen wird zur Laufzeit erzeugt.', source_type='inline', enabled=True, is_system=True, timeout_seconds=600, success_codes='0', **data)
            db.add(row)
        else:
            # System definitions are maintained by the application. This one-time
            # normalization removes previously stored callback/wrapper code.
            row.name=data['name']; row.platform=data['platform']; row.interpreter=data['interpreter']
            row.source_type='inline'; row.inline_script=data['inline_script']; row.source_path=None
            row.description='Systemdefinition für die Softwareinventur. Enthält ausschließlich den fachlichen Skriptcode; der technische Rahmen wird zur Laufzeit erzeugt.'
            row.enabled=True; row.is_system=True; row.timeout_seconds=600; row.success_codes='0'
    db.commit()

def _job_definition_snapshot(row: JobDefinition) -> dict:
    return {**{k:getattr(row,k) for k in ('name','description','job_kind','configuration','system_key','platform','interpreter','source_type','source_path','inline_script','arguments','timeout_seconds','success_codes','enabled','is_system','revision')}, 'parameters':[{'parameter_key':x.parameter_key,'label':x.label,'description':x.description,'data_type':x.data_type,'default_value':x.default_value,'required':x.required,'sort_order':x.sort_order} for x in row.parameters]}

def _job_preview_values() -> dict[str, str]:
    return {
        "JobId": "12345",
        "CallbackUrl": "https://assetmanager.example.invalid/api/software-jobs/12345/callback?token=preview-token",
        "AssetName": "DEMO-PC-001",
        "Hostname": "DEMO-PC-001",
        "IPAddress": "192.0.2.25",
        "CurrentUser": "demo.user",
        "SerialNumber": "DEMO-SERIAL-001",
        "Manufacturer": "Example Manufacturer",
        "Model": "Example Model",
        "Department": "IT",
        "Location": "Head Office",
        "MeshNodeId": "node/demo-preview"
    }


def _replace_job_placeholders(script: str, values: dict[str, str] | None = None) -> str:
    rendered = str(script or "")
    for key, value in (values or _job_preview_values()).items():
        rendered = rendered.replace("{{" + key + "}}", str(value))
    return rendered


def _validate_job_definition(row: JobDefinition, request: Request | None = None) -> str:
    def tr(key: str, default: str, **values) -> str:
        return _translate_request(request, key, default, **values) if request else default.format(**values)
    errors=[]; notes=[]
    script=build_registry_user_script(row.configuration) if row.job_kind=='registry' else ((row.inline_script or '') if row.source_type=='inline' else '')
    if row.job_kind=='registry' and not (row.configuration or {}).get('registry_entries'): errors.append(tr('jobdefs.validation.registry_empty','At least one registry entry is required.'))
    if row.job_kind!='registry' and row.source_type=='inline' and not script.strip(): errors.append(tr('jobdefs.validation.inline_empty','The inline script is empty.'))
    if row.source_type=='mounted':
        path=Path(row.source_path or '')
        if not path.is_absolute(): errors.append(tr('jobdefs.validation.mounted_absolute','The mounted path must be absolute.'))
        elif not path.is_file(): errors.append(tr('jobdefs.validation.file_missing','File not found: {path}',path=path))
        else:
            script=path.read_text(encoding='utf-8',errors='replace')
            notes.append(tr('jobdefs.validation.file_readable','File readable: {path} ({count} characters)',path=path,count=len(script)))
    if row.source_type=='unc':
        path=str(row.source_path or '')
        if not path.startswith('\\\\'): errors.append(tr('jobdefs.validation.unc_prefix','A UNC path must begin with \\\\.'))
        else: notes.append(tr('jobdefs.validation.unc_ok','UNC syntax is valid. Reachability is checked on the target device.'))
    rendered = _replace_job_placeholders(script)
    if rendered and row.interpreter=='python':
        try: compile(rendered,'<job-definition>','exec');notes.append(tr('jobdefs.validation.python_ok','Python syntax is valid.'))
        except SyntaxError as exc: errors.append(tr('jobdefs.validation.python_error','Python syntax error on line {line}: {message}',line=exc.lineno,message=exc.msg))
    elif rendered and row.interpreter=='bash' and shutil.which('bash'):
        with tempfile.NamedTemporaryFile('w',suffix='.sh',delete=False,encoding='utf-8') as f:f.write(rendered);name=f.name
        try:
            r=subprocess.run(['bash','-n',name],capture_output=True,text=True,timeout=10)
            if r.returncode: errors.append(r.stderr.strip())
            else: notes.append(tr('jobdefs.validation.bash_ok','Bash syntax is valid.'))
        finally: Path(name).unlink(missing_ok=True)
    elif rendered and row.interpreter in {'powershell','cmd'}: notes.append(tr('jobdefs.validation.windows_later','Content is present. Full syntax validation is performed later on a Windows test device.'))
    if errors: return 'ERROR\n'+'\n'.join('- '+x for x in errors)+'\n\n'+'\n'.join(x for x in notes if x)
    return 'OK\n'+'\n'.join(x for x in notes if x)


def _seed_software_inventory_package(db: Session) -> SoftwarePackage:
    package = db.query(SoftwarePackage).filter(SoftwarePackage.name == "Software-Inventur").first()
    if package is None:
        package = SoftwarePackage(
            name="Software-Inventur",
            description="Liest installierte Software über den vorhandenen MeshCentral-Agenten aus.",
            package_type="inventory",
            enabled=True,
            is_system=True,
            callback_timeout_minutes=30,
        )
        db.add(package)
        db.commit()
        db.refresh(package)
    return package


def _seed_generic_job_package(db: Session) -> SoftwarePackage:
    package = db.query(SoftwarePackage).filter(SoftwarePackage.name == "Jobdefinition").first()
    if package is None:
        package = SoftwarePackage(
            name="Jobdefinition",
            description="Technisches Systempaket für ausführbare Jobdefinitionen.",
            package_type="job_definition",
            enabled=True,
            is_system=True,
            callback_timeout_minutes=30,
        )
        db.add(package)
        db.commit()
        db.refresh(package)
    return package


@app.on_event("startup")
def startup():
    Base.metadata.create_all(bind=engine)
    apply_lightweight_migrations()
    from .database import SessionLocal
    db = SessionLocal()
    try:
        _ensure_local_emergency_admin(db)
        seed_i18n(db)
        _seed_field_definitions(db)
        _seed_mesh_field_mappings(db)
        _seed_software_inventory_package(db)
        _seed_generic_job_package(db)
        _seed_job_definitions(db)
        backfill_asset_job_states(db)
        default_statuses = ["Aktiv", "Defekt", "im Lager", "Ausgegeben", "Verschrottet"]
        if db.query(StatusOption).count() == 0:
            for order, status_name in enumerate(default_statuses):
                db.add(StatusOption(name=status_name, sort_order=order, active=True))
            db.commit()
        if not db.query(StatusOption).filter(StatusOption.use_for_issue.is_(True)).first():
            item = db.query(StatusOption).filter(StatusOption.name == "Ausgegeben").first()
            if item: item.use_for_issue = True
        if not db.query(StatusOption).filter(StatusOption.use_for_return.is_(True)).first():
            item = db.query(StatusOption).filter(StatusOption.name == "im Lager").first()
            if item: item.use_for_return = True
        db.commit()
        if db.query(Category).count() == 0:
            computer = Category(name="Computer", description="Computer und Notebooks aus MeshCentral oder manueller Erfassung")
            db.add(computer)
            db.flush()
            for order, field_name in enumerate(COMPUTER_FIELDS):
                db.add(CategoryField(
                    category_id=computer.id,
                    field_name=field_name,
                    label=ASSET_FIELDS[field_name],
                    active=True,
                    required=field_name == "name",
                    show_in_list=field_name in {"asset_tag", "name", "hostname", "operating_system", "os_patch_level", "mac_address", "status"},
                    sort_order=order,
                ))
            db.commit()
        else:
            computer = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.name == "Computer").first()
            if computer:
                existing = {field.field_name for field in computer.visible_fields}
                next_order = max((field.sort_order for field in computer.visible_fields), default=-1) + 1
                important_list_fields = {"hostname", "manufacturer", "model", "serial_number", "operating_system", "ip_address", "mac_address", "storage_details", "gpu_name", "tpm_version", "antivirus", "mesh_group"}
                changed = False
                for field_name in COMPUTER_FIELDS:
                    if field_name not in existing:
                        db.add(CategoryField(
                            category_id=computer.id, field_name=field_name, label=ASSET_FIELDS[field_name],
                            active=True, required=False, show_in_list=field_name in important_list_fields,
                            sort_order=next_order,
                        ))
                        next_order += 1
                        changed = True
                if changed:
                    db.commit()
        _seed_field_definitions(db)

        # Earlier Excel imports could store date values as
        # "YYYY-MM-DD 00:00:00". Browsers render such values as empty in
        # <input type="date">. Existing system date fields are therefore
        # normalized to YYYY-MM-DD once.
        date_repairs = 0
        for asset in db.query(Asset).filter(
            (Asset.purchase_date.like("% %")) |
            (Asset.purchase_date.like("%T%")) |
            (Asset.warranty_until.like("% %")) |
            (Asset.warranty_until.like("%T%")) |
            (Asset.assigned_on.like("% %")) |
            (Asset.assigned_on.like("%T%"))
        ).all():
            for field_name in ("purchase_date", "warranty_until", "assigned_on"):
                old_value = getattr(asset, field_name, None)
                new_value = _normalize_date_value(old_value)
                if old_value and new_value and old_value != new_value:
                    setattr(asset, field_name, new_value)
                    date_repairs += 1
        if date_repairs:
            db.commit()
            logger.info("%s ältere Datumswerte auf YYYY-MM-DD normalisiert", date_repairs)
    finally:
        db.close()


@app.on_event("startup")
def start_software_timeout_service():
    global SOFTWARE_TIMEOUT_THREAD
    if SOFTWARE_TIMEOUT_THREAD is None or not SOFTWARE_TIMEOUT_THREAD.is_alive():
        SOFTWARE_TIMEOUT_STOP_EVENT.clear()
        SOFTWARE_TIMEOUT_THREAD = threading.Thread(
            target=_software_job_timeout_loop,
            args=(SOFTWARE_TIMEOUT_STOP_EVENT,),
            daemon=True,
            name="assetmanager-software-timeouts",
        )
        SOFTWARE_TIMEOUT_THREAD.start()


@app.on_event("shutdown")
def stop_software_timeout_service():
    SOFTWARE_TIMEOUT_STOP_EVENT.set()


@app.on_event("startup")
def start_mesh_presence_service():
    global PRESENCE_THREAD
    if PRESENCE_THREAD is None or not PRESENCE_THREAD.is_alive():
        PRESENCE_STOP_EVENT.clear()
        PRESENCE_THREAD = threading.Thread(
            target=mesh_presence_loop,
            args=(PRESENCE_STOP_EVENT,),
            daemon=True,
            name="assetmanager-mesh-presence",
        )
        PRESENCE_THREAD.start()


@app.on_event("shutdown")
def stop_mesh_presence_service():
    PRESENCE_STOP_EVENT.set()



def delete_asset_image(image_path: str | None) -> None:
    """Delete only individually uploaded asset images from the upload directory."""
    if not image_path or not image_path.startswith("/static/uploads/"):
        return
    filename = Path(image_path).name
    target = UPLOAD_DIR / filename
    try:
        target.unlink(missing_ok=True)
    except OSError:
        pass


def _validate_image_upload(upload: UploadFile | None) -> tuple[str, str] | None:
    if not upload or not upload.filename:
        return None
    suffix = Path(upload.filename).suffix.lower()
    if suffix not in STANDARD_IMAGE_EXTENSIONS:
        raise HTTPException(400, "Nur PNG, JPG, WEBP oder GIF sind erlaubt.")
    return Path(upload.filename).stem, suffix


def save_upload(upload: UploadFile | None) -> str | None:
    validated = _validate_image_upload(upload)
    if not validated:
        return None
    _, suffix = validated
    filename = f"{uuid.uuid4().hex}{suffix}"
    target = UPLOAD_DIR / filename
    with target.open("wb") as buffer:
        shutil.copyfileobj(upload.file, buffer)
    return f"/static/uploads/{filename}"


def _standard_image_url(filename: str) -> str:
    return "/static/uploads/library/" + urllib.parse.quote(filename)


def list_standard_images() -> list[dict[str, str]]:
    """Return reusable images stored in the persistent standard image library."""
    images: list[dict[str, str]] = []
    try:
        candidates = sorted(STANDARD_IMAGE_DIR.iterdir(), key=lambda item: item.name.casefold())
    except OSError:
        return images
    for path in candidates:
        if not path.is_file() or path.suffix.lower() not in STANDARD_IMAGE_EXTENSIONS:
            continue
        images.append({"name": path.name, "path": _standard_image_url(path.name)})
    return images


def resolve_standard_image(value: Any) -> str | None:
    """Accept only image paths currently present in the standard image library."""
    requested = str(value or "").strip()
    if not requested:
        return None
    allowed = {item["path"] for item in list_standard_images()}
    if requested not in allowed:
        raise HTTPException(400, "Ungültiges Standardbild ausgewählt.")
    return requested


def save_standard_image(upload: UploadFile | None) -> str | None:
    """Store an image once in the reusable standard image library.

    Existing files are never overwritten. If the original file name already
    exists, a numeric suffix is appended (for example switch-1.png).
    """
    validated = _validate_image_upload(upload)
    if not validated:
        return None
    stem, suffix = validated
    safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("-._") or "image"
    filename = f"{safe_stem}{suffix}"
    counter = 1
    while (STANDARD_IMAGE_DIR / filename).exists():
        filename = f"{safe_stem}-{counter}{suffix}"
        counter += 1
    target = STANDARD_IMAGE_DIR / filename
    with target.open("wb") as buffer:
        shutil.copyfileobj(upload.file, buffer)
    return _standard_image_url(filename)


def choose_form_image(form: Any, upload: UploadFile | None) -> str | None:
    """Resolve image input precedence: direct upload, new library upload, library selection."""
    direct = save_upload(upload)
    if direct:
        return direct
    library_upload = form.get("library_upload")
    if library_upload is not None and getattr(library_upload, "filename", None):
        return save_standard_image(library_upload)
    return resolve_standard_image(form.get("library_image"))


def _display_value(value) -> str:
    if value is None:
        return ""
    if isinstance(value, (dict, list)):
        import json
        return json.dumps(value, ensure_ascii=False, sort_keys=True)
    return str(value)


def _record_asset_history(db: Session, asset: Asset, changes: dict, source: str = "manual", changed_by: str | None = None) -> None:
    if not changes:
        return
    db.add(AssetHistory(asset_id=asset.id, source=source, changed_by=changed_by, changes=changes))


def _xlsx_response(filename: str, headers: list[str], rows: list[list]) -> Response:
    """Create a real XLSX file and return complete bytes instead of a stream.

    This prevents corrupted downloads with certain reverse proxies and browsers.
    """
    workbook = Workbook()
    sheet = workbook.active
    sheet.title = "Export"
    sheet.freeze_panes = "A2"
    sheet.auto_filter.ref = f"A1:{sheet.cell(row=1, column=max(1, len(headers))).column_letter}1"
    sheet.append(headers)
    header_fill = PatternFill("solid", fgColor="D9EAF7")
    for cell in sheet[1]:
        cell.font = Font(bold=True)
        cell.fill = header_fill
        cell.alignment = Alignment(vertical="top")
    for row in rows:
        sheet.append([_display_value(value) for value in row])
    for column in sheet.columns:
        max_length = min(max((len(str(cell.value or "")) for cell in column), default=10) + 2, 60)
        sheet.column_dimensions[column[0].column_letter].width = max_length
    output = io.BytesIO()
    workbook.save(output)
    payload = output.getvalue()
    ascii_name = "".join(ch if ch.isascii() and (ch.isalnum() or ch in "._-") else "_" for ch in filename)
    disposition = f"attachment; filename={ascii_name}; filename*=UTF-8''{quote(filename)}"
    return Response(
        content=payload,
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        headers={
            "Content-Disposition": disposition,
            "Content-Length": str(len(payload)),
            "X-Content-Type-Options": "nosniff",
            "Cache-Control": "no-store",
        },
    )


def _import_field_map(category: Category) -> dict[str, str]:
    result: dict[str, str] = {"asset-id": "asset_id", "asset_id": "asset_id", "id": "asset_id", "bezeichnung": "name", "name": "name", "kategorie": "category"}
    for field in category.visible_fields:
        if field.active and hasattr(Asset, field.field_name):
            result[field.label.strip().casefold()] = field.field_name
            result[field.field_name.strip().casefold()] = field.field_name
    return result


def _excel_cell_value(value) -> str | None:
    if value is None:
        return None
    if isinstance(value, datetime):
        return value.strftime("%Y-%m-%d %H:%M:%S")
    if isinstance(value, date):
        return value.isoformat()
    text = str(value).strip()
    return text or None


def _normalize_date_value(value: Any) -> str | None:
    """Normalize Excel and database values for HTML date fields to YYYY-MM-DD."""
    if value in (None, ""):
        return None
    if isinstance(value, datetime):
        return value.date().isoformat()
    if isinstance(value, date):
        return value.isoformat()
    text = str(value).strip()
    if not text:
        return None
    # ISO date or timestamp: the date portion is the first 10 characters.
    if re.match(r"^\d{4}-\d{2}-\d{2}(?:[ T].*)?$", text):
        return text[:10]
    # German date notation from manually maintained Excel files.
    match = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+.*)?$", text)
    if match:
        day, month, year = match.groups()
        return f"{year}-{int(month):02d}-{int(day):02d}"
    return text


def _normalize_datetime_value(value: Any) -> str | None:
    """Normalize values for <input type=datetime-local>."""
    if value in (None, ""):
        return None
    if isinstance(value, datetime):
        return value.strftime("%Y-%m-%dT%H:%M")
    if isinstance(value, date):
        return f"{value.isoformat()}T00:00"
    text = str(value).strip()
    if not text:
        return None
    text = text.replace(" ", "T")
    if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", text):
        return text[:16]
    if re.match(r"^\d{4}-\d{2}-\d{2}$", text):
        return f"{text}T00:00"
    return text


def _html_field_value(value: Any, data_type: str | None = None) -> str:
    if data_type == "date":
        return _normalize_date_value(value) or ""
    if data_type == "datetime":
        return _normalize_datetime_value(value) or ""
    return _display_value(value)


templates.env.globals["html_field_value"] = _html_field_value


def _request_language(request: Request | None) -> str:
    if request is None:
        return "en"
    user = request.session.get("user") or {}
    return str(user.get("language_code") or request.session.get("language_code") or load_config().get("general", {}).get("default_language", "en")).lower()


def _localized_text_key(value: Any, language_code: str = "en") -> str:
    text = str(value or "").strip().casefold()
    # German users expect umlauts near their expanded base letters instead of after Z.
    if language_code.startswith("de"):
        text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
    return "".join(ch for ch in unicodedata.normalize("NFKD", text) if not unicodedata.combining(ch))


def _sort_localized(items: list[Any], attribute: str, request: Request | None) -> list[Any]:
    language_code = _request_language(request)
    return sorted(items, key=lambda item: _localized_text_key(getattr(item, attribute, ""), language_code))


def _active_status_options(db: Session, request: Request | None = None) -> list[StatusOption]:
    items = db.query(StatusOption).filter(StatusOption.active.is_(True)).all()
    return _sort_localized(items, "name", request)


def _asset_action_changes(asset: Asset, action: str, status_name: str, assigned_to: str | None = None, assigned_on: str | None = None) -> dict[str, dict[str, str]]:
    changes: dict[str, dict[str, str]] = {}
    if _display_value(asset.status) != status_name:
        changes[ASSET_FIELDS["status"]] = {"old": _display_value(asset.status), "new": status_name}
        asset.status = status_name
    if action == "issue":
        new_name = (assigned_to or "").strip() or None
        new_date = (assigned_on or date.today().isoformat()).strip()
        if _display_value(asset.assigned_to) != _display_value(new_name):
            changes[ASSET_FIELDS["assigned_to"]] = {"old": _display_value(asset.assigned_to), "new": _display_value(new_name)}
            asset.assigned_to = new_name
        if _display_value(asset.assigned_on) != new_date:
            changes[ASSET_FIELDS["assigned_on"]] = {"old": _display_value(asset.assigned_on), "new": new_date}
            asset.assigned_on = new_date
    else:
        if asset.assigned_to:
            changes[ASSET_FIELDS["assigned_to"]] = {"old": _display_value(asset.assigned_to), "new": ""}
            asset.assigned_to = None
        if asset.assigned_on:
            changes[ASSET_FIELDS["assigned_on"]] = {"old": _display_value(asset.assigned_on), "new": ""}
            asset.assigned_on = None
    return changes


def _normalize_optional_text(value: str | None) -> str | None:
    text = (value or "").strip()
    return text or None


def _validate_asset_identifiers(db: Session, *, asset_tag: str | None, manufacturer: str | None, serial_number: str | None, exclude_id: int | None = None) -> None:
    asset_tag = _normalize_optional_text(asset_tag)
    manufacturer = _normalize_optional_text(manufacturer)
    serial_number = _normalize_optional_text(serial_number)
    if asset_tag:
        query = db.query(Asset).filter(Asset.asset_tag == asset_tag)
        if exclude_id is not None:
            query = query.filter(Asset.id != exclude_id)
        if query.first():
            raise HTTPException(400, f"Die Inventarnummer {asset_tag} ist bereits vergeben.")
    if manufacturer and serial_number:
        query = db.query(Asset).filter(Asset.manufacturer == manufacturer, Asset.serial_number == serial_number)
        if exclude_id is not None:
            query = query.filter(Asset.id != exclude_id)
        if query.first():
            raise HTTPException(400, f"Die Seriennummer {serial_number} ist beim Hersteller {manufacturer} bereits vorhanden.")


def _would_create_parent_cycle(db: Session, asset_id: int, parent_id: int | None) -> bool:
    seen = {asset_id}
    current = parent_id
    while current:
        if current in seen:
            return True
        seen.add(current)
        parent = db.query(Asset.parent_asset_id).filter(Asset.id == current).scalar()
        current = parent
    return False


def _list_fields_for_categories(categories: list[Category]) -> list[CategoryField]:
    """Build a unique, sorted union of the configured list columns."""
    result: list[CategoryField] = []
    seen: set[str] = set()
    for category in categories:
        for field in sorted(category.visible_fields, key=lambda item: item.sort_order):
            if not field.active or not field.show_in_list or field.field_name in seen:
                continue
            result.append(field)
            seen.add(field.field_name)
    return result



CHART_FIELDS = {
    "category": "Kategorie",
    "status": "Status",
    "operating_system": "Betriebssystem",
    "manufacturer": "Hersteller",
    "model": "Modell",
    "location": "Standort",
    "room_number": "Raumnummer",
    "department": "Abteilung",
    "mesh_group": "MeshCentral-Gruppe",
    "antivirus": "Antivirus",
    "tpm_version": "TPM-Version",
}
CHART_TYPES = {
    "donut": "Donutdiagramm",
    "pie": "Kuchendiagramm",
    "bar": "Balkendiagramm",
    "stacked_bar": "Gestapeltes Balkendiagramm",
}


def _parse_value_mappings(text_value: str) -> list[dict[str, str]]:
    """Read rules in the format search text => display name, one rule per line."""
    mappings: list[dict[str, str]] = []
    for line_number, raw_line in enumerate((text_value or "").splitlines(), start=1):
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        separator = "=>" if "=>" in line else "=" if "=" in line else None
        if not separator:
            raise HTTPException(400, f"Ungültige Wertzuordnung in Zeile {line_number}. Erwartet: Suchtext => Anzeigename")
        search, label = (part.strip() for part in line.split(separator, 1))
        if not search or not label:
            raise HTTPException(400, f"Unvollständige Wertzuordnung in Zeile {line_number}.")
        mappings.append({"search": search, "label": label})
    return mappings


def _value_mappings_text(chart: ChartDefinition | None) -> str:
    if not chart:
        return ""
    return "\n".join(
        f"{entry.get('search', '')} => {entry.get('label', '')}"
        for entry in (chart.value_mappings or [])
        if isinstance(entry, dict) and entry.get("search") and entry.get("label")
    )


def _automatic_chart_value(field: str, value: str) -> str:
    """Summarize technical raw values for charts without modifying asset data."""
    text = value.strip()
    lower = text.casefold()
    if field == "operating_system":
        if "windows server" in lower:
            match = re.search(r"windows server\s*(20\d{2})", text, re.IGNORECASE)
            return f"Windows Server {match.group(1)}" if match else "Windows Server"
        if "windows 11" in lower:
            return "Windows 11"
        if "windows 10" in lower:
            return "Windows 10"
        if "ubuntu" in lower:
            return "Ubuntu"
        if "debian" in lower:
            return "Debian"
        if "red hat" in lower or "rhel" in lower:
            return "Red Hat Enterprise Linux"
        if "centos" in lower:
            return "CentOS"
        if "fedora" in lower:
            return "Fedora"
        if "macos" in lower or "mac os" in lower:
            return "macOS"
        if "linux" in lower:
            return "Linux"
    if field == "manufacturer":
        if lower.startswith("hewlett") or lower in {"hp", "hp inc.", "hp inc"}:
            return "HP"
        if lower.startswith("dell"):
            return "Dell"
        if lower.startswith("lenovo"):
            return "Lenovo"
        if lower.startswith("microsoft"):
            return "Microsoft"
    return text


def _transform_chart_value(chart: ChartDefinition, field: str, value: str) -> str:
    # Custom rules take precedence; matching is case-insensitive.
    lower = value.casefold()
    for entry in chart.value_mappings or []:
        if not isinstance(entry, dict):
            continue
        search = str(entry.get("search") or "").strip()
        label = str(entry.get("label") or "").strip()
        if search and label and search.casefold() in lower:
            return label
    if (chart.value_mode or "raw") == "automatic":
        return _automatic_chart_value(field, value)
    return value


def _chart_value(asset: Asset, chart: ChartDefinition, field: str, show_empty: bool) -> str | None:
    if field == "category":
        value = asset.category.name if asset.category else None
    else:
        value = getattr(asset, field, None)
    if isinstance(value, str):
        value = value.strip()
    if value in (None, ""):
        return "Nicht angegeben" if show_empty else None
    return _transform_chart_value(chart, field, str(value))

def _chart_payload(chart: ChartDefinition, assets: list[Asset]) -> dict:
    grouped: dict[str, dict[str, int] | int] = {}
    stack_values: set[str] = set()
    excluded_statuses = {str(value).casefold() for value in (chart.excluded_statuses or []) if value not in (None, "")}
    for asset in assets:
        if excluded_statuses and str(asset.status or "").casefold() in excluded_statuses:
            continue
        group = _chart_value(asset, chart, chart.group_field, chart.show_empty)
        if group is None:
            continue
        if chart.chart_type == "stacked_bar" and chart.stack_field:
            stack = _chart_value(asset, chart, chart.stack_field, chart.show_empty)
            if stack is None:
                continue
            stack_values.add(stack)
            bucket = grouped.setdefault(group, {})
            bucket[stack] = bucket.get(stack, 0) + 1
        else:
            grouped[group] = int(grouped.get(group, 0)) + 1
    def total(item):
        value = item[1]
        return sum(value.values()) if isinstance(value, dict) else value
    items = list(grouped.items())
    if chart.sort_mode == "alpha":
        items.sort(key=lambda item: item[0].casefold())
    else:
        items.sort(key=total, reverse=True)
    limit = max(1, min(int(chart.max_items or 12), 50))
    if len(items) > limit and chart.chart_type != "stacked_bar":
        kept, rest = items[:limit-1], items[limit-1:]
        kept.append(("Sonstige", sum(int(v) for _, v in rest)))
        items = kept
    else:
        items = items[:limit]
    maximum = max((total(item) for item in items), default=1)
    total_count = sum(total(item) for item in items)
    donut_stops = []
    current = 0.0
    for index, (_, value) in enumerate(items):
        count = total(("", value))
        end = current + (count / total_count * 100 if total_count else 0)
        donut_stops.append(f"var(--chart-color-{index % 12}) {current:.2f}% {end:.2f}%")
        current = end
    return {
        "chart": chart,
        "items": items,
        "stack_values": sorted(stack_values, key=str.casefold),
        "maximum": maximum,
        "total": total_count,
        "donut_gradient": ", ".join(donut_stops) or "#e5e7eb 0 100%",
    }


def _seed_default_charts(db: Session) -> None:
    defaults = [
        {"name": "Betriebssysteme", "chart_type": "donut", "group_field": "operating_system", "value_mode": "automatic", "dashboard_visible": True, "max_items": 10},
        {"name": "Geräte nach Kategorie und Status", "chart_type": "stacked_bar", "group_field": "category", "stack_field": "status", "dashboard_visible": True, "max_items": 15},
        {"name": "Hersteller", "chart_type": "bar", "group_field": "manufacturer", "dashboard_visible": False, "max_items": 10},
        {"name": "Statusübersicht", "chart_type": "donut", "group_field": "status", "dashboard_visible": False, "max_items": 10},
    ]
    changed = False
    for values in defaults:
        chart = db.query(ChartDefinition).filter(ChartDefinition.name == values["name"]).first()
        if chart is None:
            chart = ChartDefinition(**values, is_system=True)
            db.add(chart)
            changed = True
        elif not chart.is_system:
            chart.is_system = True
            changed = True
    if changed:
        db.commit()


@app.get("/")
def dashboard(request: Request, db: Session = Depends(get_db)):
    categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
    category_asset_counts = dict(
        _apply_asset_access(
            db.query(Asset.category_id, func.count(Asset.id)),
            request,
        )
        .group_by(Asset.category_id)
        .all()
    )
    assets = (
        _apply_asset_access(db.query(Asset), request)
        .options(joinedload(Asset.category).joinedload(Category.visible_fields))
        .order_by(Asset.updated_at.desc())
        .limit(10)
        .all()
    )
    recent_categories = []
    seen_category_ids = set()
    for asset in assets:
        if asset.category_id not in seen_category_ids:
            recent_categories.append(asset.category)
            seen_category_ids.add(asset.category_id)
    fields = _list_fields_for_categories(recent_categories)
    _seed_default_charts(db)
    chart_assets = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category)).all()
    dashboard_charts = [_chart_payload(chart, chart_assets) for chart in db.query(ChartDefinition).filter(ChartDefinition.dashboard_visible.is_(True)).order_by(ChartDefinition.id).all()]
    return templates.TemplateResponse(
        "dashboard.html",
        {"request": request, "categories": categories, "category_asset_counts": category_asset_counts, "assets": assets, "fields": fields, "is_admin": _is_admin(request), "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None), "dashboard_charts": dashboard_charts},
    )


BULK_EDIT_EXCLUDED_FIELDS = {"asset_tag", "serial_number", "mesh_node_id"}

def _bulk_edit_fields_for_assets(assets: list[Asset]) -> list[dict]:
    """Return active fields shared by all selected asset categories."""
    if not assets:
        return []
    category_maps = []
    for asset in assets:
        fields = {
            field.field_name: _definition_label(field)
            for field in asset.category.visible_fields
            if field.active and hasattr(asset, field.field_name) and field.field_name not in BULK_EDIT_EXCLUDED_FIELDS and not _definition_readonly(field) and (not field.definition or field.definition.bulk_edit)
        }
        category_maps.append(fields)
    common = set(category_maps[0])
    for item in category_maps[1:]:
        common &= set(item)
    return [{"field_name": name, "label": category_maps[0][name]} for name in sorted(common, key=lambda n: category_maps[0][n].casefold())]


def _selected_asset_ids(values: list[str]) -> list[int]:
    result = []
    for value in values:
        try:
            number = int(value)
        except (TypeError, ValueError):
            continue
        if number > 0 and number not in result:
            result.append(number)
    return result



ASSET_JOB_FILTER_STATUSES = (
    "never_run",
    "created",
    "sending",
    "sent",
    "running",
    "success",
    "partial",
    "failed",
    "timeout",
    "cancelled",
)


def _resolve_asset_job_filter(db: Session, selection: str) -> dict[str, Any] | None:
    selection = str(selection or "").strip()
    if selection == "software_inventory":
        return {
            "selection": selection,
            "state_key": filter_state_key("software_inventory"),
            "job_type": "software_inventory",
            "definition": None,
            "definition_id": None,
            "current_revision": None,
            "label": "Softwareinventur",
            "label_key": "jobs.type.software_inventory",
        }
    if not selection.startswith("definition:"):
        return None
    try:
        definition_id = int(selection.split(":", 1)[1])
    except (TypeError, ValueError):
        return None
    definition = db.get(JobDefinition, definition_id)
    if not definition:
        return None
    return {
        "selection": selection,
        "state_key": filter_state_key("job_definition", definition.id),
        "job_type": "job_definition",
        "definition": definition,
        "definition_id": definition.id,
        "current_revision": int(definition.revision or 0),
        "label": definition.name,
        "label_key": None,
    }


def _normalize_job_filter_selections(value: Any) -> list[str]:
    """Normalize repeated query/form values while preserving their order."""
    if value is None:
        raw_values: list[Any] = []
    elif isinstance(value, (list, tuple, set)):
        raw_values = list(value)
    else:
        raw_values = [value]

    result: list[str] = []
    for raw in raw_values:
        for item in str(raw or "").split(","):
            selection = item.strip()
            if not selection or selection in result:
                continue
            if selection == "software_inventory" or re.fullmatch(r"definition:\d+", selection):
                result.append(selection)
    return result


def _legacy_job_filter_exclusions(condition: str) -> list[str]:
    """Preserve links generated by 0.5.5.17 while switching to exclusions."""
    include_by_condition = {
        "successful": {"success"},
        "not_successful": set(ASSET_JOB_FILTER_STATUSES) - {"success"},
        "never_run": {"never_run"},
        "last_failed": {"failed"},
        "last_partial": {"partial"},
        "timeout": {"timeout"},
        "callback_pending": {"sent"},
        "active": {"created", "sending", "running"},
    }
    included = include_by_condition.get(str(condition or "").strip())
    if included is None:
        return []
    return [status for status in ASSET_JOB_FILTER_STATUSES if status not in included]


def _normalize_job_filter_exclusions(value: Any) -> list[str]:
    """Normalize repeated form/query values or a comma-separated status list."""
    if value is None:
        raw_values: list[Any] = []
    elif isinstance(value, (list, tuple, set)):
        raw_values = list(value)
    else:
        raw_values = [value]

    requested: set[str] = set()
    for raw in raw_values:
        for item in str(raw or "").split(","):
            status = item.strip().lower()
            if status in ASSET_JOB_FILTER_STATUSES:
                requested.add(status)
    return [status for status in ASSET_JOB_FILTER_STATUSES if status in requested]


def _normalize_asset_job_filter(
    db: Session,
    *,
    selection: Any,
    excluded_statuses: Any = None,
    revision_mode: str = "all",
) -> dict[str, Any] | None:
    selections = _normalize_job_filter_selections(selection)
    specs: list[dict[str, Any]] = []
    state_keys: set[str] = set()
    for selected_value in selections:
        spec = _resolve_asset_job_filter(db, selected_value)
        if spec is None or spec["state_key"] in state_keys:
            continue
        specs.append(spec)
        state_keys.add(spec["state_key"])
    if not specs:
        return None

    revision_mode = "current" if str(revision_mode or "").strip() == "current" else "all"
    if not any(spec["job_type"] == "job_definition" for spec in specs):
        revision_mode = "all"
    return {
        "selections": [spec["selection"] for spec in specs],
        "specs": specs,
        "state_keys": [spec["state_key"] for spec in specs],
        "labels": [spec["label"] for spec in specs],
        "startable_specs": [spec for spec in specs if spec.get("definition") is None or spec["definition"].enabled],
        "excluded_statuses": _normalize_job_filter_exclusions(excluded_statuses),
        "revision_mode": revision_mode,
    }


def _asset_job_filter_url(job_filter: dict[str, Any], category_id: int | None = None, **toast: str) -> str:
    params: list[tuple[str, Any]] = []
    params.extend(("job_filter", selection) for selection in job_filter.get("selections") or [])
    params.append(("job_filter_revision", job_filter["revision_mode"]))
    params.extend(("job_filter_exclude", status) for status in job_filter.get("excluded_statuses") or [])
    if category_id:
        params.append(("category_id", category_id))
    params.extend((key, value) for key, value in toast.items() if value)
    return "/assets?" + urllib.parse.urlencode(params, doseq=True)


def _asset_job_filter_view(state: AssetJobState | None, job_filter: dict[str, Any] | None = None) -> dict[str, Any]:
    effective_never_run = state is None
    if state is not None and job_filter and job_filter.get("revision_mode") == "current":
        current_revision = job_filter.get("current_revision")
        effective_never_run = current_revision is not None and state.definition_revision != current_revision
    if state is None:
        return {
            "status": "never_run",
            "last_execution": None,
            "last_success": None,
            "definition_revision": None,
            "last_success_revision": None,
            "execution_count": 0,
        }
    return {
        "status": "never_run" if effective_never_run else (state.last_status or "created"),
        "last_execution": state.last_started_at or state.last_sent_at or state.last_status_at or state.last_created_at,
        "last_success": state.last_success_at,
        "definition_revision": state.definition_revision,
        "last_success_revision": state.last_success_revision,
        "execution_count": int(state.execution_count or 0),
    }


def _combined_asset_job_filter_view(
    states_by_key: dict[str, AssetJobState],
    job_filter: dict[str, Any],
) -> dict[str, Any]:
    """Combine alternative selected jobs using the most recently executed state.

    This is intentional for cases such as the generic software inventory job
    and platform-specific inventory definitions: an asset counts as successful
    when its latest execution among the selected alternatives was successful.
    """
    candidates: list[tuple[datetime, dict[str, Any]]] = []
    latest_success: datetime | None = None
    total_executions = 0

    for spec in job_filter.get("specs") or []:
        state = states_by_key.get(spec["state_key"])
        view = _asset_job_filter_view(
            state,
            {
                "revision_mode": job_filter.get("revision_mode"),
                "current_revision": spec.get("current_revision"),
            },
        )
        total_executions += int(view.get("execution_count") or 0)
        if state is not None:
            success_is_current = True
            if job_filter.get("revision_mode") == "current" and spec.get("current_revision") is not None:
                success_is_current = state.last_success_revision == spec.get("current_revision")
            if success_is_current and state.last_success_at and (latest_success is None or state.last_success_at > latest_success):
                latest_success = state.last_success_at
        if view["status"] == "never_run":
            continue
        timestamp = view.get("last_execution") or (state.updated_at if state is not None else None) or datetime.min
        view.update({
            "job_label": spec["label"],
            "job_selection": spec["selection"],
        })
        candidates.append((timestamp, view))

    if not candidates:
        return {
            "status": "never_run",
            "last_execution": None,
            "last_success": latest_success,
            "definition_revision": None,
            "last_success_revision": None,
            "execution_count": total_executions,
            "job_label": None,
            "job_selection": None,
        }

    result = max(candidates, key=lambda item: item[0])[1]
    result["last_success"] = latest_success
    result["execution_count"] = total_executions
    return result


def _filter_assets_by_job_states(
    db: Session,
    assets: list[Asset],
    job_filter: dict[str, Any],
) -> tuple[list[Asset], dict[int, dict[str, Any]]]:
    if not assets:
        return [], {}
    asset_ids = [asset.id for asset in assets]
    rows = (
        db.query(AssetJobState)
        .filter(
            AssetJobState.asset_id.in_(asset_ids),
            AssetJobState.state_key.in_(job_filter.get("state_keys") or [""]),
        )
        .all()
    )
    states_by_asset: dict[int, dict[str, AssetJobState]] = {}
    for state in rows:
        states_by_asset.setdefault(state.asset_id, {})[state.state_key] = state

    excluded = set(job_filter.get("excluded_statuses") or [])
    filtered_assets: list[Asset] = []
    views: dict[int, dict[str, Any]] = {}
    for asset in assets:
        view = _combined_asset_job_filter_view(states_by_asset.get(asset.id, {}), job_filter)
        if view["status"] in excluded:
            continue
        filtered_assets.append(asset)
        views[asset.id] = view
    return filtered_assets, views


@app.get("/assets")
def assets_list(request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
    categories = (
        db.query(Category)
        .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition))
        .order_by(Category.name)
        .all()
    )

    requested_exclusions = request.query_params.getlist("job_filter_exclude")
    if not requested_exclusions and request.query_params.get("job_filter_condition"):
        requested_exclusions = _legacy_job_filter_exclusions(request.query_params.get("job_filter_condition", ""))
    job_filter = _normalize_asset_job_filter(
        db,
        selection=request.query_params.getlist("job_filter"),
        excluded_statuses=requested_exclusions,
        revision_mode=request.query_params.get("job_filter_revision", "all"),
    )

    query = _apply_asset_access(db.query(Asset), request)
    selected = None
    if category_id:
        selected = next((category for category in categories if category.id == category_id), None)
        if not selected:
            raise HTTPException(404, "Kategorie nicht gefunden")
        query = query.filter(Asset.category_id == category_id)

    assets = (
        query
        .options(joinedload(Asset.category))
        .order_by(func.lower(Asset.name), Asset.id)
        .all()
    )
    job_state_by_asset: dict[int, dict[str, Any]] = {}
    if job_filter:
        assets, job_state_by_asset = _filter_assets_by_job_states(db, assets, job_filter)

    fields = (
        [field for field in sorted(selected.visible_fields, key=lambda item: item.sort_order) if field.active and field.show_in_list]
        if selected
        else _list_fields_for_categories(categories)
    )
    issue_status = _action_status(db, "issue")
    enabled_job_definitions = (
        db.query(JobDefinition)
        .filter(JobDefinition.enabled.is_(True))
        .order_by(func.lower(JobDefinition.name), JobDefinition.id)
        .all()
        if _is_admin(request)
        else []
    )
    filter_job_definitions = (
        db.query(JobDefinition)
        .filter(JobDefinition.enabled.is_(True))
        .order_by(func.lower(JobDefinition.name), JobDefinition.id)
        .all()
        if _is_admin(request)
        else []
    )
    return templates.TemplateResponse(
        "assets.html",
        {
            "request": request,
            "assets": assets,
            "categories": categories,
            "selected": selected,
            "fields": fields,
            "is_admin": _is_admin(request),
            "issue_status_name": issue_status.name if issue_status else None,
            "total_assets": len(assets),
            "job_definitions": enabled_job_definitions,
            "job_filter_definitions": filter_job_definitions,
            "job_filter": job_filter,
            "job_filter_statuses": ASSET_JOB_FILTER_STATUSES,
            "job_state_by_asset": job_state_by_asset,
            "asset_platforms": {asset.id: detect_platform(asset) for asset in assets},
        },
    )


SOFTWARE_COMPARE_MODES = {"exact", "ignore_version", "ignore_version_year"}

def _software_compare_key(name: str, platform: str, mode: str) -> tuple[str, str]:
    """Build a conservative comparison key without changing stored inventory data."""
    value = unicodedata.normalize("NFKC", str(name or "")).strip()
    value = re.sub(r"\s+", " ", value)
    if mode in {"ignore_version", "ignore_version_year"}:
        # Remove explicit version/build markers and common architecture suffixes.
        value = re.sub(r"(?i)\b(?:version|ver\.?|build|release)\s*v?\d+(?:[._-]\d+)*\b", " ", value)
        value = re.sub(r"(?i)(?:\s|[-_(])v\d+(?:[._-]\d+)+(?:[)\s]*)$", " ", value)
        value = re.sub(r"(?i)(?:\s|[-_(])\d+(?:[._-]\d+){1,}(?:[)\s]*)$", " ", value)
        # Architecture markers may be parenthesized or appended after language/year,
        # for example "2027 - English 64-Bit" versus "2016 - English".
        value = re.sub(r"(?i)\(?\b(?:x64|x86|amd64|arm64|64[- ]?bit|32[- ]?bit)\b\)?", " ", value)
    if mode == "ignore_version_year":
        # Years are removed only as standalone tokens. Product identities such as
        # Microsoft 365, Windows 11, .NET 8 and 7-Zip remain untouched.
        value = re.sub(r"(?<!\d)(?:19|20)\d{2}(?!\d)", " ", value)
    value = re.sub(r"[\s._-]+$", "", re.sub(r"\s+", " ", value)).strip()
    return (value.casefold(), str(platform or "unknown").casefold())

def _software_compare_rows(db: Session, asset_a: Asset, asset_b: Asset, mode: str) -> list[dict[str, Any]]:
    entries = (
        db.query(InstalledSoftware)
        .options(load_only(InstalledSoftware.asset_id, InstalledSoftware.name, InstalledSoftware.version, InstalledSoftware.publisher, InstalledSoftware.platform))
        .filter(InstalledSoftware.asset_id.in_([asset_a.id, asset_b.id]))
        .order_by(func.lower(InstalledSoftware.name), func.lower(InstalledSoftware.version), InstalledSoftware.id)
        .all()
    )
    grouped: dict[tuple[str, str], dict[int, list[InstalledSoftware]]] = {}
    for entry in entries:
        key = _software_compare_key(entry.name, entry.platform, mode)
        grouped.setdefault(key, {}).setdefault(entry.asset_id, []).append(entry)

    result: list[dict[str, Any]] = []
    for (normalized_name, platform), by_asset in grouped.items():
        left = by_asset.get(asset_a.id, [])
        right = by_asset.get(asset_b.id, [])
        all_entries = left + right
        display_name = min((entry.name for entry in all_entries), key=lambda value: (len(value), value.casefold()))
        def values(items: list[InstalledSoftware], attr: str) -> str:
            unique = sorted({str(getattr(item, attr) or "").strip() for item in items if str(getattr(item, attr) or "").strip()}, key=str.casefold)
            return ", ".join(unique)
        status = "both" if left and right else ("only_a" if left else "only_b")
        result.append({
            "comparison_name": display_name,
            "platform": platform or "unknown",
            "status": status,
            "a_names": values(left, "name"),
            "a_versions": values(left, "version"),
            "a_publishers": values(left, "publisher"),
            "b_names": values(right, "name"),
            "b_versions": values(right, "version"),
            "b_publishers": values(right, "publisher"),
        })
    # Keep the comparison table stable and easy to scan: sort primarily by the
    # normalized comparison name instead of grouping rows by comparison status.
    result.sort(key=lambda row: (_software_compare_key(row["comparison_name"], row["platform"], mode), row["comparison_name"].casefold()))
    return result

def _software_compare_assets(request: Request, db: Session, asset_a_id: int, asset_b_id: int) -> tuple[Asset, Asset]:
    if asset_a_id == asset_b_id:
        raise HTTPException(400, "Select two different assets.")
    rows = _apply_asset_access(db.query(Asset), request).filter(Asset.id.in_([asset_a_id, asset_b_id])).all()
    assets = {row.id: row for row in rows}
    if asset_a_id not in assets or asset_b_id not in assets:
        raise HTTPException(404, "Asset not found or not accessible.")
    return assets[asset_a_id], assets[asset_b_id]

@app.post("/assets/software-compare")
def assets_software_compare_start(request: Request, asset_ids: list[str] = Form([])):
    _require_admin(request)
    ids = []
    for value in asset_ids:
        try:
            parsed = int(value)
        except (TypeError, ValueError):
            continue
        if parsed not in ids:
            ids.append(parsed)
    if len(ids) != 2:
        return RedirectResponse("/assets?toast_error=" + quote("Select exactly two assets for the software comparison."), status_code=303)
    return RedirectResponse(f"/assets/software-compare?asset_a={ids[0]}&asset_b={ids[1]}&mode=ignore_version_year", status_code=303)

@app.get("/assets/software-compare")
def assets_software_compare(request: Request, asset_a: int, asset_b: int, mode: str = "ignore_version_year", db: Session = Depends(get_db)):
    _require_admin(request)
    mode = mode if mode in SOFTWARE_COMPARE_MODES else "ignore_version_year"
    left, right = _software_compare_assets(request, db, asset_a, asset_b)
    rows = _software_compare_rows(db, left, right, mode)
    counts = {key: sum(1 for row in rows if row["status"] == key) for key in ("only_a", "only_b", "both")}
    return templates.TemplateResponse("software_compare.html", {
        "request": request, "asset_a": left, "asset_b": right, "mode": mode, "rows": rows, "counts": counts, "is_admin": True
    })

@app.get("/assets/software-compare/export.xlsx")
def assets_software_compare_export(request: Request, asset_a: int, asset_b: int, mode: str = "ignore_version_year", db: Session = Depends(get_db)):
    _require_admin(request)
    mode = mode if mode in SOFTWARE_COMPARE_MODES else "ignore_version_year"
    left, right = _software_compare_assets(request, db, asset_a, asset_b)
    rows = _software_compare_rows(db, left, right, mode)
    headers = [
        "Comparison name", "Platform", "Status",
        f"{left.name} - software", f"{left.name} - versions", f"{left.name} - publishers",
        f"{right.name} - software", f"{right.name} - versions", f"{right.name} - publishers",
    ]
    status_labels = {"both": "Present on both assets", "only_a": f"Only on {left.name}", "only_b": f"Only on {right.name}"}
    export_rows = [[
        row["comparison_name"], row["platform"], status_labels[row["status"]],
        row["a_names"], row["a_versions"], row["a_publishers"],
        row["b_names"], row["b_versions"], row["b_publishers"],
    ] for row in rows]
    return _xlsx_response(f"software-comparison-{left.id}-{right.id}.xlsx", headers, export_rows)


@app.get("/charts")
def charts_page(request: Request, db: Session = Depends(get_db)):
    _seed_default_charts(db)
    assets = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category)).all()
    charts = [_chart_payload(chart, assets) for chart in db.query(ChartDefinition).order_by(ChartDefinition.id).all()]
    return templates.TemplateResponse("charts.html", {"request": request, "charts": charts, "is_admin": _is_admin(request)})


@app.get("/charts/manage")
def charts_manage(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    _seed_default_charts(db)
    charts = db.query(ChartDefinition).order_by(ChartDefinition.name).all()
    return templates.TemplateResponse("charts_manage.html", {"request": request, "charts": charts, "chart_fields": CHART_FIELDS, "chart_types": CHART_TYPES})


def _chart_form_context(request: Request, db: Session, chart: ChartDefinition | None = None) -> dict[str, Any]:
    language_code = _request_language(request)
    statuses = db.query(StatusOption).filter(StatusOption.active.is_(True)).all()
    statuses.sort(key=lambda item: _localized_text_key(item.name, language_code))
    return {
        "request": request,
        "chart": chart,
        "chart_fields": CHART_FIELDS,
        "chart_types": CHART_TYPES,
        "value_mappings_text": _value_mappings_text(chart),
        "status_options": statuses,
        "excluded_statuses": set(chart.excluded_statuses or []) if chart else set(),
    }


@app.get("/charts/new")
def chart_new(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    return templates.TemplateResponse("chart_form.html", _chart_form_context(request, db))


@app.post("/charts/new")
def chart_create(request: Request, name: str = Form(...), description: str = Form(""), chart_type: str = Form("bar"), group_field: str = Form("category"), stack_field: str = Form(""), dashboard_visible: str | None = Form(None), show_empty: str | None = Form(None), max_items: int = Form(12), sort_mode: str = Form("count_desc"), value_mode: str = Form("raw"), value_mappings_text: str = Form(""), excluded_statuses: list[str] = Form([]), db: Session = Depends(get_db)):
    _require_admin(request)
    if chart_type not in CHART_TYPES or group_field not in CHART_FIELDS:
        raise HTTPException(400, "Ungültige Diagrammkonfiguration")
    if chart_type == "stacked_bar" and stack_field not in CHART_FIELDS:
        raise HTTPException(400, "Für ein gestapeltes Diagramm ist eine gültige Unterteilung erforderlich.")
    if value_mode not in {"raw", "automatic", "custom"}:
        raise HTTPException(400, "Ungültige Wertedarstellung")
    mappings = _parse_value_mappings(value_mappings_text)
    valid_statuses = {row.name for row in db.query(StatusOption).all()}
    selected_statuses = [value for value in excluded_statuses if value in valid_statuses]
    chart = ChartDefinition(name=name.strip(), description=description.strip() or None, chart_type=chart_type, group_field=group_field, stack_field=stack_field if chart_type == "stacked_bar" else None, dashboard_visible=dashboard_visible == "on", show_empty=show_empty == "on", max_items=max(1, min(max_items, 50)), sort_mode=sort_mode, value_mode=value_mode, value_mappings=mappings, excluded_statuses=selected_statuses, created_by=_changed_by(request), is_system=False)
    db.add(chart)
    try:
        db.commit()
    except IntegrityError:
        db.rollback(); raise HTTPException(400, "Ein Diagramm mit diesem Namen existiert bereits.")
    return RedirectResponse("/charts/manage?toast_success=Diagramm angelegt", status_code=303)


@app.get("/charts/{chart_id}/edit")
def chart_edit(chart_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    chart = db.get(ChartDefinition, chart_id)
    if not chart: raise HTTPException(404, "Diagramm nicht gefunden")
    return templates.TemplateResponse("chart_form.html", _chart_form_context(request, db, chart))


@app.post("/charts/{chart_id}/edit")
def chart_update(chart_id: int, request: Request, name: str = Form(...), description: str = Form(""), chart_type: str = Form("bar"), group_field: str = Form("category"), stack_field: str = Form(""), dashboard_visible: str | None = Form(None), show_empty: str | None = Form(None), max_items: int = Form(12), sort_mode: str = Form("count_desc"), value_mode: str = Form("raw"), value_mappings_text: str = Form(""), excluded_statuses: list[str] = Form([]), db: Session = Depends(get_db)):
    _require_admin(request)
    chart = db.get(ChartDefinition, chart_id)
    if not chart: raise HTTPException(404, "Diagramm nicht gefunden")
    if chart_type not in CHART_TYPES or group_field not in CHART_FIELDS or (chart_type == "stacked_bar" and stack_field not in CHART_FIELDS):
        raise HTTPException(400, "Ungültige Diagrammkonfiguration")
    if value_mode not in {"raw", "automatic", "custom"}:
        raise HTTPException(400, "Ungültige Wertedarstellung")
    mappings = _parse_value_mappings(value_mappings_text)
    valid_statuses = {row.name for row in db.query(StatusOption).all()}
    chart.name=name.strip(); chart.description=description.strip() or None; chart.chart_type=chart_type; chart.group_field=group_field; chart.stack_field=stack_field if chart_type == "stacked_bar" else None; chart.dashboard_visible=dashboard_visible == "on"; chart.show_empty=show_empty == "on"; chart.max_items=max(1,min(max_items,50)); chart.sort_mode=sort_mode; chart.value_mode=value_mode; chart.value_mappings=mappings; chart.excluded_statuses=[value for value in excluded_statuses if value in valid_statuses]
    try:
        db.commit()
    except IntegrityError:
        db.rollback(); raise HTTPException(400, "Ein Diagramm mit diesem Namen existiert bereits.")
    return RedirectResponse("/charts/manage?toast_success=Diagramm gespeichert", status_code=303)


@app.post("/charts/{chart_id}/duplicate")
def chart_duplicate(chart_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    source = db.get(ChartDefinition, chart_id)
    if not source: raise HTTPException(404, "Diagramm nicht gefunden")
    base_name = f"{source.name} (Kopie)"
    name = base_name
    number = 2
    while db.query(ChartDefinition).filter(ChartDefinition.name == name).first():
        name = f"{source.name} (Kopie {number})"
        number += 1
    duplicate = ChartDefinition(name=name, description=source.description, chart_type=source.chart_type, group_field=source.group_field, stack_field=source.stack_field, dashboard_visible=False, show_empty=source.show_empty, max_items=source.max_items, sort_mode=source.sort_mode, value_mode=source.value_mode, value_mappings=list(source.value_mappings or []), excluded_statuses=list(source.excluded_statuses or []), created_by=_changed_by(request), is_system=False)
    db.add(duplicate); db.commit(); db.refresh(duplicate)
    return RedirectResponse(f"/charts/{duplicate.id}/edit?toast_success=Diagramm dupliziert", status_code=303)


@app.post("/charts/{chart_id}/delete")
def chart_delete(chart_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    chart=db.get(ChartDefinition, chart_id)
    if not chart: raise HTTPException(404, "Diagramm nicht gefunden")
    if chart.is_system:
        return RedirectResponse("/charts/manage?toast_warning=Standarddiagramme können bearbeitet, aber nicht gelöscht werden", status_code=303)
    db.delete(chart); db.commit()
    return RedirectResponse("/charts/manage?toast_success=Diagramm gelöscht", status_code=303)


@app.get("/assets/new")
def asset_new(request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
    _require_admin(request)
    categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
    selected = next((c for c in categories if c.id == category_id), categories[0] if categories else None)
    return templates.TemplateResponse("asset_form.html", {"request": request, "asset": None, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).order_by(Asset.name).all(), "custom_values": {}, "standard_images": list_standard_images()})


@app.get("/assets/{asset_id}/duplicate")
def asset_duplicate(asset_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    original = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first()
    if not original:
        raise HTTPException(404, "Asset nicht gefunden")
    categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
    class DuplicateValues:
        pass
    duplicate = DuplicateValues()
    for field_name in ASSET_FIELDS:
        setattr(duplicate, field_name, getattr(original, field_name, None))
    duplicate.asset_tag = None
    duplicate.mesh_node_id = None
    duplicate.name = f"{original.name} – Kopie"
    duplicate.visual_source = getattr(original, "visual_source", "auto") or "auto"
    return templates.TemplateResponse("asset_form.html", {
        "request": request, "asset": duplicate, "categories": categories, "selected": original.category,
        "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": True, "parent_candidates": db.query(Asset).filter(Asset.id != original.id).order_by(Asset.name).all(), "custom_values": {}, "standard_images": list_standard_images(),
    })


@app.post("/assets/new")
async def asset_create(request: Request, category_id: int = Form(...), image: UploadFile | None = File(None), db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first()
    if not category:
        raise HTTPException(404, "Kategorie nicht gefunden")
    data = {f.field_name: form.get(f.field_name) or None for f in category.visible_fields if f.active and hasattr(Asset, f.field_name) and f.field_name != "parent_asset_id" and not _definition_readonly(f)}
    for field in category.visible_fields:
        if field.field_name not in data or not field.definition:
            continue
        if field.definition.data_type == "date":
            data[field.field_name] = _normalize_date_value(data[field.field_name])
        elif field.definition.data_type == "datetime":
            data[field.field_name] = _normalize_datetime_value(data[field.field_name])
    data["room_number"] = _normalize_optional_text(form.get("room_number"))
    parent_raw = _normalize_optional_text(form.get("parent_asset_id"))
    data["parent_asset_id"] = int(parent_raw) if parent_raw else None
    if not data.get("name"):
        raise HTTPException(400, "Bezeichnung fehlt")
    _validate_asset_identifiers(db, asset_tag=data.get("asset_tag"), manufacturer=data.get("manufacturer"), serial_number=data.get("serial_number"))
    visual_source = str(form.get("visual_source") or "auto").strip().lower()
    if visual_source not in {"auto", "asset_image", "category_image", "mesh_icon"}:
        visual_source = "auto"
    asset = Asset(category_id=category_id, image_path=choose_form_image(form, image), visual_source=visual_source, **data)
    db.add(asset)
    try:
        db.flush()
        for field in category.visible_fields:
            if field.active and field.definition and not field.definition.is_system and not field.definition.readonly:
                _custom_value_set(asset, field.definition, form.get(field.field_name), db)
        created_changes = {_definition_label(field): {"old": "", "new": _display_value(getattr(asset, field.field_name, None))} for field in category.visible_fields if field.active and hasattr(asset, field.field_name) and getattr(asset, field.field_name, None) not in (None, "")}
        _record_asset_history(db, asset, created_changes, source="manual-create", changed_by=_changed_by(request))
        db.commit()
    except IntegrityError as exc:
        db.rollback()
        raise HTTPException(400, "Asset konnte nicht gespeichert werden. Inventarnummer, Seriennummer oder MeshCentral Node-ID ist möglicherweise bereits vergeben.") from exc
    return RedirectResponse(f"/assets/{asset.id}", status_code=303)


@app.get("/assets/import")
def assets_import_form(request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
    categories = _sort_localized(db.query(Category).options(joinedload(Category.visible_fields)).all(), "name", request)
    selected = next((category for category in categories if category.id == category_id), categories[0] if categories else None)
    return templates.TemplateResponse(
        "assets_import.html",
        {"request": request, "categories": categories, "selected": selected, "result": None, "import_only_active_fields": False},
    )


@app.get("/assets/import/template.xlsx")
def assets_import_template(request: Request, category_id: int, db: Session = Depends(get_db)):
    _require_admin(request)
    category = db.query(Category).filter(Category.id == category_id).first()
    if not category:
        raise HTTPException(404, "Kategorie nicht gefunden")

    definitions = (
        db.query(FieldDefinition)
        .filter(
            FieldDefinition.is_active.is_(True),
            FieldDefinition.excel_import.is_(True),
            FieldDefinition.readonly.is_(False),
        )
        .order_by(FieldDefinition.sort_order, FieldDefinition.label)
        .all()
    )
    import_definitions = [definition for definition in definitions if definition.field_name != "name"]
    headers = ["Asset-ID", "Kategorie", "Bezeichnung"] + [definition.label for definition in import_definitions]
    return _xlsx_response(f"asset-import-{category.name}.xlsx", headers, [])


@app.post("/assets/import")
async def assets_import(
    request: Request,
    category_id: int = Form(...),
    file: UploadFile = File(...),
    duplicate_action: str = Form("skip"),
    import_only_active_fields: str | None = Form(None),
    import_mode: str = Form("import"),
    db: Session = Depends(get_db),
):
    _require_admin(request)
    dry_run = import_mode == "dry_run"
    category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first()
    categories = _sort_localized(db.query(Category).options(joinedload(Category.visible_fields)).all(), "name", request)
    if not category:
        raise HTTPException(404, "Kategorie nicht gefunden")
    if not file.filename or Path(file.filename).suffix.lower() != ".xlsx":
        raise HTTPException(400, "Bitte eine XLSX-Datei auswählen.")
    try:
        raw = await file.read()
        workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
        sheet = workbook.active
    except Exception as exc:
        logger.exception("Excel-Import konnte nicht geöffnet werden")
        raise HTTPException(400, "Die Excel-Datei ist ungültig oder beschädigt.") from exc

    rows_iter = sheet.iter_rows(values_only=True)
    header_row = next(rows_iter, None)
    if not header_row:
        raise HTTPException(400, "Die Excel-Datei enthält keine Kopfzeile.")
    # Excel columns are recognized from the global field catalog. Category
    # visibility is only applied when the optional checkbox is enabled.
    import_definitions = (
        db.query(FieldDefinition)
        .filter(
            FieldDefinition.is_active.is_(True),
            FieldDefinition.excel_import.is_(True),
            FieldDefinition.readonly.is_(False),
        )
        .order_by(FieldDefinition.sort_order, FieldDefinition.label)
        .all()
    )
    definitions_by_name = {definition.field_name: definition for definition in import_definitions}
    field_map: dict[str, str] = {
        "asset-id": "asset_id",
        "asset_id": "asset_id",
        "id": "asset_id",
        "bezeichnung": "name",
        "name": "name",
        "kategorie": "category",
        "category": "category",
        "category_id": "category",
    }
    for definition in import_definitions:
        field_map[definition.label.strip().casefold()] = definition.field_name
        field_map[definition.field_name.strip().casefold()] = definition.field_name
    categories_by_id = {item.id: item for item in categories}
    categories_by_name = {item.name.strip().casefold(): item for item in categories}
    columns: list[str | None] = []
    unknown_headers: list[str] = []
    for header in header_row:
        label = _excel_cell_value(header) or ""
        field_name = field_map.get(label.casefold())
        columns.append(field_name)
        if label and not field_name:
            unknown_headers.append(label)
    if "name" not in columns:
        raise HTTPException(400, "Die Spalte ‚Bezeichnung‘ bzw. ‚Name‘ fehlt.")

    created = 0
    updated = 0
    skipped = 0
    errors: list[str] = []
    row_results: list[dict[str, Any]] = []
    changed_by = _changed_by(request)
    for excel_row_number, values in enumerate(rows_iter, start=2):
        raw_data: dict[str, str | None] = {}
        for index, value in enumerate(values):
            if index >= len(columns):
                break
            field_name = columns[index]
            if field_name:
                raw_data[field_name] = _excel_cell_value(value)
        if not any(value not in (None, "") for value in raw_data.values()):
            continue

        asset_id_value = raw_data.pop("asset_id", None)
        category_value = raw_data.pop("category", None)
        category_was_supplied = category_value not in (None, "")

        row_category = category
        if category_was_supplied:
            try:
                row_category = categories_by_id.get(int(category_value))
            except (TypeError, ValueError):
                row_category = categories_by_name.get(str(category_value).strip().casefold())
            if row_category is None:
                message = f"Kategorie ‚{category_value}‘ wurde nicht gefunden"
                errors.append(f"Zeile {excel_row_number}: {message}")
                row_results.append({"row": excel_row_number, "asset_id": "", "action": "Fehler", "reason": message})
                continue

        if import_only_active_fields == "on":
            allowed_fields = {
                field.field_name
                for field in row_category.visible_fields
                if field.active
                and field.definition
                and field.definition.is_active
                and field.definition.excel_import
                and not field.definition.readonly
            } | {"name"}
        else:
            allowed_fields = set(definitions_by_name) | {"name"}

        # Empty cells are intentionally not applied to updates.
        data = {
            name: value
            for name, value in raw_data.items()
            if name in allowed_fields and value not in (None, "")
        }
        for field_name, value in list(data.items()):
            definition = definitions_by_name.get(field_name)
            if definition and definition.data_type == "date":
                data[field_name] = _normalize_date_value(value)
            elif definition and definition.data_type == "datetime":
                data[field_name] = _normalize_datetime_value(value)

        system_data = {name: value for name, value in data.items() if hasattr(Asset, name)}
        custom_data = {
            name: value
            for name, value in data.items()
            if name in definitions_by_name and not definitions_by_name[name].is_system
        }

        if asset_id_value not in (None, ""):
            try:
                parsed_asset_id = int(str(asset_id_value).strip())
            except (TypeError, ValueError):
                message = f"Asset-ID ‚{asset_id_value}‘ ist ungültig"
                errors.append(f"Zeile {excel_row_number}: {message}")
                row_results.append({"row": excel_row_number, "asset_id": str(asset_id_value or ""), "action": "Fehler", "reason": message})
                continue

            existing = db.query(Asset).filter(Asset.id == parsed_asset_id).first()
            if existing is None:
                message = f"Asset-ID {parsed_asset_id} wurde nicht gefunden"
                errors.append(f"Zeile {excel_row_number}: {message}")
                row_results.append({"row": excel_row_number, "asset_id": parsed_asset_id, "action": "Fehler", "reason": message})
                continue

            changes: dict[str, dict[str, str]] = {}
            if category_was_supplied and existing.category_id != row_category.id:
                changes["Kategorie"] = {
                    "old": existing.category.name if existing.category else str(existing.category_id),
                    "new": row_category.name,
                }
                existing.category_id = row_category.id

            for field_name, value in system_data.items():
                old_value = getattr(existing, field_name, None)
                if _display_value(old_value) != _display_value(value):
                    definition = definitions_by_name.get(field_name)
                    label = definition.label if definition else ASSET_FIELDS.get(field_name, field_name)
                    changes[label] = {
                        "old": _display_value(old_value),
                        "new": _display_value(value),
                    }
                    setattr(existing, field_name, value)

            for field_name, value in custom_data.items():
                definition = definitions_by_name[field_name]
                old_value = _custom_value_get(existing, definition, db)
                if _display_value(old_value) != _display_value(value):
                    changes[definition.label] = {
                        "old": _display_value(old_value),
                        "new": _display_value(value),
                    }
                    _custom_value_set(existing, definition, value, db)

            if changes:
                _record_asset_history(
                    db,
                    existing,
                    changes,
                    source="excel-import-update",
                    changed_by=changed_by,
                )
                updated += 1
                changed_labels = ", ".join(changes.keys())
                row_results.append({
                    "row": excel_row_number,
                    "asset_id": existing.id,
                    "action": "Aktualisiert",
                    "reason": f"Geänderte Felder: {changed_labels}",
                })
            else:
                skipped += 1
                row_results.append({
                    "row": excel_row_number,
                    "asset_id": existing.id,
                    "action": "Übersprungen",
                    "reason": "Keine Änderungen; alle ausgefüllten Werte entsprechen bereits dem Datensatz.",
                })
            continue

        # Without an asset ID, always create a new record.
        if not system_data.get("name"):
            message = "Für ein neues Asset fehlt die Bezeichnung"
            errors.append(f"Zeile {excel_row_number}: {message}")
            row_results.append({"row": excel_row_number, "asset_id": "", "action": "Fehler", "reason": message})
            continue

        try:
            with db.begin_nested():
                asset = Asset(category_id=row_category.id, **system_data)
                db.add(asset)
                db.flush()
                for field_name, value in custom_data.items():
                    _custom_value_set(asset, definitions_by_name[field_name], value, db)
                created_changes = {
                    "Kategorie": {"old": "", "new": row_category.name},
                    **{
                        (definitions_by_name[name].label if name in definitions_by_name else ASSET_FIELDS.get(name, name)): {"old": "", "new": _display_value(value)}
                        for name, value in data.items()
                    },
                }
                _record_asset_history(
                    db,
                    asset,
                    created_changes,
                    source="excel-import-create",
                    changed_by=changed_by,
                )
            created += 1
            row_results.append({
                "row": excel_row_number,
                "asset_id": asset.id,
                "action": "Neu angelegt",
                "reason": f"Neues Asset in Kategorie {row_category.name}",
            })
        except IntegrityError:
            message = (
                "Das neue Asset verletzt eine Eindeutigkeitsregel "
                "(zum Beispiel Inventarnummer, Seriennummer oder MeshCentral Node-ID)"
            )
            errors.append(f"Zeile {excel_row_number}: {message}")
            row_results.append({"row": excel_row_number, "asset_id": "", "action": "Fehler", "reason": message})
            continue
    if dry_run:
        # Flushes and nested savepoints above provide the same validation as a
        # real import. The outer transaction is rolled back so no asset,
        # custom field value or history entry remains stored.
        db.rollback()
    else:
        try:
            db.commit()
        except IntegrityError as exc:
            db.rollback()
            raise HTTPException(400, "Der Import konnte wegen doppelter eindeutiger Werte nicht gespeichert werden.") from exc
    result = {
        "created": created,
        "updated": updated,
        "skipped": skipped,
        "errors": errors,
        "unknown_headers": unknown_headers,
        "row_results": row_results,
        "dry_run": dry_run,
    }
    return templates.TemplateResponse(
        "assets_import.html",
        {"request": request, "categories": categories, "selected": category, "result": result, "import_only_active_fields": import_only_active_fields == "on", "import_mode": import_mode},
    )



@app.get("/asset-tree")
def asset_tree(
    request: Request,
    root_id: str | None = None,
    name_filter: str = "",
    hide_without_parent: str | None = None,
    db: Session = Depends(get_db),
):
    """Display the filtered device hierarchy.

    Multiple categories are passed as repeated ``category_id`` query parameters.
    Filters apply to every device, not only to root nodes. If the immediate parent
    node is filtered out, the device is rendered as a temporary root so it remains
    visible in the hierarchy.
    """
    try:
        parsed_root_id = int(root_id) if root_id not in (None, "") else None
    except (TypeError, ValueError):
        parsed_root_id = None

    selected_category_ids: list[int] = []
    for value in request.query_params.getlist("category_id"):
        try:
            category_id = int(value)
        except (TypeError, ValueError):
            continue
        if category_id not in selected_category_ids:
            selected_category_ids.append(category_id)

    # First load all assets visible to the signed-in user. Filtering
    # is then performed in Python so matches at any hierarchy
    # depth are not separated from their parents by a SQL query
    # or accidentally restricted to root nodes only.
    all_assets = (
        _apply_asset_access(db.query(Asset), request)
        .options(joinedload(Asset.category))
        .order_by(Asset.name)
        .all()
    )
    all_by_id = {asset.id: asset for asset in all_assets}
    all_children: dict[int | None, list[Asset]] = {}
    for asset in all_assets:
        parent_key = asset.parent_asset_id if asset.parent_asset_id in all_by_id else None
        all_children.setdefault(parent_key, []).append(asset)

    # When a top-level node is selected, include only its subtree.
    scope_ids: set[int]
    selected_root = all_by_id.get(parsed_root_id) if parsed_root_id else None
    if selected_root:
        scope_ids = set()
        pending = [selected_root.id]
        while pending:
            current_id = pending.pop()
            if current_id in scope_ids:
                continue
            scope_ids.add(current_id)
            pending.extend(child.id for child in all_children.get(current_id, []))
    else:
        scope_ids = set(all_by_id)

    needle = name_filter.strip().casefold()
    selected_categories = set(selected_category_ids)
    visible_assets: list[Asset] = []
    for asset in all_assets:
        if asset.id not in scope_ids:
            continue
        category_matches = not selected_categories or asset.category_id in selected_categories
        name_matches = not needle or needle in (asset.name or "").casefold()
        if category_matches and name_matches:
            visible_assets.append(asset)

    # Keep the explicitly selected top-level node visible for orientation,
    # even when it does not itself match the name or category filter.
    if selected_root and selected_root not in visible_assets:
        visible_assets.insert(0, selected_root)

    visible_ids = {asset.id for asset in visible_assets}
    by_parent: dict[int | None, list[Asset]] = {}
    for asset in visible_assets:
        parent_key = asset.parent_asset_id if asset.parent_asset_id in visible_ids else None
        by_parent.setdefault(parent_key, []).append(asset)
    for children in by_parent.values():
        children.sort(key=lambda item: (item.name or "").casefold())

    hide_unassigned = str(hide_without_parent or "").lower() in {"1", "true", "on", "yes"}
    if selected_root:
        roots = [selected_root]
        # Also render matches whose intermediate nodes were filtered out as
        # separate roots so no match is lost.
        roots.extend(
            asset for asset in by_parent.get(None, [])
            if asset.id != selected_root.id
        )
    else:
        roots = list(by_parent.get(None, []))

    if hide_unassigned:
        # Hide only genuine standalone devices without a stored parent device.
        # Devices that appear as roots only because of filtering
        # remain visible. A selected top-level node also remains visible.
        roots = [
            asset for asset in roots
            if asset.id == parsed_root_id
            or asset.parent_asset_id is not None
            or bool(by_parent.get(asset.id))
        ]

    categories = db.query(Category).order_by(Category.name).all()
    actual_roots = [
        asset for asset in all_assets
        if asset.parent_asset_id is None or asset.parent_asset_id not in all_by_id
    ]
    actual_roots.sort(key=lambda item: (item.name or "").casefold())

    return templates.TemplateResponse(
        "asset_tree.html",
        {
            "request": request,
            "roots": roots,
            "by_parent": by_parent,
            "categories": categories,
            "all_roots": actual_roots,
            "selected_root_id": parsed_root_id,
            "selected_category_ids": selected_category_ids,
            "name_filter": name_filter,
            "hide_without_parent": hide_unassigned,
        },
    )



def _inventory_fallback(asset: Asset) -> tuple[dict, list | dict, dict]:
    raw = asset.mesh_source_data if isinstance(asset.mesh_source_data, dict) else {}
    hardware = asset.mesh_hardware_data if isinstance(getattr(asset, "mesh_hardware_data", None), dict) else {}
    software = getattr(asset, "mesh_software_data", None)
    # Rebuild from the raw snapshot whenever possible. This upgrades older snapshots
    # with language-neutral i18n keys immediately, without waiting for another sync.
    if raw:
        try:
            from app.meshcentral import _build_hardware_inventory
            hardware = _build_hardware_inventory(raw)
        except Exception:
            if not hardware:
                hardware = (((raw.get("sys") or {}).get("hardware")) if isinstance(raw.get("sys"), dict) else {}) or {}
    if not isinstance(software, (list, dict)) or not software:
        candidates = []
        sys_data = raw.get("sys") if isinstance(raw.get("sys"), dict) else {}
        windows = (((sys_data.get("hardware") or {}).get("windows")) if isinstance(sys_data.get("hardware"), dict) else {}) or {}
        for candidate in (sys_data.get("software"), windows.get("software") if isinstance(windows, dict) else None, raw.get("software"), raw.get("installedSoftware"), raw.get("installedApplications")):
            if isinstance(candidate, (list, dict)) and candidate:
                software = candidate
                break
        else:
            software = []
    return hardware, software, raw


def _software_table(software: list | dict) -> tuple[list[str], list[dict]]:
    if isinstance(software, dict):
        if all(isinstance(value, dict) for value in software.values()):
            rows = [{"Name": key, **value} for key, value in software.items()]
        else:
            rows = [{"Eigenschaft": key, "Wert": value} for key, value in software.items()]
    elif isinstance(software, list):
        rows = [item if isinstance(item, dict) else {"Wert": item} for item in software]
    else:
        rows = []
    preferred = ["Name", "DisplayName", "name", "Version", "DisplayVersion", "version", "Publisher", "publisher", "InstallDate", "installDate"]
    keys = []
    for key in preferred:
        if any(key in row for row in rows) and key not in keys:
            keys.append(key)
    for row in rows:
        for key in row.keys():
            if key not in keys:
                keys.append(key)
    return keys, rows


@app.get("/assets/{asset_id:int}")
def asset_detail(asset_id: int, request: Request, db: Session = Depends(get_db)):
    asset = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first()
    if not asset:
        raise HTTPException(404, "Asset nicht gefunden")
    active_fields = [f for f in asset.category.visible_fields if f.active]
    history_count = db.query(AssetHistory).filter(AssetHistory.asset_id == asset.id).count()
    hardware_inventory, software_inventory, raw_inventory = _inventory_fallback(asset)

    # The software tab on the asset detail page uses the dedicated callback
    # inventory. MeshCentral raw details remain available independently.
    stored_software = (
        db.query(InstalledSoftware)
        .filter(InstalledSoftware.asset_id == asset.id)
        .order_by(InstalledSoftware.name.asc(), InstalledSoftware.version.asc())
        .all()
    )
    latest_software_job = (
        db.query(SoftwareJob)
        .options(joinedload(SoftwareJob.package))
        .filter(SoftwareJob.asset_id == asset.id)
        .order_by(SoftwareJob.created_at.desc())
        .first()
    )

    return templates.TemplateResponse("asset_detail.html", {
        "request": request,
        "asset": asset,
        "active_fields": active_fields,
        "history_count": history_count,
        "is_admin": _is_admin(request),
        "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None),
        "today": date.today().isoformat(),
        "parent_asset": asset.parent_asset,
        "child_assets": sorted(asset.child_assets, key=lambda item: item.name.casefold()),
        "custom_values": {
            f.field_name: _custom_value_get(asset, f.definition, db)
            for f in active_fields
            if f.definition and not f.definition.is_system
        },
        "hardware_inventory": hardware_inventory,
        "software_inventory": software_inventory,
        "installed_software": stored_software,
        "latest_software_job": latest_software_job,
        "raw_inventory": raw_inventory,
        "asset_platform": detect_platform(asset),
        "job_definitions": (
            db.query(JobDefinition).options(selectinload(JobDefinition.parameters))
            .filter(JobDefinition.enabled.is_(True), JobDefinition.platform.in_([detect_platform(asset), "all"]))
            .order_by(JobDefinition.name).all()
            if _is_admin(request) and asset.mesh_node_id and detect_platform(asset) != "unknown" else []
        ),
        "job_parameter_overrides": ({x.definition_parameter_id:x.value_text for x in db.query(JobDefinitionAssetParameter).filter(JobDefinitionAssetParameter.asset_id==asset.id).all()} if _is_admin(request) else {}),
    })


def _reset_mesh_link_runtime_state(asset: Asset) -> None:
    """Clear only connection-derived runtime state after a manual relink.

    Inventory and normal asset fields intentionally remain untouched until the
    next regular MeshCentral synchronization applies the configured mappings.
    """
    asset.mesh_online = None
    asset.mesh_online_state = "unknown"
    asset.mesh_presence_updated_at = None
    asset.mesh_last_online_at = None
    asset.mesh_last_seen = None
    asset.mesh_presence_source = None
    asset.mesh_last_sync = None


def _mesh_link_asset(db: Session, asset_id: int) -> Asset:
    asset = db.query(Asset).filter(Asset.id == asset_id).first()
    if not asset:
        raise HTTPException(404, "Asset nicht gefunden")
    return asset


@app.get("/assets/{asset_id}/mesh-link")
def asset_mesh_link_page(asset_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    asset = _mesh_link_asset(db, asset_id)
    try:
        devices = fetch_device_summaries_for_linking()
    except RuntimeError as exc:
        return RedirectResponse(
            f"/assets/{asset.id}?toast_error=" + quote(
                _translate_request(
                    request,
                    "mesh.link.fetch_failed",
                    "The MeshCentral device list could not be loaded: {error}",
                    error=str(exc),
                )
            ),
            status_code=303,
        )

    node_ids = [item["node_id"] for item in devices if item.get("node_id")]
    assigned_rows = (
        db.query(Asset.id, Asset.name, Asset.mesh_node_id)
        .filter(Asset.mesh_node_id.in_(node_ids))
        .all()
        if node_ids
        else []
    )
    assigned = {str(row.mesh_node_id): {"asset_id": row.id, "asset_name": row.name} for row in assigned_rows if row.mesh_node_id}

    available_devices: list[dict[str, Any]] = []
    current_device: dict[str, Any] | None = None
    for item in devices:
        row = dict(item)
        owner = assigned.get(row["node_id"])
        row["assigned_asset_id"] = owner["asset_id"] if owner else None
        row["assigned_asset_name"] = owner["asset_name"] if owner else None
        row["is_current"] = row["node_id"] == (asset.mesh_node_id or "")
        if row["is_current"]:
            current_device = row
            continue
        if owner is None:
            available_devices.append(row)

    return templates.TemplateResponse(
        "asset_mesh_link.html",
        {
            "request": request,
            "asset": asset,
            "current_device": current_device,
            "available_devices": available_devices,
        },
    )


@app.post("/assets/{asset_id}/mesh-link")
def asset_mesh_link_save(
    asset_id: int,
    request: Request,
    node_id: str = Form(...),
    db: Session = Depends(get_db),
):
    _require_admin(request)
    asset = _mesh_link_asset(db, asset_id)
    requested_node_id = str(node_id or "").strip()
    if not requested_node_id:
        return RedirectResponse(
            f"/assets/{asset.id}/mesh-link?toast_error="
            + quote(_translate_request(request, "mesh.link.select_required", "Select a MeshCentral device.")),
            status_code=303,
        )

    try:
        device_ids = {item["node_id"] for item in fetch_device_summaries_for_linking() if item.get("node_id")}
    except RuntimeError as exc:
        return RedirectResponse(
            f"/assets/{asset.id}?toast_error=" + quote(
                _translate_request(
                    request,
                    "mesh.link.fetch_failed",
                    "The MeshCentral device list could not be loaded: {error}",
                    error=str(exc),
                )
            ),
            status_code=303,
        )

    if requested_node_id not in device_ids:
        return RedirectResponse(
            f"/assets/{asset.id}/mesh-link?toast_error="
            + quote(_translate_request(request, "mesh.link.not_found", "The selected MeshCentral device no longer exists.")),
            status_code=303,
        )

    owner = db.query(Asset).filter(Asset.mesh_node_id == requested_node_id, Asset.id != asset.id).first()
    if owner:
        message = _translate_request(
            request,
            "mesh.link.already_assigned",
            "This MeshCentral device is already linked to asset {asset}.",
            asset=owner.name,
        )
        return RedirectResponse(
            f"/assets/{asset.id}/mesh-link?toast_error=" + quote(message),
            status_code=303,
        )

    old_node_id = asset.mesh_node_id or ""
    if old_node_id == requested_node_id:
        return RedirectResponse(
            f"/assets/{asset.id}?toast_warning="
            + quote(_translate_request(request, "mesh.link.unchanged", "The MeshCentral link is already set to this device.")),
            status_code=303,
        )

    asset.mesh_node_id = requested_node_id
    _reset_mesh_link_runtime_state(asset)
    asset.mesh_sync_status = "pending"
    asset.mesh_sync_message = _translate_request(
        request,
        "mesh.link.pending_sync",
        "MeshCentral link changed manually; synchronization is pending.",
    )
    _record_asset_history(
        db,
        asset,
        {
            _translate_request(request, "field.mesh_node_id", "MeshCentral node ID"): {
                "old": old_node_id,
                "new": requested_node_id,
            }
        },
        source="mesh-link-manual",
        changed_by=_changed_by(request),
    )
    try:
        db.commit()
    except IntegrityError:
        db.rollback()
        return RedirectResponse(
            f"/assets/{asset.id}/mesh-link?toast_error="
            + quote(_translate_request(request, "mesh.link.conflict", "The MeshCentral node ID was assigned by another operation.")),
            status_code=303,
        )

    return RedirectResponse(
        f"/assets/{asset.id}?toast_success="
        + quote(_translate_request(request, "mesh.link.changed", "MeshCentral link changed.")),
        status_code=303,
    )


@app.post("/assets/{asset_id}/mesh-unlink")
def asset_mesh_unlink(asset_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    asset = _mesh_link_asset(db, asset_id)
    old_node_id = asset.mesh_node_id or ""
    if not old_node_id:
        return RedirectResponse(
            f"/assets/{asset.id}?toast_warning="
            + quote(_translate_request(request, "mesh.link.already_unlinked", "This asset is not linked to MeshCentral.")),
            status_code=303,
        )

    asset.mesh_node_id = None
    _reset_mesh_link_runtime_state(asset)
    asset.mesh_sync_status = "unlinked"
    asset.mesh_sync_message = _translate_request(request, "mesh.link.unlinked_message", "MeshCentral link removed manually.")
    _record_asset_history(
        db,
        asset,
        {
            _translate_request(request, "field.mesh_node_id", "MeshCentral node ID"): {
                "old": old_node_id,
                "new": "",
            }
        },
        source="mesh-unlink-manual",
        changed_by=_changed_by(request),
    )
    db.commit()
    return RedirectResponse(
        f"/assets/{asset.id}?toast_success="
        + quote(_translate_request(request, "mesh.link.unlinked", "MeshCentral link removed.")),
        status_code=303,
    )


def _presence_payload(asset: Asset) -> dict[str, Any]:
    return {
        "asset_id": asset.id,
        "has_mesh_node_id": bool(asset.mesh_node_id),
        "state": asset.mesh_online_state or "unknown",
        "online": asset.mesh_online,
        "updated_at": _utc_iso(asset.mesh_presence_updated_at) or None,
        "last_online_at": _utc_iso(asset.mesh_last_online_at) or None,
        "last_seen_at": _utc_iso(asset.mesh_presence_updated_at if (asset.mesh_online_state or "unknown") == "online" else asset.mesh_last_seen) or None,
        "historical_last_seen_at": _utc_iso(asset.mesh_last_seen) or None,
        "source": asset.mesh_presence_source,
    }


@app.get("/api/assets/presence")
def assets_presence(request: Request, db: Session = Depends(get_db)):
    assets = (
        _apply_asset_access(db.query(Asset), request)
        .filter(Asset.mesh_node_id.isnot(None), Asset.mesh_node_id != "")
        .all()
    )
    return {"items": [_presence_payload(asset) for asset in assets]}


@app.get("/api/assets/{asset_id}/presence")
def asset_presence(asset_id: int, request: Request, db: Session = Depends(get_db)):
    asset = _get_visible_asset(db, request, asset_id)
    return _presence_payload(asset)


@app.get("/assets/{asset_id}/mesh-inventory.json")
def asset_mesh_inventory_json(asset_id: int, request: Request, db: Session = Depends(get_db)):
    asset = _apply_asset_access(db.query(Asset), request).filter(Asset.id == asset_id).first()
    if not asset:
        raise HTTPException(404, "Asset nicht gefunden")
    hardware, software, raw = _inventory_fallback(asset)
    payload = {
        "asset_id": asset.id,
        "asset_name": asset.name,
        "mesh_node_id": asset.mesh_node_id,
        "updated_at": _utc_iso(asset.mesh_inventory_updated_at) or None,
        "hardware": hardware,
        "software": software,
        "raw": raw,
    }
    filename = re.sub(r"[^A-Za-z0-9._-]+", "_", asset.name or f"asset-{asset.id}") + "-mesh-inventory.json"
    return Response(content=json.dumps(payload, ensure_ascii=False, indent=2, default=str), media_type="application/json", headers={"Content-Disposition": f'attachment; filename="{filename}"'})


@app.get("/assets/{asset_id}/edit")
def asset_edit(asset_id: int, request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
    _require_admin(request)
    asset = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first()
    categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
    if not asset:
        raise HTTPException(404, "Asset nicht gefunden")
    selected = next((category for category in categories if category.id == category_id), None) if category_id else asset.category
    if selected is None:
        selected = asset.category
    return templates.TemplateResponse("asset_form.html", {"request": request, "asset": asset, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).filter(Asset.id != asset.id).order_by(Asset.name).all(), "custom_values": {f.field_name: _custom_value_get(asset, f.definition, db) for f in selected.visible_fields if f.definition and not f.definition.is_system}, "standard_images": list_standard_images()})


@app.post("/assets/{asset_id}/edit")
async def asset_update(asset_id: int, request: Request, category_id: int = Form(...), image: UploadFile | None = File(None), db: Session = Depends(get_db)):
    _require_admin(request)
    asset = db.query(Asset).filter(Asset.id == asset_id).first()
    category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first()
    if not asset or not category:
        raise HTTPException(404, "Asset oder Kategorie nicht gefunden")
    form = await request.form()
    changes: dict[str, dict[str, str]] = {}

    # mesh_node_id is a protected system field. On the administrator-only
    # edit page it may nevertheless be changed directly or cleared. Do this
    # separately from the generic field loop because readonly system fields
    # are intentionally skipped there.
    if "mesh_node_id" in form:
        old_node_id = _normalize_optional_text(asset.mesh_node_id)
        requested_node_id = _normalize_optional_text(form.get("mesh_node_id"))
        if old_node_id != requested_node_id:
            if requested_node_id:
                owner = (
                    db.query(Asset.id, Asset.name)
                    .filter(Asset.mesh_node_id == requested_node_id, Asset.id != asset.id)
                    .first()
                )
                if owner:
                    message = _translate_request(
                        request,
                        "mesh.link.already_assigned",
                        "This MeshCentral device is already linked to asset {asset}.",
                    ).replace("{asset}", owner.name or str(owner.id))
                    return RedirectResponse(
                        f"/assets/{asset.id}/edit?toast_error=" + quote(message),
                        status_code=303,
                    )

            changes[_translate_request(request, "field.mesh_node_id", "MeshCentral node ID")] = {
                "old": old_node_id or "",
                "new": requested_node_id or "",
            }
            asset.mesh_node_id = requested_node_id
            _reset_mesh_link_runtime_state(asset)
            if requested_node_id:
                asset.mesh_sync_status = "pending"
                asset.mesh_sync_message = _translate_request(
                    request,
                    "mesh.link.pending_sync",
                    "MeshCentral link changed manually; synchronization is pending.",
                )
            else:
                asset.mesh_sync_status = "unlinked"
                asset.mesh_sync_message = _translate_request(
                    request,
                    "mesh.link.unlinked_message",
                    "MeshCentral link removed manually.",
                )

    old_category_id = asset.category_id
    if old_category_id != category_id:
        old_category = db.query(Category).filter(Category.id == old_category_id).first()
        changes["Kategorie"] = {"old": old_category.name if old_category else str(old_category_id), "new": category.name}
    asset.category_id = category_id
    for field in category.visible_fields:
        if (
            field.active
            and hasattr(asset, field.field_name)
            and field.field_name not in {"room_number", "parent_asset_id"}
            and not _definition_readonly(field)
        ):
            old_value = getattr(asset, field.field_name)
            new_value = form.get(field.field_name) or None
            if field.definition and field.definition.data_type == "date":
                new_value = _normalize_date_value(new_value)
            elif field.definition and field.definition.data_type == "datetime":
                new_value = _normalize_datetime_value(new_value)
            if _display_value(old_value) != _display_value(new_value):
                changes[_definition_label(field)] = {"old": _display_value(old_value), "new": _display_value(new_value)}
                setattr(asset, field.field_name, new_value)
    new_room = _normalize_optional_text(form.get("room_number"))
    if _display_value(asset.room_number) != _display_value(new_room):
        changes["Raumnummer"] = {"old": _display_value(asset.room_number), "new": _display_value(new_room)}
        asset.room_number = new_room
    parent_raw = _normalize_optional_text(form.get("parent_asset_id"))
    new_parent_id = int(parent_raw) if parent_raw else None
    if new_parent_id and _would_create_parent_cycle(db, asset.id, new_parent_id):
        raise HTTPException(400, "Diese Zuordnung würde einen Kreis in der Baustruktur erzeugen.")
    current_parent_id = int(asset.parent_asset_id) if asset.parent_asset_id not in (None, "") else None
    if current_parent_id != new_parent_id:
        old_parent = db.query(Asset).filter(Asset.id == current_parent_id).first() if current_parent_id else None
        new_parent = db.query(Asset).filter(Asset.id == new_parent_id).first() if new_parent_id else None
        changes["Übergeordnetes Gerät"] = {"old": old_parent.name if old_parent else "", "new": new_parent.name if new_parent else ""}
        asset.parent_asset_id = new_parent_id
    for field in category.visible_fields:
        if field.active and field.definition and not field.definition.is_system and not field.definition.readonly:
            old_custom = _custom_value_get(asset, field.definition, db)
            new_custom = form.get(field.field_name)
            if _display_value(old_custom) != _display_value(new_custom):
                changes[_definition_label(field)] = {"old": _display_value(old_custom), "new": _display_value(new_custom)}
                _custom_value_set(asset, field.definition, new_custom, db)
    _validate_asset_identifiers(db, asset_tag=asset.asset_tag, manufacturer=asset.manufacturer, serial_number=asset.serial_number, exclude_id=asset.id)
    visual_source = str(form.get("visual_source") or "auto").strip().lower()
    if visual_source not in {"auto", "asset_image", "category_image", "mesh_icon"}:
        visual_source = "auto"
    old_visual_source = getattr(asset, "visual_source", "auto") or "auto"
    if old_visual_source != visual_source:
        changes[_translate_request(request, "assets.visual_source", "Display source")] = {
            "old": _translate_request(request, f"assets.visual_source.{old_visual_source}", old_visual_source),
            "new": _translate_request(request, f"assets.visual_source.{visual_source}", visual_source),
        }
        asset.visual_source = visual_source
    remove_image_requested = str(form.get("remove_image") or "").strip().lower() in {"1", "true", "on", "yes"}
    new_image = choose_form_image(form, image)
    image_label = _translate_request(request, "assets.image", "Image")
    if new_image:
        old_image = asset.image_path
        if old_image != new_image:
            changes[image_label] = {"old": old_image or "", "new": new_image}
        asset.image_path = new_image
        # Do not delete the previous upload here. Image files can be referenced
        # by more than one record (for example after duplication). Orphaned
        # uploads are intentionally left for a later maintenance/cleanup task.
    elif remove_image_requested and asset.image_path:
        old_image = asset.image_path
        changes[image_label] = {"old": old_image, "new": ""}
        asset.image_path = None
        # Only remove the association. The physical file may still be referenced
        # elsewhere and is cleaned up separately by maintenance tooling.
    try:
        _record_asset_history(db, asset, changes, source="manual-update", changed_by=_changed_by(request))
        db.commit()
    except IntegrityError as exc:
        db.rollback()
        raise HTTPException(400, "Asset konnte nicht gespeichert werden. Inventarnummer, Seriennummer oder MeshCentral Node-ID ist möglicherweise bereits vergeben.") from exc
    return RedirectResponse(f"/assets/{asset.id}", status_code=303)


@app.get("/assets/{asset_id}/history")
def asset_history(asset_id: int, request: Request, db: Session = Depends(get_db)):
    asset = _get_visible_asset(db, request, asset_id)
    entries = db.query(AssetHistory).filter(AssetHistory.asset_id == asset_id).order_by(AssetHistory.changed_at.desc()).all()
    return templates.TemplateResponse("asset_history.html", {"request": request, "asset": asset, "entries": entries})


@app.get("/assets/{asset_id}/history/export.xlsx")
def asset_history_export(asset_id: int, request: Request, db: Session = Depends(get_db)):
    asset = _get_visible_asset(db, request, asset_id)
    if not asset:
        raise HTTPException(404, "Asset nicht gefunden")
    entries = db.query(AssetHistory).filter(AssetHistory.asset_id == asset_id).order_by(AssetHistory.changed_at.desc()).all()
    rows = []
    for entry in entries:
        for label, change in (entry.changes or {}).items():
            rows.append([entry.changed_at, entry.source, entry.changed_by or "", label, change.get("old", ""), change.get("new", "")])
    return _xlsx_response(f"asset-{asset.id}-historie.xlsx", ["Zeitpunkt", "Quelle", "Benutzer", "Feld", "Alter Wert", "Neuer Wert"], rows)


@app.get("/assets/export.xlsx")
def assets_export(request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
    categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
    selected = next((category for category in categories if category.id == category_id), None) if category_id else None
    query = _apply_asset_access(db.query(Asset), request)
    if selected:
        query = query.filter(Asset.category_id == selected.id)
    assets = query.order_by(Asset.name).all()
    fields = ([field for field in sorted(selected.visible_fields, key=lambda item: item.sort_order) if field.active and field.show_in_list] if selected else _list_fields_for_categories(categories))
    headers = ["Asset-ID"] + ((["Kategorie"] if not selected else []) + [field.label for field in fields])
    if not any(field.field_name == "name" for field in fields):
        headers.insert(1, "Bezeichnung")
    rows = []
    for asset in assets:
        row = [asset.id] + (([asset.category.name] if not selected else []) + [getattr(asset, field.field_name, "") for field in fields])
        if not any(field.field_name == "name" for field in fields):
            row.insert(1, asset.name)
        rows.append(row)
    suffix = selected.name if selected else "alle"
    return _xlsx_response(f"assets-{suffix}.xlsx", headers, rows)


@app.get("/export/assets.xlsx")
def assets_export_static(request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
    return assets_export(request=request, category_id=category_id, db=db)


@app.post("/assets/{asset_id}/issue")
def asset_issue(asset_id: int, request: Request, return_to: str = Form("detail"), assigned_to: str = Form(""), assigned_on: str = Form(""), db: Session = Depends(get_db)):
    _require_admin(request)
    asset = _get_visible_asset(db, request, asset_id)
    status = _action_status(db, "issue")
    if not status:
        raise HTTPException(400, "Kein Status für die Aktion Ausgeben konfiguriert")
    changes = _asset_action_changes(asset, "issue", status.name, assigned_to, assigned_on)
    _record_asset_history(db, asset, changes, source="manual-issue", changed_by=_changed_by(request))
    db.commit()
    return RedirectResponse("/" if return_to == "dashboard" else ("/assets" if return_to == "list" else f"/assets/{asset.id}"), status_code=303)


@app.post("/assets/{asset_id}/return")
def asset_return(asset_id: int, request: Request, return_to: str = Form("detail"), db: Session = Depends(get_db)):
    _require_admin(request)
    asset = _get_visible_asset(db, request, asset_id)
    status = _action_status(db, "return")
    if not status:
        raise HTTPException(400, "Kein Status für die Aktion Zurücknehmen konfiguriert")
    changes = _asset_action_changes(asset, "return", status.name)
    _record_asset_history(db, asset, changes, source="manual-return", changed_by=_changed_by(request))
    db.commit()
    return RedirectResponse("/" if return_to == "dashboard" else ("/assets" if return_to == "list" else f"/assets/{asset.id}"), status_code=303)



MERGE_PREVIEW_FIELDS = [
    ("id", "Asset-ID"),
    ("category", "Kategorie"),
    ("name", "Bezeichnung"),
    ("model", "Modell"),
    ("serial_number", "Seriennummer"),
    ("hostname", "Computername"),
    ("notes", "Notizen"),
    ("mesh_node_id", "MeshCentral Node-ID"),
]

MERGE_FILL_FIELDS = ["model", "serial_number", "hostname", "notes", "mesh_node_id"]


def _merge_display_value(asset: Asset, field_name: str) -> str:
    if field_name == "id":
        return str(asset.id)
    if field_name == "category":
        return asset.category.name if asset.category else ""
    return _display_value(getattr(asset, field_name, None))


def _merge_preview(assets: list[Asset]) -> list[dict]:
    result = []
    for field_name, label in MERGE_PREVIEW_FIELDS:
        source = assets[0]
        value = _merge_display_value(source, field_name)
        if field_name not in {"id", "category", "name"} and not value:
            for candidate in assets[1:]:
                candidate_value = _merge_display_value(candidate, field_name)
                if candidate_value:
                    source = candidate
                    value = candidate_value
                    break
        result.append({
            "field_name": field_name,
            "label": label,
            "value": value,
            "source_asset_id": source.id,
        })
    return result


def _ordered_merge_assets(db: Session, asset_ids: list[str]) -> list[Asset]:
    ids = _selected_asset_ids(asset_ids)
    if len(ids) < 2:
        raise HTTPException(400, "Bitte mindestens zwei Assets auswählen.")
    rows = db.query(Asset).options(joinedload(Asset.category)).filter(Asset.id.in_(ids)).all()
    by_id = {asset.id: asset for asset in rows}
    if len(by_id) != len(ids):
        raise HTTPException(404, "Mindestens ein ausgewähltes Asset wurde nicht gefunden.")
    return [by_id[asset_id] for asset_id in ids]


@app.post("/assets/merge")
def assets_merge_order(request: Request, asset_ids: list[str] = Form([]), db: Session = Depends(get_db)):
    _require_admin(request)
    assets = _ordered_merge_assets(db, asset_ids)
    return templates.TemplateResponse(
        "assets_merge_order.html",
        {"request": request, "assets": assets},
    )


@app.post("/assets/merge/preview")
def assets_merge_preview(request: Request, asset_ids: list[str] = Form([]), db: Session = Depends(get_db)):
    _require_admin(request)
    assets = _ordered_merge_assets(db, asset_ids)
    return templates.TemplateResponse(
        "assets_merge_preview.html",
        {"request": request, "assets": assets, "preview": _merge_preview(assets)},
    )


@app.post("/assets/merge/apply")
def assets_merge_apply(request: Request, asset_ids: list[str] = Form([]), db: Session = Depends(get_db)):
    _require_admin(request)
    assets = _ordered_merge_assets(db, asset_ids)
    target = assets[0]
    sources = assets[1:]
    source_ids = [asset.id for asset in sources]
    changes = {}

    for field_name in MERGE_FILL_FIELDS:
        if _display_value(getattr(target, field_name, None)):
            continue
        for source in sources:
            candidate = getattr(source, field_name, None)
            if not _display_value(candidate):
                continue

            if field_name == "mesh_node_id":
                # mesh_node_id is unique. When a manually imported asset stays
                # and absorbs a MeshCentral asset, the source must release the
                # node ID before the target can receive it. Without this flush,
                # PostgreSQL may update the target first and reject the merge.
                source.mesh_node_id = None
                db.flush()

            setattr(target, field_name, candidate)
            changes[field_name] = {
                "old": "",
                "new": _display_value(candidate),
                "source_asset_id": str(source.id),
            }
            break

    target_values = {
        row.field_definition_id: row
        for row in db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == target.id).all()
    }
    for source in sources:
        for source_value in db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == source.id).all():
            existing = target_values.get(source_value.field_definition_id)
            current = ""
            if existing:
                current = _display_value(
                    existing.value_text if existing.value_text is not None else
                    existing.value_number if existing.value_number is not None else
                    existing.value_boolean if existing.value_boolean is not None else
                    existing.value_date if existing.value_date is not None else
                    existing.value_datetime if existing.value_datetime is not None else
                    existing.value_json
                )
            if current:
                continue
            if existing is None:
                source_value.asset_id = target.id
                target_values[source_value.field_definition_id] = source_value
            else:
                existing.value_text = source_value.value_text
                existing.value_number = source_value.value_number
                existing.value_boolean = source_value.value_boolean
                existing.value_date = source_value.value_date
                existing.value_datetime = source_value.value_datetime
                existing.value_json = source_value.value_json
                db.delete(source_value)

    db.query(AssetHistory).filter(AssetHistory.asset_id.in_(source_ids)).update(
        {AssetHistory.asset_id: target.id}, synchronize_session=False
    )
    db.query(SoftwareJob).filter(SoftwareJob.asset_id.in_(source_ids)).update(
        {SoftwareJob.asset_id: target.id}, synchronize_session=False
    )
    # Compact job states are derived data. Rebuild them for the surviving asset
    # after the complete job history has been moved.
    db.query(AssetJobState).filter(AssetJobState.asset_id.in_([target.id, *source_ids])).delete(synchronize_session=False)
    db.flush()
    for merged_job in (
        db.query(SoftwareJob)
        .filter(SoftwareJob.asset_id == target.id)
        .order_by(SoftwareJob.created_at.asc(), SoftwareJob.id.asc())
        .all()
    ):
        sync_asset_job_state(
            db,
            merged_job,
            increment_execution=True,
            status_at=merged_job.finished_at or merged_job.callback_completed_at or merged_job.started_at or merged_job.sent_at or merged_job.created_at,
        )

    existing_software = {
        (row.name.casefold(), (row.version or "").casefold(), (row.publisher or "").casefold(), (row.architecture or "").casefold())
        for row in db.query(InstalledSoftware).filter(InstalledSoftware.asset_id == target.id).all()
    }
    for row in db.query(InstalledSoftware).filter(InstalledSoftware.asset_id.in_(source_ids)).all():
        identity = (row.name.casefold(), (row.version or "").casefold(), (row.publisher or "").casefold(), (row.architecture or "").casefold())
        if identity in existing_software:
            db.delete(row)
        else:
            row.asset_id = target.id
            existing_software.add(identity)

    db.query(Asset).filter(Asset.parent_asset_id.in_(source_ids)).update(
        {Asset.parent_asset_id: target.id}, synchronize_session=False
    )
    if target.parent_asset_id in source_ids:
        target.parent_asset_id = None

    _record_asset_history(
        db,
        target,
        {
            "Duplikate zusammengeführt": {
                "old": ", ".join(str(asset_id) for asset_id in source_ids),
                "new": str(target.id),
            },
            **changes,
        },
        source="duplicate-merge",
        changed_by=_changed_by(request),
    )

    for source in sources:
        db.delete(source)

    try:
        db.commit()
    except IntegrityError:
        db.rollback()
        message = _translate_request(
            request,
            "duplicates.merge_unique_error",
            "The duplicates could not be merged because a unique value is already assigned to another asset.",
        )
        # Do not redirect back to /assets/merge/preview: that page is POST-only.
        # A GET redirect there caused ERR_TOO_MANY_REDIRECTS instead of a toast.
        return RedirectResponse(f"/assets?toast_error={quote(message)}", status_code=303)

    return RedirectResponse(
        f"/assets/{target.id}?toast_success={quote(f'{len(sources)} Duplikate wurden in Asset {target.id} zusammengeführt')}",
        status_code=303,
    )



@app.post("/assets/bulk-edit")
def assets_bulk_edit_form(request: Request, asset_ids: list[str] = Form([]), category_id: int | None = Form(None), db: Session = Depends(get_db)):
    _require_admin(request)
    ids = _selected_asset_ids(asset_ids)
    if not ids:
        raise HTTPException(400, "Bitte mindestens ein Asset markieren.")
    assets = (db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id.in_(ids)).order_by(Asset.name).all())
    if len(assets) != len(ids):
        raise HTTPException(404, "Mindestens ein ausgewähltes Asset wurde nicht gefunden.")
    fields = [{"field_name": "category_id", "label": "Kategorie"}] + _bulk_edit_fields_for_assets(assets)
    categories = _sort_localized(db.query(Category).all(), "name", request)
    return templates.TemplateResponse("assets_bulk_edit.html", {
        "request": request,
        "assets": assets,
        "asset_ids": ids,
        "fields": fields,
        "status_options": _active_status_options(db, request),
        "parent_candidates": _sort_localized(db.query(Asset).filter(~Asset.id.in_(ids)).all(), "name", request),
        "categories": categories,
        "category_id": category_id,
    })


@app.post("/assets/bulk-edit/apply")
def assets_bulk_edit_apply(request: Request, asset_ids: list[str] = Form([]), field_name: str = Form(...), new_value: str = Form(""), category_id: int | None = Form(None), db: Session = Depends(get_db)):
    _require_admin(request)
    ids = _selected_asset_ids(asset_ids)
    assets = (db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id.in_(ids)).all())
    if not ids or len(assets) != len(ids):
        raise HTTPException(400, "Die Auswahl ist leer oder nicht mehr vollständig vorhanden.")
    allowed = {"category_id": "Kategorie", **{item["field_name"]: item["label"] for item in _bulk_edit_fields_for_assets(assets)}}
    if field_name not in allowed:
        raise HTTPException(400, "Das gewählte Feld ist nicht in allen ausgewählten Gerätekategorien zugelassen.")
    value = _normalize_optional_text(new_value)
    parent_asset = None
    target_category = None
    if field_name == "category_id":
        try:
            target_category_id = int(value) if value else None
        except (TypeError, ValueError) as exc:
            raise HTTPException(400, "Die Kategorie ist ungültig.") from exc
        if target_category_id is None:
            raise HTTPException(400, "Bitte eine Kategorie auswählen.")
        target_category = db.query(Category).filter(Category.id == target_category_id).first()
        if not target_category:
            raise HTTPException(404, "Die gewählte Kategorie wurde nicht gefunden.")
        value = target_category_id
    if field_name == "parent_asset_id":
        try:
            parent_id = int(value) if value else None
        except (TypeError, ValueError) as exc:
            raise HTTPException(400, "Das übergeordnete Gerät ist ungültig.") from exc
        if parent_id in ids:
            raise HTTPException(400, "Ein ausgewähltes Asset kann nicht gleichzeitig als übergeordnetes Gerät für die gesamte Auswahl verwendet werden.")
        if parent_id is not None:
            parent_asset = db.query(Asset).filter(Asset.id == parent_id).first()
            if not parent_asset:
                raise HTTPException(404, "Das gewählte übergeordnete Gerät wurde nicht gefunden.")
        value = parent_id
        for asset in assets:
            if parent_id is not None and _would_create_parent_cycle(db, asset.id, parent_id):
                raise HTTPException(400, f"Die Zuordnung würde beim Asset {asset.name} einen Kreis in der Baumstruktur erzeugen.")

    changed = 0
    for asset in assets:
        old_value = getattr(asset, field_name, None)
        if old_value == value or _display_value(old_value) == _display_value(value):
            continue
        if field_name == "parent_asset_id":
            old_parent_id = int(old_value) if old_value not in (None, "") else None
            old_parent = db.query(Asset).filter(Asset.id == old_parent_id).first() if old_parent_id else None
            history_old = old_parent.name if old_parent else ""
            history_new = parent_asset.name if parent_asset else ""
        elif field_name == "category_id":
            history_old = asset.category.name if asset.category else str(old_value or "")
            history_new = target_category.name
        else:
            history_old = _display_value(old_value)
            history_new = _display_value(value)
        setattr(asset, field_name, value)
        _record_asset_history(db, asset, {allowed[field_name]: {"old": history_old, "new": history_new}}, source="bulk-update", changed_by=_changed_by(request))
        changed += 1
    try:
        db.commit()
    except IntegrityError as exc:
        db.rollback()
        raise HTTPException(400, "Die Daten konnten nicht für alle Assets gespeichert werden.") from exc
    target = f"/assets?category_id={category_id}" if category_id else "/assets"
    return RedirectResponse(f"{target}{'&' if '?' in target else '?'}toast_success={quote(str(changed) + ' Assets aktualisiert')}", status_code=303)


@app.post("/assets/bulk-delete")
def assets_bulk_delete(request: Request, asset_ids: list[str] = Form([]), category_id: int | None = Form(None), db: Session = Depends(get_db)):
    _require_admin(request)
    ids = _selected_asset_ids(asset_ids)
    if not ids:
        raise HTTPException(400, "Bitte mindestens ein Asset markieren.")
    assets = db.query(Asset).filter(Asset.id.in_(ids)).all()
    if len(assets) != len(ids):
        raise HTTPException(404, "Mindestens ein ausgewähltes Asset wurde nicht gefunden.")
    for asset in assets:
        db.delete(asset)
    db.commit()
    # Uploaded image files are intentionally retained. They may be shared by
    # duplicated records and can be removed later by an orphan cleanup task.
    target = f"/assets?category_id={category_id}" if category_id else "/assets"
    return RedirectResponse(f"{target}{'&' if '?' in target else '?'}toast_success={quote(str(len(assets)) + ' Assets gelöscht')}", status_code=303)


@app.post("/assets/{asset_id}/delete")
def asset_delete(asset_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    asset = _get_visible_asset(db, request, asset_id)

    db.delete(asset)
    db.commit()
    # Keep the uploaded image file. It may still be referenced by another
    # record; orphan cleanup is handled separately.
    return RedirectResponse("/assets", status_code=303)


@app.get("/status-options")
def status_options_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    options = db.query(StatusOption).order_by(StatusOption.sort_order, StatusOption.name).all()
    return templates.TemplateResponse("status_options.html", {"request": request, "options": options})


@app.post("/status-options")
async def status_options_save(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    existing = db.query(StatusOption).all()
    for option in existing:
        name = (form.get(f"name_{option.id}") or "").strip()
        if not name:
            continue
        option.name = name
        option.active = form.get(f"active_{option.id}") == "on"
        try:
            option.sort_order = int(form.get(f"sort_{option.id}") or option.sort_order)
        except ValueError:
            pass
    issue_id = int(form.get("issue_status_id") or 0)
    return_id = int(form.get("return_status_id") or 0)
    for option in existing:
        option.use_for_issue = option.id == issue_id
        option.use_for_return = option.id == return_id
    new_name = (form.get("new_name") or "").strip()
    if new_name:
        max_order = max((item.sort_order for item in existing), default=-1)
        db.add(StatusOption(name=new_name, sort_order=max_order + 1, active=True))
    try:
        db.commit()
    except IntegrityError as exc:
        db.rollback()
        raise HTTPException(400, "Statuswerte müssen eindeutig sein.") from exc
    return RedirectResponse("/status-options", status_code=303)


@app.post("/status-options/{option_id}/delete")
def status_option_delete(option_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    option = db.query(StatusOption).filter(StatusOption.id == option_id).first()
    if not option:
        raise HTTPException(404, "Statuswert nicht gefunden")
    db.delete(option)
    db.commit()
    return RedirectResponse("/status-options", status_code=303)


@app.get("/categories")
def categories_list(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    categories = (
        db.query(Category)
        .options(selectinload(Category.visible_fields))
        .order_by(Category.name)
        .all()
    )
    asset_counts = dict(
        db.query(Asset.category_id, func.count(Asset.id))
        .group_by(Asset.category_id)
        .all()
    )
    return templates.TemplateResponse(
        "categories.html",
        {"request": request, "categories": categories, "asset_counts": asset_counts,
         "definitions": db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all()},
    )



@app.post("/categories/bulk-edit")
async def categories_bulk_edit(
    request: Request,
    category_ids: list[str] = Form([]),
    field_name: str = Form(...),
    field_value: str = Form(""),
    clear_value: str | None = Form(None),
    image: UploadFile | None = File(None),
    db: Session = Depends(get_db),
):
    _require_admin(request)
    ids = _selected_asset_ids(category_ids)
    if not ids:
        message = _translate_request(request, "categories.bulk.no_selection", "No categories selected")
        return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303)

    categories = db.query(Category).filter(Category.id.in_(ids)).all()
    if not categories:
        message = _translate_request(request, "categories.bulk.no_selection", "No categories selected")
        return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303)

    clear = clear_value == "on"
    if field_name == "name":
        if len(categories) != 1:
            message = _translate_request(request, "categories.bulk.name_single_only", "The name can only be changed for one category at a time")
            return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303)
        value = field_value.strip()
        if not value:
            message = _translate_request(request, "categories.bulk.name_required", "The category name must not be empty")
            return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303)
        categories[0].name = value
    elif field_name == "description":
        value = None if clear else (field_value.strip() or None)
        for category in categories:
            category.description = value
    elif field_name == "image":
        if clear:
            for category in categories:
                category.image_path = None
        else:
            new_image = save_upload(image)
            if not new_image:
                message = _translate_request(request, "categories.bulk.image_required", "Select an image or choose clear value")
                return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303)
            for category in categories:
                category.image_path = new_image
    else:
        raise HTTPException(400, _translate_request(request, "categories.bulk.unsupported_field", "Unsupported category field"))

    try:
        db.commit()
    except IntegrityError:
        db.rollback()
        message = _translate_request(request, "categories.bulk.name_duplicate", "The category name already exists")
        return RedirectResponse(f"/categories?toast_error={quote(message)}", status_code=303)

    message = _translate_request(request, "categories.bulk.updated", "Updated {count} categories", count=len(categories))
    return RedirectResponse(f"/categories?toast_success={quote(message)}", status_code=303)


@app.post("/categories/bulk-fields")
async def categories_bulk_fields(
    request: Request,
    category_ids: list[str] = Form([]),
    operation: str = Form(...),
    field_ids: list[str] = Form([]),
    field_definition_id: str = Form(""),
    apply_required: str | None = Form(None),
    required_value: str | None = Form(None),
    apply_show_in_list: str | None = Form(None),
    show_in_list_value: str | None = Form(None),
    source_category_id: str = Form(""),
    db: Session = Depends(get_db),
):
    """Manage category field assignments without touching generic table behavior."""
    _require_admin(request)
    ids = _selected_asset_ids(category_ids)
    if not ids:
        message = _translate_request(request, "categories.fields.no_selection", "No categories selected")
        return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303)

    categories = (
        db.query(Category)
        .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition))
        .filter(Category.id.in_(ids))
        .all()
    )
    if not categories:
        message = _translate_request(request, "categories.fields.no_selection", "No categories selected")
        return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303)

    definitions = {item.id: item for item in db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).all()}
    selected_definition_ids = _selected_asset_ids(field_ids)
    changed = 0

    if operation in {"add", "remove"}:
        selected_definitions = [definitions[item_id] for item_id in selected_definition_ids if item_id in definitions]
        if not selected_definitions:
            message = _translate_request(request, "categories.fields.choose_fields", "Select at least one field")
            return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303)

        for category in categories:
            by_definition = {link.field_definition_id: link for link in category.visible_fields if link.field_definition_id}
            by_name = {link.field_name: link for link in category.visible_fields}
            next_order = max((link.sort_order for link in category.visible_fields), default=-1) + 1
            for definition in selected_definitions:
                link = by_definition.get(definition.id) or by_name.get(definition.field_name)
                if operation == "add":
                    if link is None:
                        link = CategoryField(
                            category_id=category.id,
                            field_name=definition.field_name,
                            label=definition.label,
                            field_definition_id=definition.id,
                            active=True,
                            required=definition.field_name == "name",
                            show_in_list=definition.field_name == "name",
                            readonly=definition.readonly,
                            sort_order=next_order,
                        )
                        next_order += 1
                        db.add(link)
                        category.visible_fields.append(link)
                        changed += 1
                    elif not link.active:
                        link.active = True
                        if definition.field_name == "name":
                            link.required = True
                            link.show_in_list = True
                        changed += 1
                else:
                    if definition.field_name == "name":
                        continue
                    if link is not None and (link.active or link.required or link.show_in_list):
                        link.active = False
                        link.required = False
                        link.show_in_list = False
                        changed += 1

    elif operation == "settings":
        try:
            definition_id = int(field_definition_id)
        except (TypeError, ValueError):
            definition_id = 0
        definition = definitions.get(definition_id)
        if not definition:
            message = _translate_request(request, "categories.fields.choose_field", "Select a field")
            return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303)
        change_required = apply_required == "on"
        change_list = apply_show_in_list == "on"
        if not change_required and not change_list:
            message = _translate_request(request, "categories.fields.choose_setting", "Select at least one setting to change")
            return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303)
        required = required_value == "on"
        show_in_list = show_in_list_value == "on"
        for category in categories:
            link = next((item for item in category.visible_fields if item.field_definition_id == definition.id or item.field_name == definition.field_name), None)
            if link is None or not link.active:
                continue
            before = (link.required, link.show_in_list)
            if change_required and definition.field_name != "name" and not definition.readonly:
                link.required = required
            if change_list:
                link.show_in_list = True if definition.field_name == "name" else show_in_list
            if before != (link.required, link.show_in_list):
                changed += 1

    elif operation == "copy":
        try:
            source_id = int(source_category_id)
        except (TypeError, ValueError):
            source_id = 0
        source = (
            db.query(Category)
            .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition))
            .filter(Category.id == source_id)
            .first()
        )
        if not source:
            message = _translate_request(request, "categories.fields.choose_source", "Select a source category")
            return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303)
        source_links = sorted(source.visible_fields, key=lambda item: item.sort_order)
        for category in categories:
            if category.id == source.id:
                continue
            target_by_name = {item.field_name: item for item in category.visible_fields}
            source_names = {item.field_name for item in source_links}
            # A template is an exact field-configuration copy. Existing links are
            # retained but deactivated when absent from the source, preserving data.
            for target in category.visible_fields:
                if target.field_name not in source_names and target.field_name != "name":
                    target.active = False
                    target.required = False
                    target.show_in_list = False
            for source_link in source_links:
                target = target_by_name.get(source_link.field_name)
                if target is None:
                    target = CategoryField(
                        category_id=category.id,
                        field_name=source_link.field_name,
                        label=source_link.label,
                        field_definition_id=source_link.field_definition_id,
                        readonly=source_link.readonly,
                    )
                    db.add(target)
                    category.visible_fields.append(target)
                target.active = source_link.active or source_link.field_name == "name"
                target.required = True if source_link.field_name == "name" else source_link.required
                target.show_in_list = True if source_link.field_name == "name" else source_link.show_in_list
                target.sort_order = source_link.sort_order
            changed += 1
    else:
        raise HTTPException(400, _translate_request(request, "categories.fields.unsupported_operation", "Unsupported field operation"))

    db.commit()
    message = _translate_request(request, "categories.fields.updated", "Field configuration updated for {count} categories", count=changed)
    return RedirectResponse(f"/categories?toast_success={quote(message)}", status_code=303)

@app.post("/categories/activate-default-fields")
def categories_activate_default_fields(
    request: Request,
    category_ids: list[str] = Form([]),
    db: Session = Depends(get_db),
):
    _require_admin(request)
    ids = _selected_asset_ids(category_ids)
    if not ids:
        return RedirectResponse("/categories?toast_warning=Keine Kategorien ausgewählt", status_code=303)

    categories = (
        db.query(Category)
        .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition))
        .filter(Category.id.in_(ids))
        .all()
    )
    default_definitions = (
        db.query(FieldDefinition)
        .filter(FieldDefinition.is_active.is_(True), FieldDefinition.is_default.is_(True))
        .all()
    )
    if not default_definitions:
        return RedirectResponse("/categories?toast_warning=Es sind keine Standardfelder markiert", status_code=303)

    activated = 0
    created_links = 0
    for category in categories:
        by_definition = {link.field_definition_id: link for link in category.visible_fields if link.field_definition_id}
        by_name = {link.field_name: link for link in category.visible_fields}
        next_order = max((link.sort_order for link in category.visible_fields), default=-1) + 1
        for definition in default_definitions:
            link = by_definition.get(definition.id) or by_name.get(definition.field_name)
            if link is None:
                link = CategoryField(
                    category_id=category.id,
                    field_name=definition.field_name,
                    label=definition.label,
                    field_definition_id=definition.id,
                    active=True,
                    required=definition.field_name == "name",
                    show_in_list=True,
                    readonly=definition.readonly,
                    sort_order=next_order,
                )
                next_order += 1
                db.add(link)
                created_links += 1
            else:
                if not link.active:
                    link.active = True
                    activated += 1
                link.show_in_list = True
            if definition.field_name == "name":
                link.required = True
    db.commit()
    message = f"Standardfelder aktiviert: {activated} vorhandene und {created_links} neue Zuordnungen"
    return RedirectResponse(f"/categories?toast_success={quote(message)}", status_code=303)


@app.get("/categories/new")
def category_new(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    definitions=db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all()
    return templates.TemplateResponse("category_form.html", {"request": request, "category": None, "definitions": definitions, "selected_fields": {}, "standard_images": list_standard_images()})


@app.post("/categories/new")
async def category_create(request: Request, name: str = Form(...), description: str = Form(""), image: UploadFile | None = File(None), db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    category = Category(name=name.strip(), description=description or None, image_path=choose_form_image(form, image))
    db.add(category)
    db.flush()
    definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all()
    for order, definition in enumerate(definitions):
        active = form.get(f"active_{definition.id}") == "on" or definition.field_name == "name"
        db.add(CategoryField(category_id=category.id, field_name=definition.field_name, label=definition.label, field_definition_id=definition.id, active=active, required=True if definition.field_name == "name" else form.get(f"required_{definition.id}") == "on", show_in_list=True if definition.field_name == "name" else form.get(f"list_{definition.id}") == "on", readonly=definition.readonly, sort_order=order))
    try:
        db.commit()
    except IntegrityError as exc:
        db.rollback()
        raise HTTPException(400, "Kategorie konnte nicht gespeichert werden. Der Kategoriename ist möglicherweise bereits vorhanden.") from exc
    return RedirectResponse("/categories", status_code=303)


@app.get("/categories/{category_id}/edit")
def category_edit(category_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first()
    if not category:
        raise HTTPException(404, "Kategorie nicht gefunden")
    selected_fields = {f.field_name: f for f in category.visible_fields}
    definitions=db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all()
    return templates.TemplateResponse("category_form.html", {"request": request, "category": category, "definitions": definitions, "selected_fields": selected_fields, "standard_images": list_standard_images()})


@app.post("/categories/{category_id}/edit")
async def category_update(category_id: int, request: Request, name: str = Form(...), description: str = Form(""), image: UploadFile | None = File(None), db: Session = Depends(get_db)):
    _require_admin(request)
    category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first()
    if not category:
        raise HTTPException(404, "Kategorie nicht gefunden")
    form = await request.form()
    category.name = name.strip()
    category.description = description.strip() or None
    remove_image_requested = str(form.get("remove_image") or "").strip().lower() in {"1", "true", "on", "yes"}
    new_image = choose_form_image(form, image)
    if new_image:
        old_image = category.image_path
        category.image_path = new_image
        # Category images can be shared after duplicating a category. Replacing
        # the association must therefore never delete the old physical file.
    elif remove_image_requested and category.image_path:
        old_image = category.image_path
        category.image_path = None
        # Only remove the category association. Shared/orphaned uploads are
        # deliberately retained for a later maintenance cleanup.
    # Explicitly delete and flush existing definitions. This prevents
    # unique-constraint violations when identical field names are inserted afterwards.
    db.query(CategoryField).filter(CategoryField.category_id == category.id).delete(synchronize_session=False)
    db.flush()
    definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all()
    for order, definition in enumerate(definitions):
        active = form.get(f"active_{definition.id}") == "on" or definition.field_name == "name"
        db.add(CategoryField(
            category_id=category.id, field_name=definition.field_name, label=definition.label, field_definition_id=definition.id, active=active,
            required=True if definition.field_name == "name" else form.get(f"required_{definition.id}") == "on",
            show_in_list=True if definition.field_name == "name" else form.get(f"list_{definition.id}") == "on",
            readonly=definition.readonly, sort_order=order,
        ))
    try:
        db.commit()
    except IntegrityError as exc:
        db.rollback()
        raise HTTPException(400, "Kategorie konnte nicht gespeichert werden. Der Kategoriename ist möglicherweise bereits vorhanden.") from exc
    return RedirectResponse("/categories", status_code=303)


@app.post("/categories/{category_id}/duplicate")
def category_duplicate(category_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    category = (
        db.query(Category)
        .options(joinedload(Category.visible_fields))
        .filter(Category.id == category_id)
        .first()
    )
    if not category:
        raise HTTPException(404, "Kategorie nicht gefunden")

    base_name = f"{category.name} (Kopie)"
    new_name = base_name
    number = 2
    while db.query(Category.id).filter(Category.name == new_name).first():
        new_name = f"{category.name} (Kopie {number})"
        number += 1

    duplicate = Category(
        name=new_name,
        description=category.description,
        image_path=category.image_path,
    )
    db.add(duplicate)
    db.flush()

    for field in sorted(category.visible_fields, key=lambda item: item.sort_order):
        db.add(CategoryField(
            category_id=duplicate.id,
            field_name=field.field_name,
            label=field.label,
            field_definition_id=field.field_definition_id,
            active=field.active,
            required=field.required,
            show_in_list=field.show_in_list,
            readonly=field.readonly,
            sort_order=field.sort_order,
        ))

    db.commit()
    return RedirectResponse(
        f"/categories/{duplicate.id}/edit?toast_success=Kategorie wurde dupliziert",
        status_code=303,
    )


@app.post("/categories/{category_id}/delete")
def category_delete(category_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    category = db.query(Category).filter(Category.id == category_id).first()
    if not category:
        raise HTTPException(404, "Kategorie nicht gefunden")

    asset_count = db.query(Asset).filter(Asset.category_id == category.id).count()
    if asset_count > 0:
        return RedirectResponse(
            f"/categories?toast_error=Kategorie kann nicht gelöscht werden: Sie wird noch von {asset_count} Asset(s) verwendet",
            status_code=303,
        )

    db.delete(category)
    db.commit()
    return RedirectResponse(
        "/categories?toast_success=Kategorie wurde gelöscht",
        status_code=303,
    )






@app.get("/api/software-callback/health")
@callback_app.get("/api/software-callback/health")
def software_callback_health():
    return {
        "ok": True,
        "service": "assetmanager-software-callback",
        "version": application_version(),
        "time": datetime.utcnow().isoformat() + "Z",
    }


def _software_overview_statistics(db: Session) -> dict[str, int]:
    """Return only the counters required by the software overview.

    Do not load every InstalledSoftware ORM row merely to display three numbers.
    PostgreSQL performs the counting and grouping and returns only scalar values.
    """
    total_entries, initialized_assets = (
        db.query(
            func.count(InstalledSoftware.id),
            func.count(func.distinct(InstalledSoftware.asset_id)),
        )
        .one()
    )

    # Fetch only the three short grouping columns. The final Python casefold
    # intentionally preserves the exact grouping semantics used by the existing
    # detailed summary (Unicode casefold is stronger than SQL LOWER).
    summary_rows = (
        db.query(
            InstalledSoftware.name,
            InstalledSoftware.publisher,
            InstalledSoftware.platform,
        )
        .distinct()
        .all()
    )
    summary_count = len({
        (
            (name or "").strip().casefold(),
            (publisher or "").strip().casefold(),
            (platform or "unknown").strip().casefold(),
        )
        for name, publisher, platform in summary_rows
    })

    return {
        "software_entry_count": int(total_entries or 0),
        "software_summary_count": int(summary_count or 0),
        "initialized_assets": int(initialized_assets or 0),
    }


def _software_group_expressions():
    """Return normalized SQL expressions used by all software groupings."""
    name_key = func.lower(func.trim(func.coalesce(InstalledSoftware.name, "")))
    publisher_key = func.lower(func.trim(func.coalesce(InstalledSoftware.publisher, "")))
    platform_key = func.lower(func.trim(func.coalesce(InstalledSoftware.platform, "unknown")))
    version_key = func.lower(func.trim(func.coalesce(InstalledSoftware.version, "")))
    return name_key, publisher_key, platform_key, version_key


def _software_summary_rows(db: Session, grouping: str) -> list[dict[str, Any]]:
    """Build the aggregated software list inside PostgreSQL.

    The query returns only already aggregated result rows. ``grouping=name_platform``
    combines changing publisher names and all versions by software name and
    platform. ``grouping=software`` combines all versions by name, publisher and
    platform. ``grouping=version`` keeps one row per version.
    """
    name_key, publisher_key, platform_key, version_key = _software_group_expressions()

    common_columns = (
        name_key.label("name_key"),
        publisher_key.label("publisher_key"),
        platform_key.label("platform_key"),
        func.min(func.coalesce(InstalledSoftware.name, "")).label("name"),
        func.min(func.coalesce(InstalledSoftware.publisher, "")).label("publisher"),
        func.min(func.coalesce(InstalledSoftware.platform, "unknown")).label("platform"),
    )

    if grouping == "name_platform":
        publishers_array = func.array_agg(
            distinct(func.nullif(func.trim(func.coalesce(InstalledSoftware.publisher, "")), ""))
        )
        versions_array = func.array_agg(
            distinct(func.nullif(func.trim(func.coalesce(InstalledSoftware.version, "")), ""))
        )
        query_rows = (
            db.query(
                name_key.label("name_key"),
                platform_key.label("platform_key"),
                func.min(func.coalesce(InstalledSoftware.name, "")).label("name"),
                func.min(func.coalesce(InstalledSoftware.platform, "unknown")).label("platform"),
                publishers_array.label("publishers"),
                versions_array.label("versions"),
                func.count(func.distinct(InstalledSoftware.asset_id)).label("asset_count"),
                func.count(InstalledSoftware.id).label("entries"),
                func.max(InstalledSoftware.last_seen_at).label("last_seen_at"),
            )
            .group_by(name_key, platform_key)
            .order_by(platform_key, name_key)
            .all()
        )

        result: list[dict[str, Any]] = []
        for row in query_rows:
            publishers = sorted(
                {str(value).strip() for value in (row.publishers or []) if value and str(value).strip()},
                key=str.casefold,
            )
            versions = sorted(
                {str(value).strip() for value in (row.versions or []) if value and str(value).strip()},
                key=str.casefold,
            )
            result.append({
                "name": row.name or "",
                "publisher": ", ".join(publishers),
                "platform": row.platform or "unknown",
                "versions": ", ".join(versions),
                "version_count": len(versions),
                "asset_count": int(row.asset_count or 0),
                "entries": int(row.entries or 0),
                "last_seen_at": row.last_seen_at,
            })
        return result

    if grouping == "version":
        query_rows = (
            db.query(
                *common_columns,
                version_key.label("version_key"),
                func.min(func.coalesce(InstalledSoftware.version, "")).label("version"),
                func.count(func.distinct(InstalledSoftware.asset_id)).label("asset_count"),
                func.count(InstalledSoftware.id).label("entries"),
                func.max(InstalledSoftware.last_seen_at).label("last_seen_at"),
            )
            .group_by(name_key, publisher_key, platform_key, version_key)
            .order_by(platform_key, name_key, publisher_key, version_key)
            .all()
        )
        return [
            {
                "name": row.name or "",
                "publisher": row.publisher or "",
                "platform": row.platform or "unknown",
                "versions": row.version or "",
                "version_count": 1 if (row.version or "").strip() else 0,
                "asset_count": int(row.asset_count or 0),
                "entries": int(row.entries or 0),
                "last_seen_at": row.last_seen_at,
            }
            for row in query_rows
        ]

    # PostgreSQL performs the three-column grouping and returns the distinct
    # version values as a compact array. Assets are counted distinctly across
    # all versions, so the value remains correct even if an asset contains more
    # than one version record for the same software.
    versions_array = func.array_agg(
        distinct(func.nullif(func.trim(func.coalesce(InstalledSoftware.version, "")), ""))
    )
    query_rows = (
        db.query(
            *common_columns,
            versions_array.label("versions"),
            func.count(func.distinct(InstalledSoftware.asset_id)).label("asset_count"),
            func.count(InstalledSoftware.id).label("entries"),
            func.max(InstalledSoftware.last_seen_at).label("last_seen_at"),
        )
        .group_by(name_key, publisher_key, platform_key)
        .order_by(platform_key, name_key, publisher_key)
        .all()
    )

    result: list[dict[str, Any]] = []
    for row in query_rows:
        versions = sorted(
            {str(value).strip() for value in (row.versions or []) if value and str(value).strip()},
            key=str.casefold,
        )
        result.append({
            "name": row.name or "",
            "publisher": row.publisher or "",
            "platform": row.platform or "unknown",
            "versions": ", ".join(versions),
            "version_count": len(versions),
            "asset_count": int(row.asset_count or 0),
            "entries": int(row.entries or 0),
            "last_seen_at": row.last_seen_at,
        })
    return result


def _software_installation_rows(db: Session):
    """Return only columns rendered by the detailed installation table."""
    return (
        db.query(
            InstalledSoftware.id.label("id"),
            InstalledSoftware.asset_id.label("asset_id"),
            Asset.name.label("asset_name"),
            InstalledSoftware.name.label("name"),
            InstalledSoftware.version.label("version"),
            InstalledSoftware.publisher.label("publisher"),
            InstalledSoftware.architecture.label("architecture"),
            InstalledSoftware.platform.label("platform"),
            InstalledSoftware.install_date.label("install_date"),
            InstalledSoftware.last_seen_at.label("last_seen_at"),
        )
        .join(Asset, InstalledSoftware.asset_id == Asset.id)
        .order_by(
            InstalledSoftware.name.asc(),
            Asset.name.asc(),
            InstalledSoftware.version.asc(),
            InstalledSoftware.id.asc(),
        )
        .all()
    )



_SOFTWARE_JOB_FILTER_KEYS = (
    "job_id",
    "created_at",
    "asset",
    "job_type",
    "package",
    "action",
    "priority",
    "platform",
    "status",
)


def _software_job_filter_values(request: Request) -> dict[str, str]:
    """Read and normalize the server-side filters of the recent-job table."""
    return {
        key: str(request.query_params.get(f"job_filter_{key}") or "").strip()[:200]
        for key in _SOFTWARE_JOB_FILTER_KEYS
    }


def _normalize_filter_text(value: Any) -> str:
    """Normalize text while preserving natural German vowel combinations."""
    import re
    import unicodedata

    text = str(value or "").casefold()
    protected = (
        text.replace("ä", "\ue000")
        .replace("ö", "\ue001")
        .replace("ü", "\ue002")
        .replace("ß", "\ue003")
    )
    text = "".join(
        character
        for character in unicodedata.normalize("NFKD", protected)
        if not unicodedata.combining(character)
    )
    text = (
        text.replace("\ue000", "ä")
        .replace("\ue001", "ö")
        .replace("\ue002", "ü")
        .replace("\ue003", "ß")
    )

    # Mark only plausible ASCII umlaut spellings. This prevents natural
    # combinations in words such as "Bauer", "teuer" and "euer" from being
    # treated as an umlaut while keeping Mueller/Müller and Hyaene/Hyäne equal.
    text = re.sub(r"(^|[^aeiouäöü])([aou])e", r"\1\2~e", text)
    return (
        text.replace("ä", "a~e")
        .replace("ö", "o~e")
        .replace("ü", "u~e")
        .replace("ß", "s~s")
    )


def _sql_normalized_filter_text(column: Any):
    """Return the PostgreSQL equivalent of :func:`_normalize_filter_text`."""
    expression = func.lower(func.coalesce(cast(column, String), ""))
    expression = func.regexp_replace(
        expression,
        r"(^|[^aeiouäöü])([aou])e",
        r"\1\2~e",
        "g",
    )
    for source, target in (("ä", "a~e"), ("ö", "o~e"), ("ü", "u~e"), ("ß", "s~s")):
        expression = func.replace(expression, source, target)
    return expression


def _sql_text_contains(column: Any, value: str):
    """Literal substring search with conservative German umlaut equivalence."""
    escaped = (
        _normalize_filter_text(value)
        .replace("\\", "\\\\")
        .replace("%", "\\%")
        .replace("_", "\\_")
    )
    return _sql_normalized_filter_text(column).like(
        f"%{escaped}%",
        escape="\\",
    )


def _translated_filter_codes(
    request: Request,
    prefix: str,
    codes: tuple[str, ...],
    search_value: str,
) -> list[str]:
    """Map translated, partially entered labels back to their internal codes.

    The translation dictionaries are cached on the request so one filter
    request does not open a database session for every possible status value.
    """
    needle = _normalize_filter_text(str(search_value or "").strip())
    if not needle:
        return []

    cache = getattr(request.state, "software_job_filter_translations", None)
    if cache is None:
        user = _session_user(request) or {}
        language_code = (
            user.get("language_code")
            or request.session.get("language_code")
            or load_config().get("general", {}).get("default_language", "en")
        )
        translation_db = SessionLocal()
        try:
            cache = {
                "own": translation_dictionary(translation_db, language_code),
                "en": translation_dictionary(translation_db, "en"),
            }
        finally:
            translation_db.close()
        request.state.software_job_filter_translations = cache

    matches: list[str] = []
    for code in codes:
        key = f"{prefix}.{code}"
        label = cache["own"].get(key) or cache["en"].get(key) or code
        if needle in _normalize_filter_text(code) or needle in _normalize_filter_text(label):
            matches.append(code)
    return matches


def _software_job_date_filter(column: Any, search_value: str):
    """Support ISO/database text and the common German date formats."""
    value = str(search_value or "").strip()
    conditions = [_sql_text_contains(column, value)]
    for date_format in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
        try:
            parsed = datetime.strptime(value, date_format)
        except ValueError:
            continue
        conditions.append(and_(column >= parsed, column < parsed + timedelta(days=1)))
        break
    return or_(*conditions)


def _apply_software_job_filters(query: Any, request: Request, filters: dict[str, str]):
    """Apply all recent-job filters before LIMIT is evaluated.

    This keeps an old job searchable even when the visible result size is only
    25 rows.  The large script, result and log columns are still excluded from
    the final SELECT by ``load_only`` below.
    """
    parameters_text = cast(SoftwareJob.parameters, String)

    if filters["job_id"]:
        value = filters["job_id"]
        if value.isdigit():
            query = query.filter(SoftwareJob.id == int(value))
        else:
            query = query.filter(_sql_text_contains(SoftwareJob.id, value))

    if filters["created_at"]:
        query = query.filter(
            _software_job_date_filter(SoftwareJob.created_at, filters["created_at"])
        )

    if filters["asset"]:
        query = query.filter(_sql_text_contains(Asset.name, filters["asset"]))

    if filters["job_type"]:
        value = filters["job_type"]
        conditions = [
            _sql_text_contains(SoftwareJob.job_type, value),
            _sql_text_contains(parameters_text, value),
        ]
        job_type_codes = _translated_filter_codes(
            request,
            "jobs.type",
            ("software_inventory", "job_definition"),
            value,
        )
        if job_type_codes:
            conditions.append(SoftwareJob.job_type.in_(job_type_codes))
        job_kind_codes = _translated_filter_codes(
            request,
            "jobdefs.kind",
            ("script", "registry"),
            value,
        )
        for kind in job_kind_codes:
            conditions.append(
                and_(
                    SoftwareJob.job_type == "job_definition",
                    _sql_text_contains(parameters_text, kind),
                )
            )
        query = query.filter(or_(*conditions))

    if filters["package"]:
        value = filters["package"]
        query = query.filter(
            or_(
                _sql_text_contains(SoftwarePackage.name, value),
                _sql_text_contains(parameters_text, value),
            )
        )

    if filters["action"]:
        value = filters["action"]
        conditions = [_sql_text_contains(SoftwareJob.action, value)]
        action_codes = _translated_filter_codes(
            request,
            "jobs.action",
            ("run", "install", "uninstall", "update"),
            value,
        )
        if action_codes:
            conditions.append(SoftwareJob.action.in_(action_codes))
        query = query.filter(or_(*conditions))

    if filters["priority"]:
        value = filters["priority"]
        if re.fullmatch(r"[-+]?\d+", value):
            query = query.filter(SoftwareJob.priority == int(value))
        else:
            query = query.filter(_sql_text_contains(SoftwareJob.priority, value))

    if filters["platform"]:
        query = query.filter(
            _sql_text_contains(SoftwareJob.platform, filters["platform"])
        )

    if filters["status"]:
        value = filters["status"]
        conditions = [_sql_text_contains(SoftwareJob.status, value)]
        status_codes = _translated_filter_codes(
            request,
            "software.status",
            (
                "created",
                "sending",
                "sent",
                "running",
                "success",
                "partial",
                "failed",
                "timeout",
                "cancelled",
            ),
            value,
        )
        if status_codes:
            conditions.append(SoftwareJob.status.in_(status_codes))
        query = query.filter(or_(*conditions))

    return query


@app.get("/software")
def software_overview(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    page_started = time.perf_counter()

    timeout_started = time.perf_counter()
    _expire_stale_software_jobs(db)
    timeout_seconds = time.perf_counter() - timeout_started

    allowed_job_limits = (25, 50, 100, 500, 1000)
    raw_limit = request.query_params.get("job_limit")
    if raw_limit is None:
        raw_limit = request.session.get("software_job_limit", 25)
    try:
        requested_limit = int(raw_limit)
    except (TypeError, ValueError):
        requested_limit = 25
    job_limit = requested_limit if requested_limit in allowed_job_limits else 25
    request.session["software_job_limit"] = job_limit

    stats_started = time.perf_counter()
    statistics = _software_overview_statistics(db)
    stats_seconds = time.perf_counter() - stats_started

    jobs_started = time.perf_counter()
    job_filters = _software_job_filter_values(request)
    filters_active = any(job_filters.values())

    jobs_query = (
        db.query(SoftwareJob)
        .join(Asset, Asset.id == SoftwareJob.asset_id)
        .outerjoin(SoftwarePackage, SoftwarePackage.id == SoftwareJob.package_id)
    )
    jobs_query = _apply_software_job_filters(jobs_query, request, job_filters)

    # Count after filtering but before LIMIT. This value is only needed while a
    # filter is active and therefore adds no overhead to the normal overview.
    jobs_filtered_total = (
        int(jobs_query.with_entities(func.count(SoftwareJob.id)).scalar() or 0)
        if filters_active
        else 0
    )

    jobs = (
        jobs_query
        .options(
            load_only(
                SoftwareJob.id,
                SoftwareJob.asset_id,
                SoftwareJob.package_id,
                SoftwareJob.status,
                SoftwareJob.job_type,
                SoftwareJob.action,
                SoftwareJob.priority,
                SoftwareJob.platform,
                SoftwareJob.parameters,
                SoftwareJob.created_at,
            ),
            contains_eager(SoftwareJob.asset).load_only(Asset.id, Asset.name, Asset.mesh_node_id),
            contains_eager(SoftwareJob.package).load_only(SoftwarePackage.id, SoftwarePackage.name),
        )
        .order_by(SoftwareJob.created_at.desc(), SoftwareJob.id.desc())
        .limit(job_limit)
        .all()
    )
    _decorate_jobs_for_display(db, jobs)
    jobs_seconds = time.perf_counter() - jobs_started

    response = templates.TemplateResponse(
        "software.html",
        {
            "request": request,
            "jobs": jobs,
            "job_limit": job_limit,
            "job_limit_options": allowed_job_limits,
            "job_filters": job_filters,
            "jobs_filters_active": filters_active,
            "jobs_filtered_total": jobs_filtered_total,
            **statistics,
        },
    )
    total_seconds = time.perf_counter() - page_started
    if total_seconds >= 0.5:
        logger.info(
            "Software overview timing: total=%.3fs timeout=%.3fs statistics=%.3fs jobs=%.3fs rows=%s",
            total_seconds,
            timeout_seconds,
            stats_seconds,
            jobs_seconds,
            len(jobs),
        )
    return response


@app.get("/software/job-states")
def software_job_states_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    _expire_stale_software_jobs(db)
    rows = (
        db.query(AssetJobState, Asset, JobDefinition)
        .join(Asset, Asset.id == AssetJobState.asset_id)
        .outerjoin(JobDefinition, JobDefinition.id == AssetJobState.job_definition_id)
        .order_by(AssetJobState.updated_at.desc(), func.lower(Asset.name), AssetJobState.state_key)
        .all()
    )
    job_states: list[dict[str, Any]] = []
    for state, asset, definition in rows:
        if state.job_type == "job_definition":
            job_label = definition.name if definition is not None else state.state_key
            job_type_key = "jobs.type.job_definition"
            job_type_fallback = "Jobdefinition"
        elif state.job_type == "software_inventory":
            job_label = _translate_request(request, "jobs.type.software_inventory", "Software inventory")
            job_type_key = "jobs.type.software_inventory"
            job_type_fallback = "Software inventory"
        else:
            job_label = state.state_key
            job_type_key = f"jobs.type.{state.job_type}"
            job_type_fallback = state.job_type
        job_states.append({
            "state": state,
            "asset": asset,
            "job_label": job_label,
            "job_type_key": job_type_key,
            "job_type_fallback": job_type_fallback,
            "last_execution": state.last_started_at or state.last_sent_at or state.last_status_at or state.last_created_at,
        })
    return templates.TemplateResponse(
        "software_job_states.html",
        {
            "request": request,
            "job_states": job_states,
        },
    )


@app.post("/software/inventory/delete")
async def delete_software_inventory_entries(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    redirect_to = str(form.get("redirect_to") or "/software/installations")
    if not redirect_to.startswith("/") or redirect_to.startswith("//"):
        redirect_to = "/software/installations"
    entry_ids = []
    for value in form.getlist("entry_ids"):
        try:
            entry_ids.append(int(value))
        except (TypeError, ValueError):
            continue
    entry_ids = list(dict.fromkeys(entry_ids))
    if not entry_ids:
        return RedirectResponse(redirect_to + ("&" if "?" in redirect_to else "?") + "toast_error=" + quote(_translate_request(request, "software.delete.none_selected", "No software inventory entries were selected.")), status_code=303)
    deleted = db.query(InstalledSoftware).filter(InstalledSoftware.id.in_(entry_ids)).delete(synchronize_session=False)
    db.commit()
    message = _translate_request(request, "software.delete.result", "Deleted {count} software inventory entries.").format(count=deleted)
    return RedirectResponse(redirect_to + ("&" if "?" in redirect_to else "?") + "toast_success=" + quote(message), status_code=303)


@app.get("/software/installations")
def software_installations_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    started = time.perf_counter()
    software_entries = _software_installation_rows(db)
    query_seconds = time.perf_counter() - started
    response = templates.TemplateResponse(
        "software_installations.html",
        {
            "request": request,
            "software_entries": software_entries,
        },
    )
    total_seconds = time.perf_counter() - started
    if total_seconds >= 0.5:
        logger.info(
            "Software installations timing: total=%.3fs query=%.3fs rows=%s",
            total_seconds,
            query_seconds,
            len(software_entries),
        )
    return response


@app.get("/software/summary")
def software_summary_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    started = time.perf_counter()
    allowed_groupings = {"name_platform", "software", "version"}
    grouping = str(
        request.query_params.get("grouping")
        or request.session.get("software_summary_grouping")
        or "software"
    ).strip().lower()
    if grouping not in allowed_groupings:
        grouping = "software"
    request.session["software_summary_grouping"] = grouping

    software_summary = _software_summary_rows(db, grouping)
    query_seconds = time.perf_counter() - started
    response = templates.TemplateResponse(
        "software_summary.html",
        {
            "request": request,
            "software_summary": software_summary,
            "grouping": grouping,
        },
    )
    total_seconds = time.perf_counter() - started
    if total_seconds >= 0.5:
        logger.info(
            "Software summary timing: total=%.3fs query=%.3fs rows=%s grouping=%s",
            total_seconds,
            query_seconds,
            len(software_summary),
            grouping,
        )
    return response


@app.get("/assets/{asset_id}/software")
def asset_software(asset_id: int, request: Request, db: Session = Depends(get_db)):
    asset = _get_visible_asset(db, request, asset_id)
    software = db.query(InstalledSoftware).filter(InstalledSoftware.asset_id == asset.id).order_by(InstalledSoftware.name, InstalledSoftware.version).all()
    jobs = db.query(SoftwareJob).options(joinedload(SoftwareJob.package)).filter(SoftwareJob.asset_id == asset.id).order_by(SoftwareJob.created_at.desc()).limit(50).all()
    _decorate_jobs_for_display(db, jobs)
    return templates.TemplateResponse("asset_software.html", {"request": request, "asset": asset, "software": software, "jobs": jobs, "is_admin": _is_admin(request)})



@app.post("/assets/{asset_id}/job-parameters/{definition_id}")
async def asset_job_parameter_overrides(asset_id:int, definition_id:int, request:Request, db:Session=Depends(get_db)):
    _require_admin(request)
    asset=_get_visible_asset(db,request,asset_id); definition=db.get(JobDefinition,definition_id)
    if not definition: raise HTTPException(404)
    form=await request.form(); valid_ids={x.id:x for x in definition.parameters}
    existing={x.definition_parameter_id:x for x in db.query(JobDefinitionAssetParameter).filter(JobDefinitionAssetParameter.asset_id==asset.id, JobDefinitionAssetParameter.definition_parameter_id.in_(list(valid_ids) or [-1])).all()}
    for parameter_id,parameter in valid_ids.items():
        use_override=str(form.get(f"override_{parameter_id}") or "") == "on"
        row=existing.get(parameter_id)
        if use_override:
            if row is None:
                row=JobDefinitionAssetParameter(asset_id=asset.id,definition_parameter_id=parameter_id);db.add(row)
            row.value_text=str(form.get(f"value_{parameter_id}") or "");row.updated_by=_changed_by(request)
        elif row is not None:
            db.delete(row)
    db.commit()
    return RedirectResponse(f"/assets/{asset.id}?toast_success="+quote(_translate_request(request,"jobparams.saved","Asset-specific job parameters were saved.")),303)

def _job_parameter_dict(job: SoftwareJob) -> dict[str, Any]:
    return job.parameters if isinstance(job.parameters, dict) else {}


def _decorate_jobs_for_display(db: Session, jobs: list[SoftwareJob]) -> None:
    """Attach user-facing job metadata without changing technical job fields.

    Job definitions intentionally use the technical database values
    ``job_type=job_definition`` and the shared system package
    ``Jobdefinition``.  Those values are required by the dispatcher but are
    not meaningful in the UI.  The selected definition and its real kind are
    therefore exposed as transient display attributes.
    """
    definition_ids: set[int] = set()
    definition_revisions: set[int] = set()
    for job in jobs:
        if job.job_type != "job_definition":
            continue
        parameters = _job_parameter_dict(job)
        try:
            definition_ids.add(int(parameters.get("definition_id")))
        except (TypeError, ValueError):
            continue
        try:
            definition_revisions.add(int(parameters.get("definition_revision")))
        except (TypeError, ValueError):
            pass

    definitions_by_id: dict[int, JobDefinition] = {}
    revision_snapshots: dict[tuple[int, int], dict[str, Any]] = {}
    if definition_ids:
        definitions_by_id = {
            definition.id: definition
            for definition in (
                db.query(JobDefinition)
                .filter(JobDefinition.id.in_(definition_ids))
                .all()
            )
        }
        if definition_revisions:
            revision_snapshots = {
                (row.definition_id, row.revision): (row.snapshot if isinstance(row.snapshot, dict) else {})
                for row in (
                    db.query(JobDefinitionRevision)
                    .filter(
                        JobDefinitionRevision.definition_id.in_(definition_ids),
                        JobDefinitionRevision.revision.in_(definition_revisions),
                    )
                    .all()
                )
            }

    for job in jobs:
        parameters = _job_parameter_dict(job)
        package_name = job.package.name if job.package else ""
        job.display_job_type_key = f"jobs.type.{job.job_type}"
        job.display_job_type_fallback = job.job_type
        job.display_package_name = package_name
        job.display_definition_revision = None
        job.display_creation_mode = str(parameters.get("_creation_mode") or "single")
        job.display_bulk_batch_id = str(parameters.get("_bulk_batch_id") or "")

        if job.job_type != "job_definition":
            continue

        definition = None
        definition_id = None
        definition_revision = None
        try:
            definition_id = int(parameters.get("definition_id"))
            definition = definitions_by_id.get(definition_id)
        except (TypeError, ValueError):
            pass
        try:
            definition_revision = int(parameters.get("definition_revision"))
        except (TypeError, ValueError):
            pass
        revision_snapshot = (
            revision_snapshots.get((definition_id, definition_revision), {})
            if definition_id is not None and definition_revision is not None
            else {}
        )
        definition_name = str(
            parameters.get("definition_name")
            or revision_snapshot.get("name")
            or ""
        ).strip()
        if not definition_name and definition is not None:
            definition_name = definition.name
        definition_kind = str(
            parameters.get("definition_job_kind")
            or revision_snapshot.get("job_kind")
            or ""
        ).strip().lower()
        if not definition_kind and definition is not None:
            definition_kind = str(definition.job_kind or "script").strip().lower()
        if definition_kind not in {"script", "registry"}:
            definition_kind = "script"

        job.display_job_type_key = f"jobdefs.kind.{definition_kind}"
        job.display_job_type_fallback = definition_kind
        job.display_package_name = definition_name or package_name
        job.display_definition_revision = (
            parameters.get("definition_revision")
            if parameters.get("definition_revision") is not None
            else (definition.revision if definition is not None else None)
        )


def _job_creation_metadata(
    *,
    creation_mode: str = "single",
    bulk_batch_id: str | None = None,
    bulk_position: int | None = None,
    bulk_total: int | None = None,
) -> dict[str, Any]:
    metadata: dict[str, Any] = {"_creation_mode": "bulk" if creation_mode == "bulk" else "single"}
    if bulk_batch_id:
        metadata["_bulk_batch_id"] = bulk_batch_id
    if bulk_position is not None:
        metadata["_bulk_position"] = int(bulk_position)
    if bulk_total is not None:
        metadata["_bulk_total"] = int(bulk_total)
    return metadata


def _create_definition_job(
    db: Session,
    request: Request,
    asset: Asset,
    definition: JobDefinition,
    *,
    creation_mode: str = "single",
    bulk_batch_id: str | None = None,
    bulk_position: int | None = None,
    bulk_total: int | None = None,
) -> SoftwareJob:
    if not asset.mesh_node_id:
        raise ValueError("missing_node_id")
    platform = detect_platform(asset)
    if platform == "unknown":
        raise ValueError("unknown_platform")
    if definition.platform not in {platform, "all"}:
        raise ValueError("platform_mismatch")
    if not definition.enabled:
        raise ValueError("definition_disabled")
    package = _seed_generic_job_package(db)
    token = secrets.token_urlsafe(32)
    job = SoftwareJob(
        asset_id=asset.id, package_id=package.id, status="created",
        job_type="job_definition", action="run", priority=100, platform=platform,
        parameters={
            "definition_id": definition.id,
            "definition_name": definition.name,
            "definition_revision": definition.revision,
            "definition_job_kind": definition.job_kind,
            "definition_interpreter": definition.interpreter,
            "definition_source_type": definition.source_type,
            "effective_parameters": _job_effective_parameters(db, definition, asset),
            **_job_creation_metadata(
                creation_mode=creation_mode,
                bulk_batch_id=bulk_batch_id,
                bulk_position=bulk_position,
                bulk_total=bulk_total,
            ),
        },
        result_data={}, attempt_count=0, max_attempts=1,
        callback_token_hash=token_hash(token),
        callback_expires_at=datetime.utcnow() + timedelta(seconds=max(60, min(int(definition.timeout_seconds or 300) + 120, 86400))),
        created_by=_changed_by(request),
    )
    db.add(job)
    db.flush()
    sync_asset_job_state(db, job, increment_execution=True)
    _record_job_event(
        db,
        job,
        "created",
        "created",
        _translate_request(
            request,
            "jobs.event.created_named",
            'Job "{name}" created',
            name=definition.name,
        ),
        _changed_by(request),
    )
    db.commit()
    db.refresh(job)
    configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/")
    callback_base = configured or str(request.base_url).rstrip("/")
    job_parameters = dict(job.parameters or {})
    job_parameters["_callback_base_url"] = callback_base
    job.parameters = job_parameters
    db.commit()
    threading.Thread(target=execute_job, args=(job.id, token, callback_base), daemon=True).start()
    return job


@app.post("/assets/{asset_id}/jobs/run")
def asset_run_job_definition(asset_id: int, request: Request, definition_id: int = Form(...), db: Session = Depends(get_db)):
    _require_admin(request)
    asset = _get_visible_asset(db, request, asset_id)
    definition = db.get(JobDefinition, definition_id)
    if not definition or not definition.enabled:
        return RedirectResponse(f"/assets/{asset.id}?toast_error=" + quote(_translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available.")), status_code=303)
    try:
        job = _create_definition_job(db, request, asset, definition)
    except ValueError as exc:
        key = str(exc)
        messages = {
            "missing_node_id": _translate_request(request, "jobs.asset_missing_node", "The asset has no MeshCentral node ID."),
            "unknown_platform": _translate_request(request, "jobs.asset_unknown_platform", "The operating system platform could not be detected."),
            "platform_mismatch": _translate_request(request, "jobs.platform_mismatch", "The job definition does not match the asset platform."),
            "definition_disabled": _translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available."),
        }
        return RedirectResponse(f"/assets/{asset.id}?toast_error=" + quote(messages.get(key, key)), status_code=303)
    return RedirectResponse(f"/software/jobs/{job.id}?toast_success=" + quote(_translate_request(request, "jobs.started", "Job started.")), status_code=303)


def _create_software_inventory_job(
    db: Session,
    request: Request,
    asset: Asset,
    *,
    creation_mode: str = "single",
    bulk_batch_id: str | None = None,
    bulk_position: int | None = None,
    bulk_total: int | None = None,
) -> SoftwareJob:
    if not asset.mesh_node_id:
        raise ValueError("missing_node_id")
    platform = detect_platform(asset)
    if platform == "android":
        raise ValueError("unsupported_android")
    if platform == "unknown":
        raise ValueError("unknown_platform")
    package = _seed_software_inventory_package(db)
    token = secrets.token_urlsafe(32)
    job = SoftwareJob(
        asset_id=asset.id, package_id=package.id, status="created",
        job_type="software_inventory", action="run", priority=100, platform=platform,
        parameters=_job_creation_metadata(
            creation_mode=creation_mode,
            bulk_batch_id=bulk_batch_id,
            bulk_position=bulk_position,
            bulk_total=bulk_total,
        ),
        result_data={}, attempt_count=0, max_attempts=1,
        callback_token_hash=token_hash(token),
        callback_expires_at=datetime.utcnow() + timedelta(
            minutes=max(5, min(int(_software_settings().get("callback_timeout_minutes", package.callback_timeout_minutes or 30) or 30), 240))
        ),
        created_by=_changed_by(request),
    )
    db.add(job)
    db.flush()
    sync_asset_job_state(db, job, increment_execution=True)
    _record_job_event(db, job, "created", "created", _translate_request(request, "jobs.event.created", "Job created"), _changed_by(request))
    db.commit()
    db.refresh(job)
    configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/")
    callback_base = configured or str(request.base_url).rstrip("/")
    job_parameters = dict(job.parameters or {})
    job_parameters["_callback_base_url"] = callback_base
    job.parameters = job_parameters
    db.commit()
    threading.Thread(target=execute_job, args=(job.id, token, callback_base), daemon=True).start()
    return job


@app.post("/assets/{asset_id}/software-inventory")
def asset_software_inventory(
    asset_id: int,
    request: Request,
    return_to: str = Form("software-page"),
    db: Session = Depends(get_db),
):
    _require_admin(request)
    asset = _get_visible_asset(db, request, asset_id)
    try:
        _create_software_inventory_job(db, request, asset)
    except ValueError as exc:
        messages = {
            "missing_node_id": "Das Asset besitzt keine MeshCentral Node-ID.",
            "unsupported_android": "Der MeshCentral-Android-Agent stellt derzeit keinen vergleichbaren RunCommand-Kanal für diese Inventur bereit.",
            "unknown_platform": "Das Betriebssystem konnte nicht sicher erkannt werden.",
        }
        raise HTTPException(400, messages.get(str(exc), "Die Softwareinventur konnte nicht gestartet werden."))
    target = (
        f"/assets/{asset.id}?inventory_tab=software"
        if return_to == "asset-detail"
        else f"/assets/{asset.id}/software"
    )
    return RedirectResponse(
        target + f"&toast_success={quote(_translate_request(request, 'software.inventory_queued', 'Software inventory was submitted to MeshCentral.'))}"
        if "?" in target
        else target + f"?toast_success={quote(_translate_request(request, 'software.inventory_queued', 'Software inventory was submitted to MeshCentral.'))}",
        status_code=303,
    )


@app.post("/jobs/bulk-create")
async def bulk_create_jobs(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    job_type = str(form.get("job_type") or "").strip()
    definition_id_raw = str(form.get("definition_id") or "").strip()
    definition = None
    if definition_id_raw:
        try:
            definition = db.get(JobDefinition, int(definition_id_raw))
        except (TypeError, ValueError):
            definition = None
        if not definition or not definition.enabled:
            return RedirectResponse("/assets?toast_error=" + quote(_translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available.")), status_code=303)
    elif job_type != "software_inventory":
        return RedirectResponse("/assets?toast_error=" + quote(_translate_request(request, "jobs.bulk.invalid_type", "The selected job type is not available.")), status_code=303)

    asset_ids = []
    for value in form.getlist("asset_ids"):
        try: asset_ids.append(int(value))
        except (TypeError, ValueError): continue
    asset_ids = list(dict.fromkeys(asset_ids))
    if not asset_ids:
        return RedirectResponse("/assets?toast_error=" + quote(_translate_request(request, "jobs.bulk.none_selected", "No assets were selected.")), status_code=303)

    visible_assets = _apply_asset_access(db.query(Asset), request).filter(Asset.id.in_(asset_ids)).all()
    visible_by_id = {asset.id: asset for asset in visible_assets}
    created = 0
    skipped = 0
    bulk_batch_id = uuid.uuid4().hex
    bulk_total = len(asset_ids)
    for bulk_position, asset_id in enumerate(asset_ids, start=1):
        asset = visible_by_id.get(asset_id)
        if not asset:
            skipped += 1
            continue
        try:
            if definition is not None:
                _create_definition_job(
                    db,
                    request,
                    asset,
                    definition,
                    creation_mode="bulk",
                    bulk_batch_id=bulk_batch_id,
                    bulk_position=bulk_position,
                    bulk_total=bulk_total,
                )
            else:
                _create_software_inventory_job(
                    db,
                    request,
                    asset,
                    creation_mode="bulk",
                    bulk_batch_id=bulk_batch_id,
                    bulk_position=bulk_position,
                    bulk_total=bulk_total,
                )
            created += 1
        except ValueError:
            skipped += 1

    if created:
        message = _translate_request(request, "jobs.bulk.result", "Created {created} jobs; skipped {skipped} assets.").format(created=created, skipped=skipped)
        return RedirectResponse("/assets?toast_success=" + quote(message), status_code=303)
    message = _translate_request(request, "jobs.bulk.none_created", "No job could be created; skipped {skipped} assets.").format(skipped=skipped)
    return RedirectResponse("/assets?toast_error=" + quote(message), status_code=303)


@app.post("/assets/job-filter/start")
async def start_job_for_filtered_assets(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    category_id = _optional_form_int(form.get("category_id"), field_name="category_id", minimum=1)
    job_filter = _normalize_asset_job_filter(
        db,
        selection=form.getlist("job_filter"),
        excluded_statuses=form.getlist("job_filter_exclude"),
        revision_mode=str(form.get("job_filter_revision") or "all"),
    )
    if not job_filter:
        return RedirectResponse(
            "/assets?toast_error=" + quote(_translate_request(request, "assets.job_filter.invalid", "The job filter is invalid.")),
            status_code=303,
        )

    start_selection = str(form.get("job_filter_start") or "").strip()
    if not start_selection:
        start_selection = job_filter["selections"][0]
    if start_selection not in job_filter["selections"]:
        return RedirectResponse(
            _asset_job_filter_url(
                job_filter,
                category_id,
                toast_error=_translate_request(request, "assets.job_filter.start_invalid", "Select one of the filtered jobs to run."),
            ),
            status_code=303,
        )
    start_spec = next((spec for spec in job_filter["specs"] if spec["selection"] == start_selection), None)
    if start_spec is None:
        return RedirectResponse(
            _asset_job_filter_url(
                job_filter,
                category_id,
                toast_error=_translate_request(request, "assets.job_filter.start_invalid", "Select one of the filtered jobs to run."),
            ),
            status_code=303,
        )
    definition = start_spec.get("definition")
    if definition is not None and not definition.enabled:
        return RedirectResponse(
            _asset_job_filter_url(
                job_filter,
                category_id,
                toast_error=_translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available."),
            ),
            status_code=303,
        )

    query = _apply_asset_access(db.query(Asset), request)
    if category_id:
        query = query.filter(Asset.category_id == category_id)
    candidate_assets = query.order_by(func.lower(Asset.name), Asset.id).all()
    assets, _views = _filter_assets_by_job_states(db, candidate_assets, job_filter)
    if not assets:
        return RedirectResponse(
            _asset_job_filter_url(
                job_filter,
                category_id,
                toast_error=_translate_request(request, "assets.job_filter.no_assets", "No assets match this job filter."),
            ),
            status_code=303,
        )

    created = 0
    skipped = 0
    bulk_batch_id = uuid.uuid4().hex
    bulk_total = len(assets)
    for bulk_position, asset in enumerate(assets, start=1):
        try:
            if definition is not None:
                _create_definition_job(
                    db,
                    request,
                    asset,
                    definition,
                    creation_mode="bulk",
                    bulk_batch_id=bulk_batch_id,
                    bulk_position=bulk_position,
                    bulk_total=bulk_total,
                )
            else:
                _create_software_inventory_job(
                    db,
                    request,
                    asset,
                    creation_mode="bulk",
                    bulk_batch_id=bulk_batch_id,
                    bulk_position=bulk_position,
                    bulk_total=bulk_total,
                )
            created += 1
        except ValueError:
            skipped += 1

    if created:
        message = _translate_request(
            request,
            "assets.job_filter.started",
            "Created {created} jobs for the filtered assets; skipped {skipped} assets.",
            created=created,
            skipped=skipped,
        )
        return RedirectResponse(
            _asset_job_filter_url(job_filter, category_id, toast_success=message),
            status_code=303,
        )
    message = _translate_request(
        request,
        "jobs.bulk.none_created",
        "No job could be created; skipped {skipped} assets.",
        skipped=skipped,
    )
    return RedirectResponse(
        _asset_job_filter_url(job_filter, category_id, toast_error=message),
        status_code=303,
    )


def _validate_software_callback_token(job_id: int, token: str) -> bool:
    """Validate a callback outside the ASGI event loop.

    Returns True when the same valid token belongs to a job that was already
    completed. Callback delivery is intentionally idempotent so a client may
    safely retry after a lost HTTP response.
    """
    db = SessionLocal()
    try:
        job = db.get(SoftwareJob, job_id)
        if not job:
            raise HTTPException(404, "Job nicht gefunden")
        if not token or not hmac.compare_digest(token_hash(token), job.callback_token_hash):
            raise HTTPException(401, "Ungültiger Callback-Token")
        if datetime.utcnow() > job.callback_expires_at:
            raise HTTPException(410, "Callback-Token ist abgelaufen")
        return bool(job.callback_completed_at)
    finally:
        db.close()


def _decode_software_callback_body(raw_body: bytes) -> tuple[str, str, list[str]]:
    decoded_body = ""
    detected_encoding = "utf-8"
    decode_errors: list[str] = []
    candidates: list[tuple[str, bytes]] = []
    if raw_body.startswith(b"\xef\xbb\xbf"):
        candidates.append(("utf-8-sig", raw_body))
    elif raw_body.startswith(b"\xff\xfe"):
        candidates.append(("utf-16-le", raw_body[2:]))
    elif raw_body.startswith(b"\xfe\xff"):
        candidates.append(("utf-16-be", raw_body[2:]))
    candidates.extend([
        ("utf-8", raw_body),
        ("utf-8-sig", raw_body),
        ("utf-16-le", raw_body),
        ("utf-16-be", raw_body),
    ])

    seen_encodings: set[str] = set()
    for encoding, value in candidates:
        if encoding in seen_encodings:
            continue
        seen_encodings.add(encoding)
        try:
            candidate = value.decode(encoding)
            if candidate.lstrip().startswith(("{", "[")):
                decoded_body = candidate
                detected_encoding = encoding
                break
        except UnicodeDecodeError as exc:
            decode_errors.append(f"{encoding}: {exc}")

    if not decoded_body:
        decoded_body = raw_body.decode("utf-8", errors="replace")
        detected_encoding = "utf-8-replacement"
    return decoded_body, detected_encoding, decode_errors


def _sanitize_callback_value(value: Any) -> Any:
    """Remove PostgreSQL-incompatible NUL characters from callback values.

    Windows registry and uninstall metadata can occasionally contain embedded
    NUL characters. PostgreSQL text and JSON values reject those characters,
    so callback payloads are sanitized before they are written to the database.
    Other characters and the original data structure are preserved.
    """
    if isinstance(value, str):
        return value.replace("\x00", "")
    if isinstance(value, list):
        return [_sanitize_callback_value(item) for item in value]
    if isinstance(value, tuple):
        return tuple(_sanitize_callback_value(item) for item in value)
    if isinstance(value, dict):
        return {
            _sanitize_callback_value(key) if isinstance(key, str) else key: _sanitize_callback_value(item)
            for key, item in value.items()
        }
    return value


def _count_nul_characters(value: Any) -> int:
    """Count embedded NUL characters recursively for diagnostics only."""
    if isinstance(value, str):
        return value.count("\x00")
    if isinstance(value, (list, tuple)):
        return sum(_count_nul_characters(item) for item in value)
    if isinstance(value, dict):
        return sum(_count_nul_characters(key) + _count_nul_characters(item) for key, item in value.items())
    return 0


def _process_software_job_callback(
    job_id: int,
    token: str,
    raw_body: bytes,
    content_type: str,
    client_host: str,
    queued_at: float,
) -> dict[str, Any]:
    """Process one callback in the dedicated callback worker pool.

    Every worker creates its own SQLAlchemy session. The job row is locked so
    duplicate callbacks cannot import the same result concurrently. Software
    inventory replacement additionally locks the asset row, serializing only
    callbacks that target the same asset while callbacks for other assets run
    in parallel.
    """
    started_at = time.monotonic()
    _software_debug_log(
        f"CALLBACK worker started | job={job_id} | client={client_host} | "
        f"queue_wait_ms={(started_at - queued_at) * 1000:.1f} | workers={SOFTWARE_CALLBACK_WORKER_COUNT}"
    )
    db = SessionLocal()
    try:
        job = (
            db.query(SoftwareJob)
            .filter(SoftwareJob.id == job_id)
            .with_for_update()
            .first()
        )
        if not job:
            raise HTTPException(404, "Job nicht gefunden")
        if not token or not hmac.compare_digest(token_hash(token), job.callback_token_hash):
            raise HTTPException(401, "Ungültiger Callback-Token")
        if datetime.utcnow() > job.callback_expires_at:
            raise HTTPException(410, "Callback-Token ist abgelaufen")
        if job.callback_completed_at:
            _software_debug_log(f"CALLBACK duplicate accepted | job={job.id} | status={job.status}")
            db.rollback()
            return {"ok": True, "job_id": job.id, "status": job.status, "duplicate": True}

        decoded_body, detected_encoding, decode_errors = _decode_software_callback_body(raw_body)
        content_length = len(raw_body)
        _software_debug_log(
            f"CALLBACK raw body | job={job_id} | encoding={detected_encoding} | BEGIN\n"
            + decoded_body
            + f"\nCALLBACK raw body | job={job_id} | END"
        )

        try:
            payload = json.loads(decoded_body)
        except Exception as exc:
            failure_log = (
                f"JSON parsing failed: {type(exc).__name__}: {exc}\n"
                f"Detected encoding: {detected_encoding}\n"
                f"Content-Type: {content_type}\n"
                f"Body bytes: {content_length}\n"
                f"\n--- Complete callback body ---\n{decoded_body}"
            )
            job.log_text = ((job.log_text or "") + "\n\n--- Callback parsing error ---\n" + failure_log)[-2_000_000:]
            job.message = "Callback empfangen, aber JSON konnte nicht verarbeitet werden."
            db.commit()
            _software_debug_log(
                f"CALLBACK JSON ERROR | job={job_id} | {type(exc).__name__}: {exc}"
                + (f" | decode_errors={'; '.join(decode_errors)}" if decode_errors else "")
            )
            raise HTTPException(400, "Ungültiges JSON") from exc

        if not isinstance(payload, dict):
            job.log_text = (
                (job.log_text or "")
                + f"\n\n--- Callback parsing error ---\nJSON root type: {type(payload).__name__}\n"
                + f"\n--- Complete callback body ---\n{decoded_body}"
            )[-2_000_000:]
            job.message = "Callback empfangen, aber kein JSON-Objekt erhalten."
            db.commit()
            _software_debug_log(f"CALLBACK JSON TYPE ERROR | job={job_id} | root={type(payload).__name__}")
            raise HTTPException(400, "JSON-Objekt erwartet")

        nul_character_count = _count_nul_characters(payload)
        if nul_character_count:
            payload = _sanitize_callback_value(payload)
            _software_debug_log(
                f"CALLBACK sanitized PostgreSQL-incompatible NUL characters | "
                f"job={job_id} | removed={nul_character_count}"
            )

        _software_debug_log(
            f"CALLBACK JSON parsed | job={job_id} | keys={','.join(sorted(str(key) for key in payload.keys()))}"
        )
        log_parts: list[str] = []
        for label, key in (("Log", "log"), ("STDOUT", "stdout"), ("STDERR", "stderr")):
            value = payload.get(key)
            if value is None or value == "":
                continue
            text_value = str(value)
            if len(text_value) > 200000:
                raise HTTPException(413, f"{label} ist zu groß")
            log_parts.append(f"--- {label} ---\n{text_value}")

        status = str(payload.get("status") or "running").lower()
        job.status = status if status in {"running", "success", "failed", "partial"} else "running"
        if job.started_at is None:
            job.started_at = datetime.utcnow()
        try:
            job.exit_code = int(payload.get("exit_code")) if payload.get("exit_code") is not None else job.exit_code
        except (TypeError, ValueError):
            pass
        if payload.get("message") is not None:
            job.message = str(payload.get("message"))[:4000]

        summary_lines = [
            f"Callback received: {datetime.utcnow().isoformat()}Z",
            f"Status: {job.status}",
            f"Exit code: {job.exit_code if job.exit_code is not None else '-'}",
        ]
        if job.message:
            summary_lines.append(f"Message: {job.message}")
        callback_log = "\n".join(summary_lines + (["", *log_parts] if log_parts else []))
        job.log_text = ((job.log_text or "") + "\n\n--- Client callback ---\n" + callback_log).strip()[-2_000_000:]
        job.result_data = {
            str(key): value
            for key, value in payload.items()
            if key not in {"software", "log"}
        }

        software = payload.get("software")
        stored_software_count = 0
        excluded_software_count = 0
        if isinstance(software, list):
            _software_debug_log(f"CALLBACK software processing started | job={job.id} | received={len(software)}")
            # Serialize inventory replacement only per asset. Other assets are
            # still processed concurrently by the callback worker pool.
            db.query(Asset.id).filter(Asset.id == job.asset_id).with_for_update().first()
            now = datetime.utcnow()
            removed_count = (
                db.query(InstalledSoftware)
                .filter(InstalledSoftware.asset_id == job.asset_id)
                .delete(synchronize_session=False)
            )
            _software_debug_log(f"CALLBACK previous software removed | job={job.id} | rows={removed_count}")
            seen: set[tuple[str, str, str, str]] = set()
            inventory_rows: list[dict[str, Any]] = []
            inventory_platform = (job.platform or "unknown")[:40].lower()
            exclusion_rules = _software_settings().get("inventory_exclusion_rules", [])
            for item in software[:50000]:
                if not isinstance(item, dict):
                    continue
                name = str(item.get("name") or "").strip()
                if not name:
                    continue
                if _software_name_is_excluded(name, inventory_platform, exclusion_rules):
                    excluded_software_count += 1
                    continue
                values = (
                    name[:300],
                    str(item.get("version") or "")[:160],
                    str(item.get("publisher") or "")[:220],
                    str(item.get("architecture") or "")[:40],
                )
                identity = tuple(value.casefold() for value in values)
                if identity in seen:
                    continue
                seen.add(identity)
                inventory_rows.append({
                    "asset_id": job.asset_id,
                    "name": values[0],
                    "version": values[1],
                    "publisher": values[2],
                    "architecture": values[3],
                    "platform": inventory_platform,
                    "install_date": str(item.get("install_date") or "")[:40] or None,
                    "source": "meshcentral-inventory",
                    "first_seen_at": now,
                    "last_seen_at": now,
                })
            if inventory_rows:
                db.bulk_insert_mappings(InstalledSoftware, inventory_rows)
            stored_software_count = len(inventory_rows)
            _software_debug_log(
                f"CALLBACK software filtered | job={job.id} | received={len(software)} | "
                f"excluded={excluded_software_count} | stored={stored_software_count} | "
                f"rules={len(exclusion_rules)}"
            )
            if isinstance(job.result_data, dict):
                job.result_data["software_inventory"] = {
                    "received": len(software),
                    "excluded": excluded_software_count,
                    "stored": stored_software_count,
                }

        if bool(payload.get("completed")) or job.status in {"success", "failed"}:
            job.callback_completed_at = datetime.utcnow()
            job.finished_at = datetime.utcnow()
        language_code = str(load_config().get("general", {}).get("default_language", "de") or "de")
        event_message = job.message or translate(
            db,
            language_code,
            "jobs.event.callback",
            "Callback received",
        )
        _record_job_event(db, job, "callback", job.status, event_message)
        sync_asset_job_state(db, job)
        db.commit()
        processing_ms = (time.monotonic() - started_at) * 1000
        _software_debug_log(
            f"CALLBACK database commit successful | job={job.id} | stored_software={stored_software_count} | excluded_software={excluded_software_count} | processing_ms={processing_ms:.1f}"
        )
        _software_debug_log(
            f"CALLBACK accepted | job={job.id} | status={job.status} | completed={bool(job.callback_completed_at)} | "
            f"software_entries={len(software) if isinstance(software, list) else 0}"
        )
        return {"ok": True, "job_id": job.id, "status": job.status}
    except HTTPException:
        db.rollback()
        raise
    except Exception:
        db.rollback()
        logger.exception("Software callback worker failed for job %s", job_id)
        raise
    finally:
        db.close()


@app.post("/api/software-jobs/{job_id}/callback")
@callback_app.post("/api/software-jobs/{job_id}/callback")
async def software_job_callback(job_id: int, request: Request, token: str = ""):
    client_host = request.client.host if request.client else "-"
    content_type = request.headers.get("content-type", "-")
    _software_debug_log(
        f"CALLBACK received | job={job_id} | client={client_host} | content_type={content_type} | "
        f"workers={SOFTWARE_CALLBACK_WORKER_COUNT}"
    )

    # The short authentication query runs outside the ASGI event loop. It is
    # repeated under a row lock inside the worker to close race conditions.
    already_completed = await asyncio.to_thread(_validate_software_callback_token, job_id, token)
    if already_completed:
        _software_debug_log(f"CALLBACK duplicate accepted before body read | job={job_id} | client={client_host}")
        return {"ok": True, "job_id": job_id, "duplicate": True}
    try:
        raw_body = await request.body()
    except ClientDisconnect:
        _software_debug_log(
            f"CALLBACK client disconnected while uploading body | job={job_id} | client={client_host}"
        )
        return Response(status_code=499)

    _software_debug_log(f"CALLBACK body received | job={job_id} | bytes={len(raw_body)}")
    queued_at = time.monotonic()
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(
        SOFTWARE_CALLBACK_EXECUTOR,
        _process_software_job_callback,
        job_id,
        token,
        raw_body,
        content_type,
        client_host,
        queued_at,
    )


@app.get("/api/software-jobs/{job_id}")
def software_job_status(job_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    _expire_stale_software_jobs(db, job_id)
    job = db.get(SoftwareJob, job_id)
    if not job:
        raise HTTPException(404, "Job not found")
    return {
        "id": job.id,
        "job_type": job.job_type,
        "action": job.action,
        "priority": job.priority,
        "status": job.status,
        "status_label": _translate_request(request, f"software.status.{job.status}", job.status),
        "message": job.message or "",
        "exit_code": job.exit_code,
        "attempt_count": job.attempt_count,
        "max_attempts": job.max_attempts,
        "finished_at": _utc_iso(job.finished_at),
        "log_text": job.log_text or "",
    }


def _prepare_existing_job_restart(
    db: Session,
    request: Request | None,
    job: SoftwareJob,
    *,
    automatic: bool = False,
) -> str:
    """Reset an existing job for another attempt and return its callback token.

    Manual and automatic retries use this same tested reset path. The caller
    commits the transaction and starts ``execute_job`` afterwards.
    """
    token = secrets.token_urlsafe(32)
    package_timeout = getattr(job.package, "callback_timeout_minutes", None) or 30
    timeout_minutes = max(
        5,
        min(
            int(_software_settings().get("callback_timeout_minutes", package_timeout) or 30),
            240,
        ),
    )
    job.callback_token_hash = token_hash(token)
    job.callback_expires_at = datetime.utcnow() + timedelta(minutes=timeout_minutes)
    job.callback_completed_at = None
    job.status = "created"
    job.sent_at = None
    job.started_at = None
    job.finished_at = None
    job.exit_code = None
    job.message = None
    job.result_data = {}
    job.meshctrl_stdout = None
    job.meshctrl_stderr = None
    job.command_preview = None
    job.log_text = ""
    job.scheduled_at = None
    job.max_attempts = max(int(job.max_attempts or 1), int(job.attempt_count or 0) + 1)
    if automatic:
        language_code = str(load_config().get("general", {}).get("default_language", "de") or "de")
        event_type = "automatic_retry_requested"
        event_message = translate(
            db,
            language_code,
            "jobs.event.automatic_retry_requested",
            "Automatic retry requested",
        )
        created_by = "system:auto-retry"
    else:
        event_type = "restart_requested"
        event_message = _translate_request(request, "jobs.event.restart_requested", "Manual restart requested")
        created_by = _changed_by(request)
    _record_job_event(
        db,
        job,
        event_type,
        "created",
        event_message,
        created_by,
    )
    sync_asset_job_state(db, job, increment_execution=True)
    return token


@app.post("/api/software-jobs/statuses")
def software_job_statuses(
    request: Request,
    job_ids: list[int] = Body(...),
    db: Session = Depends(get_db),
):
    """Return compact live status data for the visible software-job rows."""
    _require_admin(request)
    normalized_ids: list[int] = []
    seen_ids: set[int] = set()
    for value in job_ids[:1000]:
        try:
            job_id = int(value)
        except (TypeError, ValueError):
            continue
        if job_id <= 0 or job_id in seen_ids:
            continue
        seen_ids.add(job_id)
        normalized_ids.append(job_id)
    if not normalized_ids:
        return {"jobs": []}

    _expire_stale_software_jobs(db)
    rows = (
        db.query(SoftwareJob.id, SoftwareJob.status)
        .filter(SoftwareJob.id.in_(normalized_ids))
        .all()
    )
    user = _session_user(request) or {}
    language_code = (
        user.get("language_code")
        or request.session.get("language_code")
        or load_config().get("general", {}).get("default_language", "en")
    )
    status_labels: dict[str, str] = {}
    result = []
    for job_id, status in rows:
        normalized_status = str(status or "created")
        if normalized_status not in status_labels:
            status_labels[normalized_status] = translate(
                db,
                language_code,
                f"software.status.{normalized_status}",
                normalized_status,
            )
        result.append({
            "id": int(job_id),
            "status": normalized_status,
            "status_label": status_labels[normalized_status],
        })
    return {"jobs": result}


@app.post("/jobs/{job_id}/restart")
def restart_job(job_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    job = db.query(SoftwareJob).options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package)).filter(SoftwareJob.id == job_id).first()
    if not job:
        raise HTTPException(404, _translate_request(request, "jobs.not_found", "Job not found"))
    _expire_stale_software_jobs(db, job.id)
    db.refresh(job)
    if job.status in {"sending", "running"}:
        return RedirectResponse(
            f"/jobs/{job.id}?toast_error=" + quote(_translate_request(request, "jobs.restart.active", "An active job cannot be restarted.")),
            status_code=303,
        )
    if not job.asset or not job.asset.mesh_node_id:
        return RedirectResponse(
            f"/jobs/{job.id}?toast_error=" + quote(_translate_request(request, "jobs.restart.no_node", "The asset has no MeshCentral node ID.")),
            status_code=303,
        )

    token = _prepare_existing_job_restart(db, request, job)
    db.commit()

    configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/")
    callback_base = configured or str(request.base_url).rstrip("/")
    threading.Thread(target=execute_job, args=(job.id, token, callback_base), daemon=True).start()
    return RedirectResponse(
        f"/jobs/{job.id}?toast_success=" + quote(_translate_request(request, "jobs.restart.started", "Job was queued again.")),
        status_code=303,
    )


@app.post("/jobs/restart-selected")
async def restart_selected_jobs(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    job_ids: list[int] = []
    for value in form.getlist("job_ids"):
        try:
            job_ids.append(int(value))
        except (TypeError, ValueError):
            continue
    job_ids = list(dict.fromkeys(job_ids))
    if not job_ids:
        return RedirectResponse(
            "/software?toast_error=" + quote(_translate_request(request, "jobs.bulk_restart.none_selected", "No jobs were selected.")),
            status_code=303,
        )

    _expire_stale_software_jobs(db)
    jobs = (
        db.query(SoftwareJob)
        .options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package))
        .filter(SoftwareJob.id.in_(job_ids))
        .all()
    )
    by_id = {job.id: job for job in jobs}
    queued: list[tuple[int, str]] = []
    skipped = 0
    for job_id in job_ids:
        job = by_id.get(job_id)
        if (
            job is None
            or job.status in {"success", "sending", "running"}
            or not job.asset
            or not job.asset.mesh_node_id
        ):
            skipped += 1
            continue
        token = _prepare_existing_job_restart(db, request, job)
        queued.append((job.id, token))

    if not queued:
        db.rollback()
        return RedirectResponse(
            "/software?toast_error=" + quote(_translate_request(request, "jobs.bulk_restart.none_eligible", "None of the selected jobs can be restarted.")),
            status_code=303,
        )

    db.commit()
    configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/")
    callback_base = configured or str(request.base_url).rstrip("/")
    for queued_job_id, token in queued:
        threading.Thread(target=execute_job, args=(queued_job_id, token, callback_base), daemon=True).start()

    message = _translate_request(
        request,
        "jobs.bulk_restart.started",
        "{count} jobs were queued again; {skipped} were skipped.",
        count=len(queued),
        skipped=skipped,
    )
    return RedirectResponse(
        "/software?toast_success=" + quote(message),
        status_code=303,
    )


@app.get("/jobs/{job_id}")
def job_detail(job_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    job = db.query(SoftwareJob).options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package)).filter(SoftwareJob.id == job_id).first()
    if not job:
        raise HTTPException(404, _translate_request(request, "jobs.not_found", "Job not found"))
    _expire_stale_software_jobs(db, job.id)
    db.refresh(job)
    _decorate_jobs_for_display(db, [job])
    events = db.query(JobEvent).filter(JobEvent.job_id == job.id).order_by(JobEvent.created_at.desc(), JobEvent.id.desc()).all()
    can_restart = job.status not in {"sending", "running"}
    return templates.TemplateResponse("software_job.html", {"request": request, "job": job, "events": events, "can_restart": can_restart})


@app.get("/software/jobs/{job_id}")
def software_job_detail_legacy(job_id: int, request: Request):
    _require_admin(request)
    return RedirectResponse(f"/jobs/{job_id}", status_code=307)


@app.get("/login")
def login_page(request: Request):
    if _session_user(request):
        return RedirectResponse("/profile", status_code=303)
    mode = load_config().get("authentication", {}).get("mode", "none")
    return templates.TemplateResponse("login.html", {"request": request, "mode": mode})


@app.post("/login")
def login_submit(request: Request, username: str = Form(...), password: str = Form(...), db: Session = Depends(get_db)):
    username = username.strip()
    auth_config = load_config().get("authentication", {})
    mode = str(auth_config.get("mode", "none")).lower()
    user = db.query(User).filter(func.lower(User.username) == username.casefold()).first()
    valid = False

    # A protected local account is authenticated only against its local password,
    # even in LDAP mode. If the password is incorrect, there is deliberately
    # no LDAP fallback for the same username.
    protected_local = bool(
        user
        and getattr(user, "is_protected", False)
        and user.auth_source == "local"
    )
    if protected_local and mode in {"local", "ldap"}:
        valid = bool(user.is_active and _verify_password(password, user.password_hash))
    elif mode == "local":
        valid = bool(user and user.is_active and user.auth_source == "local" and _verify_password(password, user.password_hash))
    elif mode == "ldap":
        ldap_user = _ldap_authenticate(username, password, auth_config)
        if ldap_user:
            if not user:
                user = User(username=username, auth_source="ldap", is_active=True)
                db.add(user)
            if user.is_active:
                user.auth_source = "ldap"
                user.display_name = ldap_user.get("display_name") or username
                user.email = ldap_user.get("email") or None
                valid = True
    elif mode == "none":
        return RedirectResponse("/?toast_warning=Die Anmeldung ist derzeit deaktiviert", status_code=303)
    if not valid or not user:
        logger.warning("Fehlgeschlagene Anmeldung für Benutzer %s im Modus %s", username, mode)
        return RedirectResponse("/login?toast_error=Benutzername oder Passwort ist ungültig", status_code=303)
    user.last_login = datetime.utcnow()
    db.commit()
    db.refresh(user)
    _login_user(request, user)
    return RedirectResponse("/?toast_success=Anmeldung erfolgreich", status_code=303)


@app.get("/logout")
def logout(request: Request):
    request.session.clear()
    return RedirectResponse("/?toast_success=Sie wurden abgemeldet", status_code=303)


@app.get("/profile")
def profile(request: Request, db: Session = Depends(get_db)):
    session_user = _session_user(request)
    if not session_user:
        return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303)
    user = db.query(User).filter(User.id == session_user.get("id")).first()
    if not user:
        request.session.clear()
        return RedirectResponse("/login?toast_error=Benutzerkonto wurde nicht gefunden", status_code=303)
    return templates.TemplateResponse("profile.html", {"request": request, "user": user, "languages": i18n_languages(db)})


@app.post("/profile/language")
def profile_language(request: Request, language_code: str = Form(...), next_url: str = Form("/profile"), db: Session = Depends(get_db)):
    available = {item.code for item in i18n_languages(db)}
    if language_code not in available:
        raise HTTPException(400, "Unsupported language")
    session_user = _session_user(request)
    if session_user:
        user = db.query(User).filter(User.id == session_user.get("id")).first()
        if user:
            user.language_code = language_code
            db.commit()
            _login_user(request, user)
    request.session["language_code"] = language_code
    safe_next = next_url if next_url.startswith("/") and not next_url.startswith("//") else "/"
    parsed_next = urllib.parse.urlsplit(safe_next)
    query_items = [
        (key, value)
        for key, value in urllib.parse.parse_qsl(parsed_next.query, keep_blank_values=True)
        if key not in {"toast_success", "toast_warning", "toast_error"}
    ]
    query_items.append(("toast_success", "Language changed"))
    redirect_target = urllib.parse.urlunsplit((
        "",
        "",
        parsed_next.path or "/",
        urllib.parse.urlencode(query_items),
        parsed_next.fragment,
    ))
    return RedirectResponse(redirect_target, status_code=303)

@app.post("/profile/browser-state")
def profile_browser_state(
    request: Request,
    persist_browser_state: str | None = Form(None),
    db: Session = Depends(get_db),
):
    session_user = _session_user(request)
    if not session_user:
        return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303)
    user = db.query(User).filter(User.id == session_user.get("id")).first()
    if not user:
        request.session.clear()
        return RedirectResponse("/login?toast_error=Benutzerkonto wurde nicht gefunden", status_code=303)
    user.persist_browser_state = persist_browser_state == "on"
    db.commit()
    _login_user(request, user)
    message = "Browserspeicherung aktiviert" if user.persist_browser_state else "Browserspeicherung deaktiviert"
    return RedirectResponse(f"/profile?toast_success={quote(message)}", status_code=303)


@app.post("/profile/toast-settings")
def profile_toast_settings(
    request: Request,
    toast_position: str = Form("top-right"),
    toast_duration_seconds: int = Form(6),
    db: Session = Depends(get_db),
):
    session_user = _session_user(request)
    if not session_user:
        return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303)
    user = db.query(User).filter(User.id == session_user.get("id")).first()
    if not user:
        request.session.clear()
        return RedirectResponse("/login?toast_error=Benutzerkonto wurde nicht gefunden", status_code=303)
    allowed_positions = {"top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"}
    user.toast_position = toast_position if toast_position in allowed_positions else "top-right"
    user.toast_duration_seconds = max(1, min(int(toast_duration_seconds or 6), 120))
    db.commit()
    _login_user(request, user)
    return RedirectResponse("/profile?toast_success=Toast-Einstellungen gespeichert", status_code=303)


@app.post("/profile/theme")
def profile_theme(
    request: Request,
    theme_mode: str = Form("light"),
    next_url: str = Form("/profile"),
    db: Session = Depends(get_db),
):
    session_user = _session_user(request)
    if not session_user:
        return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303)
    user = db.query(User).filter(User.id == session_user.get("id")).first()
    if not user:
        request.session.clear()
        return RedirectResponse("/login?toast_error=Benutzerkonto wurde nicht gefunden", status_code=303)

    user.theme_mode = theme_mode if theme_mode in {"light", "dark"} else "light"
    db.commit()
    _login_user(request, user)

    safe_next = next_url if next_url.startswith("/") and not next_url.startswith("//") else "/profile"
    message = _translate_request(request, "appearance.theme_saved", "Display mode saved")
    parsed_next = urllib.parse.urlsplit(safe_next)
    query_items = [
        (key, value)
        for key, value in urllib.parse.parse_qsl(parsed_next.query, keep_blank_values=True)
        if key not in {"toast_success", "toast_warning", "toast_error"}
    ]
    query_items.append(("toast_success", message))
    redirect_target = urllib.parse.urlunsplit((
        "",
        "",
        parsed_next.path or "/profile",
        urllib.parse.urlencode(query_items),
        parsed_next.fragment,
    ))
    return RedirectResponse(redirect_target, status_code=303)


@app.post("/profile/password")
def profile_password(request: Request, current_password: str = Form(...), new_password: str = Form(...), db: Session = Depends(get_db)):
    session_user = _session_user(request)
    if not session_user:
        return RedirectResponse("/login", status_code=303)
    user = db.query(User).filter(User.id == session_user.get("id")).first()
    if not user or user.auth_source != "local":
        return RedirectResponse("/profile?toast_warning=Passwortänderungen sind nur für lokale Konten möglich", status_code=303)
    if not _verify_password(current_password, user.password_hash):
        return RedirectResponse("/profile?toast_error=Das aktuelle Passwort ist falsch", status_code=303)
    if len(new_password) < 8:
        return RedirectResponse("/profile?toast_error=Das neue Passwort muss mindestens 8 Zeichen lang sein", status_code=303)
    user.password_hash = _hash_password(new_password)
    db.commit()
    return RedirectResponse("/profile?toast_success=Passwort geändert", status_code=303)


@app.get("/users")
def users_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    users = db.query(User).order_by(User.username).all()
    mesh_groups = [row[0] for row in db.query(Asset.mesh_group).filter(Asset.mesh_group.isnot(None), Asset.mesh_group != "").distinct().order_by(Asset.mesh_group).all()]
    return templates.TemplateResponse("users.html", {"request": request, "users": users, "auth": public_config().get("authentication", {}), "mesh_groups": mesh_groups})


@app.post("/users/new")
def user_create(request: Request, username: str = Form(...), display_name: str = Form(""), email: str = Form(""), password: str = Form(...), is_admin: str | None = Form(None), db: Session = Depends(get_db)):
    _require_admin(request)
    username = username.strip()
    if not username:
        raise HTTPException(400, "Benutzername fehlt")
    if len(password) < 8:
        raise HTTPException(400, "Das Passwort muss mindestens 8 Zeichen lang sein")
    if db.query(User.id).filter(func.lower(User.username) == username.casefold()).first():
        raise HTTPException(400, "Der Benutzername ist bereits vorhanden")
    user = User(username=username, display_name=display_name.strip() or username, email=email.strip() or None, password_hash=_hash_password(password), auth_source="local", is_active=True, is_admin=is_admin == "on", language_code=load_config().get("general", {}).get("default_language", "en"))
    db.add(user)
    try:
        db.commit()
    except IntegrityError as exc:
        db.rollback()
        raise HTTPException(400, "Der Benutzername ist bereits vorhanden") from exc
    return RedirectResponse("/users?toast_success=Benutzer angelegt", status_code=303)


@app.post("/users/{user_id}/update")
def user_update(user_id: int, request: Request, display_name: str = Form(""), email: str = Form(""), profile_department: str = Form(""), profile_location: str = Form(""), is_active: str | None = Form(None), is_admin: str | None = Form(None), new_password: str = Form(""), mesh_groups: list[str] = Form([]), asset_access_scopes: list[str] = Form([]), db: Session = Depends(get_db)):
    _require_admin(request)
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(404, "Benutzer nicht gefunden")
    user.display_name = display_name.strip() or user.username
    user.email = email.strip() or None
    if getattr(user, "is_protected", False):
        user.is_active = True
        user.is_admin = True
        user.auth_source = "local"
    else:
        user.is_active = is_active == "on"
        user.is_admin = is_admin == "on"
    user.allowed_mesh_groups = sorted({value.strip() for value in mesh_groups if value.strip()})
    user.asset_access_scopes = sorted({value for value in asset_access_scopes if value in ASSET_ACCESS_SCOPES})
    user.profile_department = profile_department.strip() or None
    user.profile_location = profile_location.strip() or None
    if new_password:
        if user.auth_source != "local":
            raise HTTPException(400, "LDAP-Passwörter können hier nicht geändert werden")
        if len(new_password) < 8:
            raise HTTPException(400, "Das neue Passwort muss mindestens 8 Zeichen lang sein")
        user.password_hash = _hash_password(new_password)
    db.commit()
    return RedirectResponse("/users?toast_success=Benutzer gespeichert", status_code=303)



@app.post("/users/{user_id}/delete")
def user_delete(user_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    user = db.get(User, user_id)
    if not user:
        raise HTTPException(404, "Benutzer nicht gefunden")
    if getattr(user, "is_protected", False):
        raise HTTPException(400, _translate_request(
            request,
            "users.protected_delete_forbidden",
            "Protected local administrators cannot be deleted.",
        ))
    session_user = _session_user(request) or {}
    if int(session_user.get("id") or 0) == user.id:
        raise HTTPException(400, "Der aktuell angemeldete Benutzer kann nicht gelöscht werden.")
    if user.is_admin and user.is_active:
        other_admins = db.query(User).filter(User.id != user.id, User.is_admin.is_(True), User.is_active.is_(True)).count()
        if other_admins == 0:
            raise HTTPException(400, "Der letzte aktive Administrator kann nicht gelöscht werden.")
    db.delete(user)
    db.commit()
    return RedirectResponse("/users?toast_success=Benutzer gelöscht", status_code=303)


def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]:
    """List AD users through the configured LDAP service account.

    An empty query returns all active user accounts. A search term matches
    usernames, display names, and email addresses.
    """
    from ldap3 import ALL, Connection, Server, SUBTREE
    from ldap3.core.exceptions import LDAPException
    from ldap3.utils.conv import escape_filter_chars

    ldap = auth_config.get("ldap", {})
    server_name = str(ldap.get("server", "")).strip()
    use_ssl = bool(ldap.get("use_ssl", False))
    start_tls = bool(ldap.get("start_tls", False))
    port = int(ldap.get("port") or (636 if use_ssl else 389))
    bind_dn = str(ldap.get("bind_dn", "")).strip()
    env_name = str(ldap.get("bind_password_env", "LDAP_BIND_PASSWORD")).strip()
    bind_password = os.getenv(env_name, "") or str(ldap.get("bind_password", ""))
    base_dn = str(ldap.get("user_base_dn", "")).strip()

    if not server_name:
        raise HTTPException(400, "LDAP-Server ist nicht konfiguriert")
    if not base_dn:
        raise HTTPException(400, "LDAP Benutzer-Basis-DN ist nicht konfiguriert")
    if bind_dn and not bind_password:
        raise HTTPException(400, f"LDAP-Bind-Passwort fehlt (Umgebungsvariable {env_name})")

    raw_query = query_text.strip()

    def ldap_wildcard_pattern(value: str) -> str:
        # Allow user wildcards while safely escaping all other LDAP special characters.
        # * matches any number of characters.
        parts = []
        literal = []
        for char in value:
            if char == "*":
                if literal:
                    parts.append(escape_filter_chars("".join(literal)))
                    literal = []
                parts.append("*")
            else:
                literal.append(char)
        if literal:
            parts.append(escape_filter_chars("".join(literal)))
        pattern = "".join(parts)
        # Without an explicit wildcard, retain the existing substring search.
        if "*" not in value:
            pattern = f"*{pattern}*"
        return pattern

    base_filter = "(&(objectCategory=person)(objectClass=user)(sAMAccountName=*)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))"
    if raw_query:
        q = ldap_wildcard_pattern(raw_query)
        search_filter = (
            "(&(objectCategory=person)(objectClass=user)(sAMAccountName=*)"
            "(!(userAccountControl:1.2.840.113556.1.4.803:=2))"
            f"(|(sAMAccountName={q})(displayName={q})(mail={q})))"
        )
    else:
        search_filter = base_filter

    server = Server(server_name, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=10)
    conn = Connection(
        server,
        user=bind_dn or None,
        password=bind_password or None,
        auto_bind=False,
        receive_timeout=30,
        raise_exceptions=False,
    )
    try:
        # Depending on the version, ldap3.Connection.open() does not return a reliable
        # Boolean result. Open the connection and then bind explicitly.
        if start_tls and not use_ssl:
            conn.open()
            if not conn.start_tls():
                raise HTTPException(400, f"LDAP StartTLS fehlgeschlagen: {conn.last_error}")
        if not conn.bind():
            raise HTTPException(400, f"LDAP Bind fehlgeschlagen: {conn.last_error}")

        attributes = ["sAMAccountName", "displayName", "mail", "distinguishedName"]
        entries = conn.extend.standard.paged_search(
            search_base=base_dn,
            search_filter=search_filter,
            search_scope=SUBTREE,
            attributes=attributes,
            paged_size=500,
            generator=False,
        )
        result = []
        for entry in entries:
            if entry.get("type") != "searchResEntry":
                continue
            attrs = entry.get("attributes", {})
            username = str(attrs.get("sAMAccountName") or "").strip()
            if not username:
                continue
            result.append({
                "username": username,
                "display_name": str(attrs.get("displayName") or username),
                "email": str(attrs.get("mail") or ""),
                "dn": str(entry.get("dn") or attrs.get("distinguishedName") or ""),
            })
        result.sort(key=lambda item: ((item.get("display_name") or item["username"]).casefold(), item["username"].casefold()))
        ldap_logger.info("LDAP directory search completed: query=%r matches=%s", raw_query, len(result))
        if _ldap_debug_enabled(auth_config):
            ldap_logger.debug("LDAP directory search details: filter=%r base_dn=%r", search_filter, base_dn)
        return result
    except HTTPException:
        raise
    except LDAPException as exc:
        ldap_logger.error("LDAP directory search failed because of an LDAP error")
        if _ldap_debug_enabled(auth_config):
            ldap_logger.exception("LDAP exception during directory search: %s", exc)
        raise HTTPException(400, "LDAP query failed") from exc
    except Exception as exc:
        ldap_logger.error("LDAP directory search failed because of an unexpected error")
        if _ldap_debug_enabled(auth_config):
            ldap_logger.exception("Unexpected LDAP directory-search error: %s", exc)
        raise HTTPException(400, "LDAP query failed") from exc
    finally:
        try:
            conn.unbind()
        except Exception:
            pass


def _mark_existing_ldap_users(results: list[dict], db: Session) -> list[dict]:
    usernames = [item["username"].casefold() for item in results]
    existing = {}
    if usernames:
        existing = {
            user.username.casefold(): user
            for user in db.query(User).filter(func.lower(User.username).in_(usernames)).all()
        }
    for item in results:
        user = existing.get(item["username"].casefold())
        item["existing"] = bool(user)
        item["existing_source"] = user.auth_source if user else ""
    return results


@app.get("/settings/ldap-search")
def ldap_search_form(request: Request, show_all: int = 0, db: Session = Depends(get_db)):
    _require_admin(request)
    results = []
    loaded = False
    if show_all:
        cfg = load_config()
        results = _mark_existing_ldap_users(_ldap_search_users("", cfg.get("authentication", {})), db)
        loaded = True
    return templates.TemplateResponse("ldap_search.html", {
        "request": request, "query": "", "results": results, "loaded": loaded,
    })


@app.post("/settings/ldap-search")
def ldap_search_page(request: Request, ldap_query: str = Form(""), db: Session = Depends(get_db)):
    _require_admin(request)
    cfg = load_config()
    results = _mark_existing_ldap_users(_ldap_search_users(ldap_query, cfg.get("authentication", {})), db)
    return templates.TemplateResponse("ldap_search.html", {
        "request": request, "query": ldap_query, "results": results, "loaded": True,
    })


@app.post("/settings/ldap-search/import")
def ldap_users_import(
    request: Request,
    usernames: list[str] = Form([]),
    db: Session = Depends(get_db),
):
    _require_admin(request)
    selected = {value.strip().casefold() for value in usernames if value.strip()}
    if not selected:
        return RedirectResponse("/settings/ldap-search?toast_warning=Keine Benutzer ausgewählt", status_code=303)

    cfg = load_config()
    ldap_users = _ldap_search_users("", cfg.get("authentication", {}))
    ldap_by_name = {item["username"].casefold(): item for item in ldap_users}
    created = 0
    updated = 0
    skipped = 0
    for normalized in selected:
        item = ldap_by_name.get(normalized)
        if not item:
            skipped += 1
            continue
        user = db.query(User).filter(func.lower(User.username) == item["username"].casefold()).first()
        if user:
            # Local accounts are not converted to LDAP accounts automatically.
            if user.auth_source != "ldap":
                skipped += 1
                continue
            user.display_name = item["display_name"] or item["username"]
            user.email = item["email"] or None
            updated += 1
        else:
            db.add(User(
                username=item["username"],
                display_name=item["display_name"] or item["username"],
                email=item["email"] or None,
                password_hash=None,
                auth_source="ldap",
                is_active=True,
                is_admin=False,
                allowed_mesh_groups=[],
                language_code=load_config().get("general", {}).get("default_language", "en"),
            ))
            created += 1
    db.commit()
    return RedirectResponse(
        f"/users?toast_success={created} LDAP-Benutzer angelegt, {updated} aktualisiert, {skipped} übersprungen",
        status_code=303,
    )

@app.get("/settings/translations")
def translations_page(request: Request, missing_only: int = 0, db: Session = Depends(get_db)):
    _require_admin(request)
    langs = i18n_languages(db, active_only=False)
    rows = {}
    for item in db.query(Translation).order_by(Translation.translation_key, Translation.language_code).all():
        rows.setdefault(item.translation_key, {})[item.language_code] = item.text_value
    if missing_only:
        rows = {key: values for key, values in rows.items() if any(not values.get(lang.code) for lang in langs)}
    return templates.TemplateResponse("translations.html", {"request": request, "languages": langs, "rows": rows, "missing_only": bool(missing_only)})

@app.post("/settings/translations/save")
async def translations_save(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    langs = {lang.code for lang in i18n_languages(db, active_only=False)}
    for field, value in form.multi_items():
        if not field.startswith("tr__"):
            continue
        _, code, key = field.split("__", 2)
        if code not in langs:
            continue
        row = db.query(Translation).filter(Translation.translation_key == key, Translation.language_code == code).first()
        if row:
            row.text_value = str(value)
        elif str(value).strip():
            db.add(Translation(translation_key=key, language_code=code, text_value=str(value)))
    db.commit(); clear_translation_cache()
    return RedirectResponse("/settings/translations?toast_success=Translations updated", status_code=303)

@app.get("/settings/translations/export")
def translations_export(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    langs = i18n_languages(db, active_only=False)
    data = {}
    for item in db.query(Translation).all():
        data.setdefault(item.translation_key, {})[item.language_code] = item.text_value
    headers = ["Key"] + [lang.code for lang in langs]
    rows = [[key] + [values.get(lang.code, "") for lang in langs] for key, values in sorted(data.items())]
    return _xlsx_response("assetmanager-translations.xlsx", headers, rows)

@app.post("/settings/translations/import")
async def translations_import(request: Request, file: UploadFile = File(...), ignore_empty: str | None = Form(None), db: Session = Depends(get_db)):
    _require_admin(request)
    payload = await file.read()
    workbook = load_workbook(io.BytesIO(payload), data_only=True)
    sheet = workbook.active
    headers = [str(cell.value or "").strip() for cell in sheet[1]]
    if not headers or headers[0].casefold() != "key":
        raise HTTPException(400, "The first Excel column must be Key")
    known = {lang.code for lang in i18n_languages(db, active_only=False)}
    columns = [(idx, code) for idx, code in enumerate(headers[1:], start=2) if code in known]
    changed = 0
    for values in sheet.iter_rows(min_row=2, values_only=True):
        key = str(values[0] or "").strip()
        if not key:
            continue
        for idx, code in columns:
            value = values[idx-1]
            text_value = "" if value is None else str(value)
            if not text_value and ignore_empty == "on":
                continue
            row = db.query(Translation).filter(Translation.translation_key == key, Translation.language_code == code).first()
            if row:
                row.text_value = text_value
            else:
                db.add(Translation(translation_key=key, language_code=code, text_value=text_value))
            changed += 1
    db.commit(); clear_translation_cache()
    return RedirectResponse(f"/settings/translations?toast_success={changed} translations imported", status_code=303)

@app.get("/settings/appearance")
def settings_appearance_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    categories = db.query(Category).order_by(Category.name.asc()).all()
    return templates.TemplateResponse(
        "settings.html",
        {
            "request": request,
            "settings": public_config(),
            "categories": categories,
            "languages": i18n_languages(db),
            "settings_section": "appearance",
            "system_storage": system_storage_information(),
        },
    )


@app.get("/settings/authentication")
def settings_authentication_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    categories = db.query(Category).order_by(Category.name.asc()).all()
    return templates.TemplateResponse(
        "settings.html",
        {
            "request": request,
            "settings": public_config(),
            "categories": categories,
            "languages": i18n_languages(db),
            "settings_section": "authentication",
            "system_storage": system_storage_information(),
        },
    )


@app.get("/settings/system")
def settings_system_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    categories = db.query(Category).order_by(Category.name.asc()).all()
    return templates.TemplateResponse(
        "settings.html",
        {
            "request": request,
            "settings": public_config(),
            "categories": categories,
            "languages": i18n_languages(db),
            "settings_section": "system",
            "system_storage": system_storage_information(),
        },
    )


@app.get("/settings/import-export")
def settings_import_export_page(request: Request):
    _require_admin(request)
    return templates.TemplateResponse(
        "settings_import_export.html",
        {"request": request},
    )



@app.get("/settings/privacy")
def settings_privacy_page(request: Request):
    config = load_config()
    privacy_settings = localized_privacy_settings(config.get("privacy"), _request_language(request))
    preview_results = request.session.pop("privacy_retention_preview", {})
    return templates.TemplateResponse(
        "settings_privacy.html",
        {
            "request": request,
            "privacy_settings": privacy_settings,
            "privacy_audit": privacy_audit_tail(50),
            "deletion_audit": deletion_audit_tail(50),
            "can_edit": _is_admin(request),
            "implemented_retention_keys": IMPLEMENTED_RETENTION_KEYS,
            "preview_results": preview_results if isinstance(preview_results, dict) else {},
        },
    )


@app.post("/settings/privacy")
async def settings_privacy_save(request: Request):
    _require_admin(request)
    form = await request.form()
    current = merge_privacy_settings(load_config().get("privacy"))
    activity = dict(current.get("processing_activity", {}))
    text_fields = (
        "title", "responsible_department", "responsible_people", "controller",
        "data_protection_contact", "legal_basis", "data_subject_groups",
        "internal_recipients", "external_recipients", "third_country_details",
        "systems", "backup_system", "restore_deletion_procedure",
        "last_reviewed_at", "approved_by", "document_version",
    )
    for field in text_fields:
        activity[field] = str(form.get(field, "") or "").strip()[:8000]
    activity["third_country_transfer"] = form.get("third_country_transfer") == "on"
    activity["backup_retention_days"] = _optional_form_int(
        form.get("backup_retention_days"), field_name="backup_retention_days", minimum=0, maximum=36500
    )
    activity["review_interval_months"] = _optional_form_int(
        form.get("review_interval_months"), field_name="review_interval_months", minimum=1, maximum=120
    ) or 12
    activity["purposes"] = [str(value).strip()[:2000] for value in form.getlist("purposes") if str(value).strip()]
    activity["toms"] = [str(value).strip()[:3000] for value in form.getlist("toms") if str(value).strip()]

    category_keys = form.getlist("category_key")
    categories = []
    for index, key in enumerate(category_keys):
        def value(name: str, default: str = "") -> str:
            values = form.getlist(name)
            return str(values[index] if index < len(values) else default)
        category = normalize_category({
            "key": value("category_key", f"custom_{index + 1}"),
            "name": value("category_name"),
            "system": value("category_system"),
            "data_subjects": value("category_data_subjects"),
            "personal_data": value("category_personal_data"),
            "purpose": value("category_purpose"),
            "retention_type": value("category_retention_type", "manual"),
            "retention_days": value("category_retention_days"),
            "retention_trigger": value("category_retention_trigger"),
            "automatic_possible": value("category_automatic_possible") == "1",
            "manual_procedure": value("category_manual_procedure"),
            "backup_handling": value("category_backup_handling"),
            "responsible": value("category_responsible"),
            "notes": value("category_notes"),
        }, index)
        if category["name"]:
            categories.append(category)

    # System categories are part of executable retention handlers and cannot be removed.
    submitted_keys = {str(item.get("key") or "") for item in categories}
    current_by_key = {str(item.get("key") or ""): item for item in current.get("categories", [])}
    default_by_key = {str(item.get("key") or ""): item for item in merge_privacy_settings(None).get("categories", [])}
    for protected_key in PROTECTED_PRIVACY_CATEGORY_KEYS:
        if protected_key not in submitted_keys:
            restored = current_by_key.get(protected_key) or default_by_key.get(protected_key)
            if restored:
                categories.append(normalize_category(restored, len(categories)))

    updated = {
        "processing_activity": activity,
        "categories": categories,
        "phase": 2,
        "automatic_deletion_available": False,
    }
    save_config({"privacy": updated})
    append_privacy_audit(
        "privacy_policy_updated",
        _changed_by(request) or "admin",
        {"categories": len(categories), "purposes": len(activity["purposes"]), "toms": len(activity["toms"])},
    )
    return RedirectResponse(
        "/settings/privacy?toast_success=" + quote(_translate_request(request, "privacy.saved", "Privacy settings saved.")),
        status_code=303,
    )



def _privacy_category_by_key(category_key: str) -> dict[str, Any]:
    settings = merge_privacy_settings(load_config().get("privacy"))
    for category in settings.get("categories", []):
        if str(category.get("key") or "") == category_key:
            return category
    raise HTTPException(404, "Privacy category not found.")


@app.post("/settings/privacy/retention/{category_key}/check")
def settings_privacy_retention_check(request: Request, category_key: str):
    _require_admin(request)
    category = _privacy_category_by_key(category_key)
    result = check_retention_category(category_key, category.get("retention_days"))
    result["category_name"] = category.get("name") or category_key
    request.session["privacy_retention_preview"] = {category_key: result}
    append_deletion_audit(
        "retention_check",
        _changed_by(request) or "admin",
        category_key=category_key,
        retention_days=category.get("retention_days"),
        result=result,
    )
    message_key = "privacy.check_completed" if result.get("supported") else "privacy.check_not_supported"
    fallback = "Retention check completed." if result.get("supported") else "No executable retention handler is available for this category."
    return RedirectResponse(
        "/settings/privacy?toast_success=" + quote(_translate_request(request, message_key, fallback)) + "#privacy-category-list",
        status_code=303,
    )


@app.post("/settings/privacy/retention/{category_key}/delete")
async def settings_privacy_retention_delete(request: Request, category_key: str):
    _require_admin(request)
    form = await request.form()
    confirmation = str(form.get("confirmation") or "").strip().upper()
    if confirmation != "DELETE":
        return RedirectResponse(
            "/settings/privacy?toast_error=" + quote(_translate_request(request, "privacy.delete_confirmation_invalid", "Enter DELETE to confirm the deletion run.")),
            status_code=303,
        )
    category = _privacy_category_by_key(category_key)
    try:
        result = delete_retention_category(category_key, category.get("retention_days"))
    except ValueError as exc:
        return RedirectResponse(
            "/settings/privacy?toast_error=" + quote(str(exc)),
            status_code=303,
        )
    result["category_name"] = category.get("name") or category_key
    append_deletion_audit(
        "manual_deletion",
        _changed_by(request) or "admin",
        category_key=category_key,
        retention_days=category.get("retention_days"),
        result=result,
    )
    append_privacy_audit(
        "privacy_manual_deletion_executed",
        _changed_by(request) or "admin",
        {
            "category_key": category_key,
            "retention_days": category.get("retention_days"),
            "records": result.get("deleted_or_anonymised_records", 0),
            "status": result.get("status"),
        },
    )
    return RedirectResponse(
        "/settings/privacy?toast_success=" + quote(_translate_request(request, "privacy.delete_completed", "Manual deletion run completed.")) + "#privacy-category-list",
        status_code=303,
    )

def _privacy_report_text(settings: dict[str, Any], language_code: str = "en") -> str:
    activity = settings["processing_activity"]
    de = str(language_code or "").lower().startswith("de")
    labels = {
        "title": "VERZEICHNIS VON VERARBEITUNGSTÄTIGKEITEN / LÖSCHKONZEPT" if de else "RECORD OF PROCESSING ACTIVITIES / RETENTION CONCEPT",
        "document_version": "Dokumentversion" if de else "Document version",
        "activity": "Verarbeitungstätigkeit" if de else "Processing activity",
        "controller": "Verantwortlicher" if de else "Controller",
        "department": "Verantwortlicher Fachbereich" if de else "Responsible department",
        "people": "Verantwortliche Personen" if de else "Responsible people",
        "dpo": "Datenschutzkontakt" if de else "Data protection contact",
        "purposes": "1. ZWECKE" if de else "1. PURPOSES",
        "legal": "2. RECHTSGRUNDLAGE" if de else "2. LEGAL BASIS",
        "subjects": "3. BETROFFENE PERSONENGRUPPEN" if de else "3. DATA SUBJECT GROUPS",
        "recipients": "4. EMPFÄNGER" if de else "4. RECIPIENTS",
        "internal": "Intern" if de else "Internal",
        "external": "Extern" if de else "External",
        "third": "5. DRITTLANDÜBERMITTLUNGEN" if de else "5. THIRD-COUNTRY TRANSFERS",
        "intended": "Übermittlung vorgesehen" if de else "Transfer intended",
        "yes": "Ja" if de else "Yes", "no": "Nein" if de else "No",
        "systems": "6. SYSTEME UND SICHERUNGEN" if de else "6. SYSTEMS AND BACKUPS",
        "system_label": "Systeme" if de else "Systems",
        "backup_system": "Backup-System" if de else "Backup system",
        "backup_retention": "Backup-Aufbewahrung" if de else "Backup retention",
        "restore": "Verfahren nach Wiederherstellung" if de else "Restore procedure",
        "categories": "7. DATENKATEGORIEN UND LÖSCHFRISTEN" if de else "7. DATA CATEGORIES AND RETENTION",
        "system": "System", "data_subjects": "Betroffene Personen" if de else "Data subjects",
        "personal_data": "Personenbezogene Daten" if de else "Personal data",
        "purpose": "Zweck" if de else "Purpose", "retention_type": "Löschart" if de else "Retention type",
        "retention_period": "Aufbewahrungsfrist" if de else "Retention period",
        "trigger": "Fristbeginn/Auslöser" if de else "Trigger",
        "automatic_possible": "Automatische Löschung technisch möglich" if de else "Automatic deletion technically possible",
        "automatic_active": "Automatische Löschung aktiv: Nein; Prüfläufe und manuelle Löschung für ausgewählte Kategorien verfügbar" if de else "Automatic deletion active: No; checks and manual deletion are available for selected categories",
        "manual": "Manuelles Verfahren" if de else "Manual procedure",
        "backup": "Behandlung in Sicherungen" if de else "Backup handling",
        "responsible": "Verantwortlich" if de else "Responsible", "notes": "Hinweise" if de else "Notes",
        "toms": "8. TECHNISCHE UND ORGANISATORISCHE MASSNAHMEN" if de else "8. TECHNICAL AND ORGANISATIONAL MEASURES",
        "review": "9. DOKUMENTENPRÜFUNG" if de else "9. DOCUMENT REVIEW",
        "review_interval": "Prüfintervall" if de else "Review interval",
        "last_reviewed": "Zuletzt geprüft" if de else "Last reviewed",
        "approved": "Freigegeben durch" if de else "Approved by",
        "notice": "HINWEIS" if de else "NOTICE",
        "notice_text": f"Dieser Export dokumentiert konfigurierte Löschregeln. Version {APP_VERSION} führt keine automatische Löschung aus; Prüfläufe und manuell bestätigte Löschläufe sind für ausgewählte Kategorien verfügbar." if de else f"This export documents configured retention rules. Version {APP_VERSION} does not execute automatic deletion; checks and manually confirmed deletion runs are available for selected categories.",
    }
    lines = [
        labels["title"],
        "=" * 64,
        f"{labels['document_version']}: {activity.get('document_version') or '-'}",
        f"{labels['activity']}: {activity.get('title') or '-'}",
        f"{labels['controller']}: {activity.get('controller') or '-'}",
        f"{labels['department']}: {activity.get('responsible_department') or '-'}",
        f"{labels['people']}: {activity.get('responsible_people') or '-'}",
        f"{labels['dpo']}: {activity.get('data_protection_contact') or '-'}",
        "",
        labels["purposes"],
    ]
    lines.extend(f"- {item}" for item in activity.get("purposes", []))
    lines.extend([
        "", labels["legal"], activity.get("legal_basis") or "-",
        "", labels["subjects"], activity.get("data_subject_groups") or "-",
        "", labels["recipients"],
        f"{labels['internal']}: {activity.get('internal_recipients') or '-'}",
        f"{labels['external']}: {activity.get('external_recipients') or '-'}",
        "", labels["third"],
        f"{labels['intended']}: {labels['yes'] if activity.get('third_country_transfer') else labels['no']}",
        activity.get("third_country_details") or "-",
        "", labels["systems"],
        f"{labels['system_label']}: {activity.get('systems') or '-'}",
        f"{labels['backup_system']}: {activity.get('backup_system') or '-'}",
        f"{labels['backup_retention']}: {activity.get('backup_retention_days') if activity.get('backup_retention_days') is not None else '-'} Tage" if de else f"{labels['backup_retention']}: {activity.get('backup_retention_days') if activity.get('backup_retention_days') is not None else '-'} days",
        f"{labels['restore']}: {activity.get('restore_deletion_procedure') or '-'}",
        "", labels["categories"],
    ])
    for number, category in enumerate(settings.get("categories", []), 1):
        lines.extend([
            "", f"7.{number} {category.get('name')}",
            f"{labels['system']}: {category.get('system') or '-'}",
            f"{labels['data_subjects']}: {category.get('data_subjects') or '-'}",
            f"{labels['personal_data']}: {category.get('personal_data') or '-'}",
            f"{labels['purpose']}: {category.get('purpose') or '-'}",
            f"{labels['retention_type']}: {category.get('retention_type') or '-'}",
            f"{labels['retention_period']}: {category.get('retention_days') if category.get('retention_days') is not None else '-'} Tage" if de else f"{labels['retention_period']}: {category.get('retention_days') if category.get('retention_days') is not None else '-'} days",
            f"{labels['trigger']}: {category.get('retention_trigger') or '-'}",
            f"{labels['automatic_possible']}: {labels['yes'] if category.get('automatic_possible') else labels['no']}",
            labels["automatic_active"],
            f"{labels['manual']}: {category.get('manual_procedure') or '-'}",
            f"{labels['backup']}: {category.get('backup_handling') or '-'}",
            f"{labels['responsible']}: {category.get('responsible') or '-'}",
            f"{labels['notes']}: {category.get('notes') or '-'}",
        ])
    lines.extend(["", labels["toms"]])
    lines.extend(f"- {item}" for item in activity.get("toms", []))
    lines.extend([
        "", labels["review"],
        f"{labels['review_interval']}: {activity.get('review_interval_months') or 12} Monate" if de else f"{labels['review_interval']}: {activity.get('review_interval_months') or 12} months",
        f"{labels['last_reviewed']}: {activity.get('last_reviewed_at') or '-'}",
        f"{labels['approved']}: {activity.get('approved_by') or '-'}",
        "", labels["notice"],
        labels["notice_text"],
    ])
    return "\n".join(lines) + "\n"


@app.get("/settings/privacy/export.txt")
def settings_privacy_export_text(request: Request):
    settings = localized_privacy_settings(load_config().get("privacy"), _request_language(request))
    append_privacy_audit("privacy_report_exported", _changed_by(request) or "authenticated-user", {"format": "text"})
    return Response(
        _privacy_report_text(settings, _request_language(request)),
        media_type="text/plain; charset=utf-8",
        headers={"Content-Disposition": "attachment; filename=assetmanager-privacy-processing-record.txt"},
    )


@app.get("/settings/privacy/export.html")
def settings_privacy_export_html(request: Request):
    language_code = _request_language(request)
    settings = localized_privacy_settings(load_config().get("privacy"), language_code)
    rendered = templates.env.get_template("privacy_report_export.html").render(
        settings=settings,
        activity=settings["processing_activity"],
        privacy_audit=list(reversed(privacy_audit_tail(1000))),
        language_code=language_code,
        app_version=APP_VERSION,
        generated_at=datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z"),
    )
    payload = rendered.encode("utf-8")
    append_privacy_audit("privacy_report_exported", _changed_by(request) or "authenticated-user", {"format": "html"})
    return Response(
        content=payload,
        media_type="text/html; charset=utf-8",
        headers={
            "Content-Disposition": "attachment; filename=assetmanager-privacy-processing-record.html",
            "Content-Length": str(len(payload)),
            "X-Content-Type-Options": "nosniff",
            "Cache-Control": "no-store",
        },
    )


def _privacy_excel_value(value: Any) -> Any:
    """Return a safe Excel cell value without control characters or oversized text."""
    if value is None:
        return ""
    if isinstance(value, (bool, int, float, date, datetime)):
        return value
    if isinstance(value, (dict, list)):
        value = json.dumps(value, ensure_ascii=False, sort_keys=True)
    text_value = str(value)
    text_value = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F]", "", text_value)
    return text_value[:32767]


def _style_privacy_workbook(workbook: Workbook) -> None:
    header_fill = PatternFill("solid", fgColor="D9EAF7")
    for sheet in workbook.worksheets:
        sheet.freeze_panes = "A2"
        sheet.auto_filter.ref = sheet.dimensions
        for cell in sheet[1]:
            cell.font = Font(bold=True)
            cell.fill = header_fill
            cell.alignment = Alignment(vertical="top", wrap_text=True)
        for column in sheet.columns:
            width = min(max((len(str(cell.value or "")) for cell in column), default=10) + 2, 60)
            sheet.column_dimensions[column[0].column_letter].width = max(width, 12)
            for cell in column:
                cell.alignment = Alignment(vertical="top", wrap_text=True)


@app.get("/settings/privacy/export.xlsx")
def settings_privacy_export_excel(request: Request):
    language_code = _request_language(request)
    de = str(language_code or "").lower().startswith("de")
    settings = localized_privacy_settings(load_config().get("privacy"), language_code)
    activity = settings["processing_activity"]
    workbook = Workbook()

    overview = workbook.active
    overview.title = "Übersicht" if de else "Overview"
    overview.append(["Feld" if de else "Field", "Wert" if de else "Value"])
    overview_fields = (
        (("Verarbeitungstätigkeit" if de else "Processing activity"), "title"),
        (("Verantwortlicher" if de else "Controller"), "controller"),
        (("Verantwortlicher Fachbereich" if de else "Responsible department"), "responsible_department"),
        (("Verantwortliche Personen" if de else "Responsible people"), "responsible_people"),
        (("Datenschutzkontakt" if de else "Data protection contact"), "data_protection_contact"),
        (("Rechtsgrundlage" if de else "Legal basis"), "legal_basis"),
        (("Betroffene Personengruppen" if de else "Data subject groups"), "data_subject_groups"),
        (("Interne Empfänger" if de else "Internal recipients"), "internal_recipients"),
        (("Externe Empfänger" if de else "External recipients"), "external_recipients"),
        (("Drittlandübermittlung" if de else "Third-country transfer"), "third_country_transfer"),
        (("Details zur Drittlandübermittlung" if de else "Third-country details"), "third_country_details"),
        (("Systeme" if de else "Systems"), "systems"),
        (("Backup-System" if de else "Backup system"), "backup_system"),
        (("Backup-Aufbewahrungstage" if de else "Backup retention days"), "backup_retention_days"),
        (("Löschverfahren nach Wiederherstellung" if de else "Restore deletion procedure"), "restore_deletion_procedure"),
        (("Prüfintervall in Monaten" if de else "Review interval months"), "review_interval_months"),
        (("Zuletzt geprüft" if de else "Last reviewed"), "last_reviewed_at"),
        (("Freigegeben durch" if de else "Approved by"), "approved_by"),
        (("Dokumentversion" if de else "Document version"), "document_version"),
        (("AssetManager-Version" if de else "AssetManager version"), None),
    )
    for label, key in overview_fields:
        value = APP_VERSION if key is None else activity.get(key, "")
        if isinstance(value, bool):
            value = ("Ja" if value else "Nein") if de else ("Yes" if value else "No")
        overview.append([_privacy_excel_value(label), _privacy_excel_value(value)])

    purposes = workbook.create_sheet("Zwecke" if de else "Purposes")
    purposes.append(["Nr." if de else "No.", "Zweck" if de else "Purpose"])
    for index, value in enumerate(activity.get("purposes", []), 1):
        purposes.append([index, _privacy_excel_value(value)])

    categories = workbook.create_sheet("Datenkategorien" if de else "Data categories")
    category_headers = [
        "Name", "System", "Betroffene Personen" if de else "Data subjects",
        "Personenbezogene Daten" if de else "Personal data", "Zweck" if de else "Purpose",
        "Löschart" if de else "Retention type", "Löschfrist (Tage)" if de else "Retention days",
        "Fristbeginn/Auslöser" if de else "Retention trigger",
        "Automatisch technisch möglich" if de else "Automatic possible",
        "Automatische Löschung aktiv" if de else "Automatic active",
        "Manuelles Verfahren" if de else "Manual procedure",
        "Behandlung in Sicherungen" if de else "Backup handling",
        "Verantwortlich" if de else "Responsible", "Hinweise" if de else "Notes",
    ]
    categories.append(category_headers)
    for item in settings.get("categories", []):
        categories.append([_privacy_excel_value(value) for value in [
            item.get("name"), item.get("system"), item.get("data_subjects"), item.get("personal_data"),
            item.get("purpose"), item.get("retention_type"), item.get("retention_days"), item.get("retention_trigger"),
            ("Ja" if item.get("automatic_possible") else "Nein") if de else ("Yes" if item.get("automatic_possible") else "No"),
            "Nein - manuell für ausgewählte Kategorien" if de else "No - manual for selected categories", item.get("manual_procedure"),
            item.get("backup_handling"), item.get("responsible"), item.get("notes"),
        ]])

    toms = workbook.create_sheet("TOMs")
    toms.append(["Nr." if de else "No.", "Technische und organisatorische Maßnahme" if de else "Technical and organisational measure"])
    for index, value in enumerate(activity.get("toms", []), 1):
        toms.append([index, _privacy_excel_value(value)])

    audit = workbook.create_sheet("Änderungsprotokoll" if de else "Policy audit")
    audit.append([
        "Zeitstempel UTC" if de else "Timestamp UTC", "Aktion" if de else "Action",
        "Geändert von" if de else "Changed by", "Details",
    ])
    for row in reversed(privacy_audit_tail(1000)):
        audit.append([_privacy_excel_value(value) for value in [
            row.get("timestamp_utc"), row.get("action"), row.get("changed_by"), row.get("details", {}),
        ]])

    _style_privacy_workbook(workbook)
    output = io.BytesIO()
    workbook.save(output)
    payload = output.getvalue()

    # Verify the generated archive before sending it to the browser.
    try:
        load_workbook(io.BytesIO(payload), read_only=True, data_only=True).close()
    except Exception as exc:
        logging.exception("Privacy XLSX validation failed")
        raise HTTPException(500, f"Privacy Excel export could not be validated: {exc}") from exc

    append_privacy_audit("privacy_report_exported", _changed_by(request) or "authenticated-user", {"format": "xlsx"})
    filename = "assetmanager-datenschutz-verarbeitungstaetigkeit.xlsx" if de else "assetmanager-privacy-processing-record.xlsx"
    disposition = f"attachment; filename={filename}; filename*=UTF-8''{quote(filename)}"
    return Response(
        content=payload,
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        headers={
            "Content-Disposition": disposition,
            "Content-Length": str(len(payload)),
            "X-Content-Type-Options": "nosniff",
            "Cache-Control": "no-store",
        },
    )


@app.get("/settings/software")
def settings_software_page(request: Request):
    _require_admin(request)
    settings = _software_settings()
    callback_base = str(settings.get("callback_base_url", "") or "").strip().rstrip("/")
    callback_internal_base = str(os.getenv("CALLBACK_INTERNAL_BASE_URL", "http://callback:8001") or "http://callback:8001").strip().rstrip("/")
    return templates.TemplateResponse(
        "settings_software.html",
        {
            "request": request,
            "software_settings": settings,
            "callback_health_url": f"{callback_base}/api/software-callback/health" if callback_base else "",
            "callback_internal_health_url": f"{callback_internal_base}/api/software-callback/health",
            "callback_test_result": request.query_params.get("callback_test_result", ""),
            "callback_test_kind": request.query_params.get("callback_test_kind", ""),
            "callback_test_status": request.query_params.get("callback_test_status", ""),
            "callback_test_ms": request.query_params.get("callback_test_ms", ""),
            "callback_test_url": request.query_params.get("callback_test_url", ""),
            "callback_test_message": request.query_params.get("callback_test_message", ""),
            "debug_log": _software_debug_tail(),
            "debug_log_path": str(SOFTWARE_CALLBACK_DEBUG_LOG),
            "callback_dedicated_port": str(os.getenv("CALLBACK_PORT", "8090") or "8090"),
            "callback_bind_ip": str(os.getenv("CALLBACK_BIND_IP", "127.0.0.1") or "127.0.0.1"),
        },
    )


@app.post("/settings/software")
def settings_software_save(
    request: Request,
    dispatch_delay_seconds: int = Form(2),
    callback_base_url: str = Form(""),
    callback_timeout_minutes: int = Form(30),
    callback_worker_count: int = Form(4),
    callback_test_timeout_seconds: int = Form(10),
    callback_test_verify_tls: str | None = Form(None),
    debug_logging: str | None = Form(None),
    debug_log_max_lines: int = Form(1000),
    automatic_retry_enabled: str | None = Form(None),
    automatic_retry_max_age_hours: int = Form(12),
    automatic_retry_interval_seconds: int = Form(60),
    automatic_retry_max_retries: int = Form(3),
    inventory_exclusion_platform: list[str] = Form(default=[]),
    inventory_exclusion_name: list[str] = Form(default=[]),
):
    _require_admin(request)
    callback_base_url = callback_base_url.strip().rstrip("/")
    if callback_base_url:
        parsed = urllib.parse.urlsplit(callback_base_url)
        if parsed.scheme not in {"http", "https"} or not parsed.hostname:
            return RedirectResponse(
                "/settings/software?toast_error=" + quote(_translate_request(request, "software.settings.invalid_url", "Enter a valid HTTP or HTTPS base URL.")),
                status_code=303,
            )

    submitted_exclusion_rules = _normalize_software_exclusion_rules([
        {"platform": platform, "name_contains": name}
        for platform, name in zip(inventory_exclusion_platform, inventory_exclusion_name)
    ])

    current = load_config()
    software = dict(current.get("software", {}))
    software.update({
        "dispatch_delay_seconds": max(0, min(int(dispatch_delay_seconds), 60)),
        "callback_base_url": callback_base_url,
        "callback_timeout_minutes": max(5, min(int(callback_timeout_minutes), 240)),
        "callback_worker_count": max(1, min(int(callback_worker_count), 8)),
        "callback_test_timeout_seconds": max(2, min(int(callback_test_timeout_seconds), 60)),
        "callback_test_verify_tls": callback_test_verify_tls == "on",
        "debug_logging": debug_logging == "on",
        "debug_log_max_lines": max(100, min(int(debug_log_max_lines), 10000)),
        "automatic_retry_enabled": automatic_retry_enabled == "on",
        "automatic_retry_max_age_hours": max(1, min(int(automatic_retry_max_age_hours), 168)),
        "automatic_retry_interval_seconds": max(10, min(int(automatic_retry_interval_seconds), 86400)),
        "automatic_retry_max_retries": max(1, min(int(automatic_retry_max_retries), 10)),
        "inventory_exclusion_rules": submitted_exclusion_rules,
    })
    save_config({"software": software})
    _software_debug_log(
        f"SETTINGS saved | dispatch_delay_seconds={software['dispatch_delay_seconds']} | callback_base_url={callback_base_url or '<automatic>'} | callback_timeout_minutes={software['callback_timeout_minutes']} | callback_worker_count={software['callback_worker_count']} | automatic_retry_enabled={software['automatic_retry_enabled']} | automatic_retry_max_age_hours={software['automatic_retry_max_age_hours']} | automatic_retry_interval_seconds={software['automatic_retry_interval_seconds']} | automatic_retry_max_retries={software['automatic_retry_max_retries']} | inventory_exclusion_rules={len(submitted_exclusion_rules)} | verify_tls={software['callback_test_verify_tls']}",
        force=True,
    )
    return RedirectResponse(
        "/settings/software?toast_success=" + quote(_translate_request(request, "software.settings.saved", "Software settings saved.")),
        status_code=303,
    )


def _run_callback_health_test(health_url: str, timeout: int, verify_tls: bool, test_kind: str) -> dict[str, str]:
    parsed = urllib.parse.urlsplit(health_url)
    _software_debug_log("=" * 72, force=True)
    _software_debug_log(f"CALLBACK HEALTH TEST started | kind={test_kind}", force=True)
    _software_debug_log(f"Health URL: {health_url}", force=True)
    _software_debug_log(f"Timeout: {timeout}s | Verify TLS: {verify_tls}", force=True)

    if parsed.scheme not in {"http", "https"} or not parsed.hostname:
        return {"ok": "0", "status": "-", "ms": "-", "url": health_url, "message": "Invalid health URL."}

    try:
        dns_started = time.monotonic()
        addresses = sorted({item[4][0] for item in socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)})
        _software_debug_log(f"DNS: {', '.join(addresses)} ({(time.monotonic()-dns_started)*1000:.1f} ms)", force=True)
    except Exception as exc:
        _software_debug_log(f"DNS ERROR: {type(exc).__name__}: {exc}", force=True)

    context = None
    if parsed.scheme == "https" and not verify_tls:
        context = ssl._create_unverified_context()

    request_started = time.monotonic()
    try:
        req = urllib.request.Request(
            health_url,
            headers={"User-Agent": f"AssetManager/{application_version()} CallbackHealthTest"},
            method="GET",
        )
        with urllib.request.urlopen(req, timeout=timeout, context=context) as response:
            body = response.read(65536).decode("utf-8", errors="replace")
            elapsed = (time.monotonic() - request_started) * 1000
            status = str(response.status)
            _software_debug_log(f"HTTP STATUS: {response.status} {response.reason} ({elapsed:.1f} ms)", force=True)
            _software_debug_log(f"Response body: {body}", force=True)
            payload = json.loads(body)
            if response.status == 200 and payload.get("ok") is True:
                _software_debug_log("RESULT: Callback health endpoint is reachable.", force=True)
                return {"ok": "1", "status": status, "ms": f"{elapsed:.1f}", "url": health_url, "message": "OK"}
            return {"ok": "0", "status": status, "ms": f"{elapsed:.1f}", "url": health_url, "message": "Health response did not contain ok=true."}
    except urllib.error.HTTPError as exc:
        elapsed = (time.monotonic() - request_started) * 1000
        body = exc.read(65536).decode("utf-8", errors="replace")
        _software_debug_log(f"HTTP ERROR: {exc.code} {exc.reason}", force=True)
        _software_debug_log(f"Response body: {body}", force=True)
        return {"ok": "0", "status": str(exc.code), "ms": f"{elapsed:.1f}", "url": health_url, "message": f"HTTP {exc.code}: {exc.reason}"}
    except Exception as exc:
        elapsed = (time.monotonic() - request_started) * 1000
        _software_debug_log(f"REQUEST ERROR: {type(exc).__name__}: {exc}", force=True)
        return {"ok": "0", "status": "-", "ms": f"{elapsed:.1f}", "url": health_url, "message": f"{type(exc).__name__}: {exc}"}


def _callback_test_redirect(request: Request, result: dict[str, str], test_kind: str) -> RedirectResponse:
    params = urllib.parse.urlencode({
        "callback_test_result": result.get("ok", "0"),
        "callback_test_kind": test_kind,
        "callback_test_status": result.get("status", "-"),
        "callback_test_ms": result.get("ms", "-"),
        "callback_test_url": result.get("url", ""),
        "callback_test_message": result.get("message", ""),
    })
    return RedirectResponse(f"/settings/software?{params}", status_code=303)


@app.post("/settings/software/test-callback")
@app.post("/settings/software/test-callback-external")
def settings_software_test_callback_external(request: Request):
    _require_admin(request)
    settings = _software_settings()
    callback_base = str(settings.get("callback_base_url", "") or "").strip().rstrip("/")
    if not callback_base:
        callback_base = str(request.base_url).rstrip("/")
    health_url = f"{callback_base}/api/software-callback/health"
    timeout = max(2, min(int(settings.get("callback_test_timeout_seconds", 10) or 10), 60))
    verify_tls = bool(settings.get("callback_test_verify_tls", True))
    result = _run_callback_health_test(health_url, timeout, verify_tls, "external")
    return _callback_test_redirect(request, result, "external")


@app.post("/settings/software/test-callback-internal")
def settings_software_test_callback_internal(request: Request):
    _require_admin(request)
    settings = _software_settings()
    internal_base = str(os.getenv("CALLBACK_INTERNAL_BASE_URL", "http://callback:8001") or "http://callback:8001").strip().rstrip("/")
    health_url = f"{internal_base}/api/software-callback/health"
    timeout = max(2, min(int(settings.get("callback_test_timeout_seconds", 10) or 10), 60))
    result = _run_callback_health_test(health_url, timeout, True, "internal")
    return _callback_test_redirect(request, result, "internal")


@app.get("/settings/software/debug-log")
def settings_software_debug_log(request: Request):
    _require_admin(request)
    return Response(_software_debug_tail(), media_type="text/plain; charset=utf-8", headers={"Cache-Control": "no-store"})


@app.post("/settings/software/debug-log/clear")
def settings_software_debug_log_clear(request: Request):
    _require_admin(request)
    try:
        SOFTWARE_CALLBACK_DEBUG_LOG.unlink(missing_ok=True)
    except OSError:
        logger.exception("Software callback debug log could not be cleared")
    return RedirectResponse(
        "/settings/software?toast_success=" + quote(_translate_request(request, "software.settings.log_cleared", "Debug log cleared.")),
        status_code=303,
    )


@app.get("/settings/backup")
def settings_backup_page(request: Request):
    _require_admin(request)
    return templates.TemplateResponse(
        "settings_backup.html",
        {
            "request": request,
            "backups": list_backups(),
            "backup_directory": str(BACKUP_DIR),
            "backup_interval_hours": BACKUP_INTERVAL_HOURS,
            "backup_retention_days": BACKUP_RETENTION_DAYS,
        },
    )


@app.post("/settings/backup/create")
def settings_backup_create(request: Request):
    _require_admin(request)
    try:
        created_by = _changed_by(request) or "admin"
        create_backup(created_by=created_by, reason="manual")
        return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.created_success", "Backup created")), status_code=303)
    except Exception as exc:
        logger.exception("Backup creation failed")
        return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303)


@app.get("/settings/backup/{backup_name}/download")
def settings_backup_download(request: Request, backup_name: str):
    _require_admin(request)
    try:
        path = backup_path(backup_name)
        if not path.is_file():
            raise HTTPException(status_code=404)
        return FileResponse(path, media_type="application/zip", filename=path.name)
    except ValueError:
        raise HTTPException(status_code=404)


@app.post("/settings/backup/{backup_name}/delete")
def settings_backup_delete(request: Request, backup_name: str):
    _require_admin(request)
    try:
        delete_backup(backup_name)
        return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.deleted_success", "Backup deleted")), status_code=303)
    except Exception as exc:
        logger.exception("Backup deletion failed")
        return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303)


@app.post("/settings/backup/upload")
async def settings_backup_upload(request: Request, backup_file: UploadFile = File(...)):
    _require_admin(request)
    try:
        content = await backup_file.read()
        store_uploaded_backup(backup_file.filename or "backup.zip", content)
        return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.uploaded_success", "Backup uploaded")), status_code=303)
    except Exception as exc:
        logger.exception("Backup upload failed")
        return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303)


@app.post("/settings/backup/{backup_name}/restore")
def settings_backup_restore(request: Request, backup_name: str, confirmation: str = Form("")):
    _require_admin(request)
    if confirmation.strip().upper() != "RESTORE":
        return RedirectResponse("/settings/backup?toast_warning=" + quote(_translate_request(request, "backup.restore_confirmation_missing", "Restore confirmation is missing")), status_code=303)
    try:
        # Safety copy immediately before replacing data.
        create_backup(created_by="system", reason="before_restore")
        restore_backup(backup_name)
        clear_translation_cache()
        return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.restored_success", "Backup restored")), status_code=303)
    except Exception as exc:
        logger.exception("Backup restore failed")
        return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303)


@app.get("/settings")
def settings_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    categories = db.query(Category).order_by(Category.name.asc()).all()
    return templates.TemplateResponse(
        "settings.html",
        {
            "request": request,
            "settings": public_config(),
            "categories": categories,
            "languages": i18n_languages(db),
            "settings_section": "appearance",
            "system_storage": system_storage_information(),
        },
    )


THEME_COLOR_DEFAULTS = {
    "primary_color": "#0878bd",
    "primary_hover_color": "#075f94",
    "header_color": "#063c66",
    "navigation_color": "#ffffff",
    "page_background": "#f2f4f6",
    "surface_color": "#ffffff",
    "text_color": "#1f2933",
    "border_color": "#d7dde3",
    "success_color": "#198754",
    "warning_color": "#d97706",
    "danger_color": "#b42318",
}


def _safe_hex_color(value: str, fallback: str) -> str:
    candidate = (value or "").strip()
    return candidate.lower() if re.fullmatch(r"#[0-9a-fA-F]{6}", candidate) else fallback


@app.post("/settings")
async def settings_save(
    request: Request,
    title: str = Form("AssetManager"),
    company_name: str = Form(""),
    about_author: str = Form(""),
    about_organization: str = Form(""),
    about_contact: str = Form(""),
    about_website: str = Form(""),
    about_repository: str = Form(""),
    about_license: str = Form(""),
    about_copyright: str = Form(""),
    callback_base_url: str = Form(""),
    default_language: str = Form("en"),
    custom_css: str = Form(""),
    default_theme: str = Form("light"),
    primary_color: str = Form("#0878bd"),
    primary_hover_color: str = Form("#075f94"),
    header_color: str = Form("#063c66"),
    navigation_color: str = Form("#ffffff"),
    page_background: str = Form("#f2f4f6"),
    surface_color: str = Form("#ffffff"),
    text_color: str = Form("#1f2933"),
    border_color: str = Form("#d7dde3"),
    success_color: str = Form("#198754"),
    warning_color: str = Form("#d97706"),
    danger_color: str = Form("#b42318"),
    auth_mode: str = Form("none"),
    ldap_server: str = Form(""),
    ldap_port: int = Form(389),
    ldap_use_ssl: str | None = Form(None),
    ldap_start_tls: str | None = Form(None),
    ldap_bind_dn: str = Form(""),
    ldap_bind_password_env: str = Form("LDAP_BIND_PASSWORD"),
    ldap_user_base_dn: str = Form(""),
    ldap_user_filter: str = Form("(sAMAccountName={username})"),
    ldap_display_name_attribute: str = Form("displayName"),
    ldap_email_attribute: str = Form("mail"),
    ldap_debug_logging: str | None = Form(None),
    remove_logo: str | None = Form(None),
    remove_favicon: str | None = Form(None),
    logo: UploadFile | None = File(None),
    favicon: UploadFile | None = File(None),
):
    _require_admin(request)

    current = load_config()
    general = dict(current.get("general", {}))
    general["title"] = title.strip() or "AssetManager"
    general["company_name"] = company_name.strip()
    general["about_author"] = about_author.strip()
    general["about_organization"] = about_organization.strip()
    general["about_contact"] = about_contact.strip()
    general["about_website"] = about_website.strip()
    general["about_repository"] = about_repository.strip()
    general["about_license"] = about_license.strip()
    general["about_copyright"] = about_copyright.strip()
    general["callback_base_url"] = callback_base_url.strip().rstrip("/")
    general["default_language"] = default_language if default_language in {"en", "de"} else "en"
    general["custom_css"] = custom_css.replace("\x00", "").strip()
    general["default_theme"] = default_theme if default_theme in {"light", "dark"} else "light"
    colors = {}
    submitted_colors = {
        "primary_color": primary_color,
        "primary_hover_color": primary_hover_color,
        "header_color": header_color,
        "navigation_color": navigation_color,
        "page_background": page_background,
        "surface_color": surface_color,
        "text_color": text_color,
        "border_color": border_color,
        "success_color": success_color,
        "warning_color": warning_color,
        "danger_color": danger_color,
    }
    for color_name, fallback in THEME_COLOR_DEFAULTS.items():
        colors[color_name] = _safe_hex_color(submitted_colors.get(color_name, ""), fallback)
    general["colors"] = colors
    if remove_logo == "on":
        old_logo = general.get("logo")
        if old_logo:
            delete_asset_image(old_logo)
        general["logo"] = ""
    if logo and logo.filename:
        old_logo = general.get("logo")
        new_logo = save_upload(logo)
        if old_logo and old_logo != new_logo:
            delete_asset_image(old_logo)
        general["logo"] = new_logo or ""

    if remove_favicon == "on":
        old_favicon = general.get("favicon")
        if old_favicon:
            delete_asset_image(old_favicon)
        general["favicon"] = ""
    if favicon and favicon.filename:
        old_favicon = general.get("favicon")
        new_favicon = save_upload(favicon)
        if old_favicon and old_favicon != new_favicon:
            delete_asset_image(old_favicon)
        general["favicon"] = new_favicon or ""
    if auth_mode not in {"none", "local", "ldap"}:
        auth_mode = "none"
    authentication = dict(current.get("authentication", {}))
    authentication["mode"] = auth_mode
    ldap = dict(authentication.get("ldap", {}))
    ldap.update({
        "server": ldap_server.strip(),
        "port": ldap_port,
        "use_ssl": ldap_use_ssl == "on",
        "start_tls": ldap_start_tls == "on",
        "bind_dn": ldap_bind_dn.strip(),
        "bind_password_env": ldap_bind_password_env.strip() or "LDAP_BIND_PASSWORD",
        "user_base_dn": ldap_user_base_dn.strip(),
        "user_filter": ldap_user_filter.strip() or "(sAMAccountName={username})",
        "display_name_attribute": ldap_display_name_attribute.strip() or "displayName",
        "email_attribute": ldap_email_attribute.strip() or "mail",
        "debug_logging": ldap_debug_logging == "on",
    })
    authentication["ldap"] = ldap
    save_config({"general": general, "authentication": authentication})
    return RedirectResponse("/settings?toast_success=Einstellungen gespeichert", status_code=303)


@app.get("/sync/meshcentral")
def meshcentral_sync_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    runs = db.query(SyncRun).order_by(SyncRun.started_at.desc()).limit(20).all()
    linked = db.query(Asset).filter(Asset.mesh_node_id.isnot(None)).count()
    conflicts = db.query(Asset).filter(Asset.mesh_sync_status == "conflict").count()
    missing = db.query(Asset).filter(Asset.mesh_sync_status == "missing").count()
    definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all()
    mappings = db.query(MeshFieldMapping).order_by(MeshFieldMapping.priority, MeshFieldMapping.id).all()
    categories = db.query(Category).order_by(Category.name.asc()).all()
    return templates.TemplateResponse("meshcentral_sync.html", {
        "request": request, "runs": runs, "config": public_config(),
        "linked": linked, "conflicts": conflicts, "missing": missing,
        "definitions": definitions,
        "mappings": mappings,
        "categories": categories,
        "transform_options": [
            "raw", "string", "integer", "decimal", "boolean", "json",
            "normalize_serial", "normalize_mac", "active_ipv4_address", "active_ipv4_mac",
            "sum_memory_gb", "format_memory_modules", "sum_storage_gb", "format_drives",
            "gpu_names", "active_antivirus",
        ],
    })


@app.post("/sync/meshcentral/start")
def meshcentral_sync_start(request: Request):
    _require_admin(request)
    with SYNC_JOBS_LOCK:
        if any(not job.get("finished") for job in SYNC_JOBS.values()):
            return JSONResponse({"error": "Es läuft bereits eine Synchronisierung."}, status_code=409)
        job_id = uuid.uuid4().hex
        SYNC_JOBS[job_id] = {"status": "running", "finished": False, "run_id": None, "logs": [], "created_at": datetime.now().isoformat()}
    _job_log(job_id, "info", "Synchronisierungsauftrag angelegt.")
    threading.Thread(target=_run_sync_job, args=(job_id,), daemon=True).start()
    return {"job_id": job_id}


@app.post("/sync/meshcentral/dry-run")
def meshcentral_sync_dry_run(request: Request):
    _require_admin(request)
    with SYNC_JOBS_LOCK:
        if any(not job.get("finished") for job in SYNC_JOBS.values()):
            return JSONResponse({"error": "Es läuft bereits eine Synchronisierung oder ein Dry-Run."}, status_code=409)
        job_id = uuid.uuid4().hex
        SYNC_JOBS[job_id] = {
            "status": "dry-run", "finished": False, "run_id": None,
            "logs": [], "created_at": datetime.now().isoformat(), "dry_run": True,
        }
    _job_log(job_id, "info", "Dry-Run-Auftrag angelegt. Es werden keine Änderungen gespeichert.")
    threading.Thread(target=_run_sync_job, args=(job_id, True), daemon=True).start()
    return {"job_id": job_id}


@app.get("/sync/meshcentral/jobs/{job_id}")
def meshcentral_sync_job(job_id: str, offset: int = 0):
    with SYNC_JOBS_LOCK:
        job = SYNC_JOBS.get(job_id)
        if not job:
            raise HTTPException(404, "Synchronisierungsauftrag nicht gefunden")
        logs = list(job["logs"][max(0, offset):])
        return {
            "status": job["status"],
            "finished": job["finished"],
            "run_id": job["run_id"],
            "logs": logs,
            "next_offset": max(0, offset) + len(logs),
        }


@app.post("/sync/meshcentral")
def meshcentral_sync_run():
    return RedirectResponse("/sync/meshcentral", status_code=303)

# ---------------------------------------------------------------------------
# v0.3.8.0: central field catalog and MeshCentral synchronization settings
# ---------------------------------------------------------------------------

@app.get('/settings/fields')
def field_definitions_page(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    definitions = db.query(FieldDefinition).order_by(FieldDefinition.sort_order, FieldDefinition.label).all()
    value_counts = dict(db.query(AssetFieldValue.field_definition_id, __import__('sqlalchemy').func.count(AssetFieldValue.id)).group_by(AssetFieldValue.field_definition_id).all())
    return templates.TemplateResponse('field_definitions.html', {'request': request, 'definitions': definitions, 'data_types': FIELD_DATA_TYPES, 'value_counts': value_counts})

@app.get('/settings/fields/new')
def field_definition_new(request: Request):
    _require_admin(request)
    return templates.TemplateResponse('field_definition_form.html', {'request': request, 'definition': None, 'data_types': FIELD_DATA_TYPES})

@app.post('/settings/fields/new')
def field_definition_create(request: Request, field_name: str = Form(...), label: str = Form(...), description: str = Form(''), data_type: str = Form('short_text'), readonly: str | None = Form(None), searchable: str | None = Form(None), filterable: str | None = Form(None), chart_enabled: str | None = Form(None), excel_import: str | None = Form(None), excel_export: str | None = Form(None), bulk_edit: str | None = Form(None), is_default: str | None = Form(None), list_width: str | None = Form(None), db: Session = Depends(get_db)):
    _require_admin(request)
    name = re.sub(r'[^a-z0-9_]', '_', field_name.strip().lower()).strip('_')
    if not name or name in ASSET_FIELDS:
        raise HTTPException(400, 'The field name is invalid or already reserved.')
    if data_type not in FIELD_DATA_TYPES:
        raise HTTPException(400, 'Invalid data type.')
    definition = FieldDefinition(field_name=name, label=label.strip(), description=description.strip() or None, data_type=data_type, readonly=readonly == 'on', is_system=False, is_active=True, translation_key=f'field.{name}', searchable=searchable == 'on', filterable=filterable == 'on', chart_enabled=chart_enabled == 'on', excel_import=excel_import == 'on', excel_export=excel_export == 'on', bulk_edit=bulk_edit == 'on', is_default=is_default == 'on', list_width=_optional_form_int(list_width, field_name="list_width", minimum=40, maximum=800), sort_order=(db.query(FieldDefinition).count()+1))
    db.add(definition)
    try:
        db.flush()
        for category in db.query(Category).all():
            db.add(CategoryField(category_id=category.id, field_name=name, label=definition.label, field_definition_id=definition.id, active=False, required=False, show_in_list=False, readonly=definition.readonly, sort_order=definition.sort_order))
        # Add English fallback translation; German can be maintained in translation editor.
        db.add(Translation(translation_key=definition.translation_key, language_code='en', text_value=definition.label))
        db.commit(); clear_translation_cache()
    except IntegrityError as exc:
        db.rollback(); raise HTTPException(400, 'A field with this name already exists.') from exc
    return RedirectResponse('/settings/fields?toast_success=Field created', status_code=303)

@app.get('/settings/fields/{definition_id}/edit')
def field_definition_edit(definition_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    definition = db.get(FieldDefinition, definition_id)
    if not definition: raise HTTPException(404, 'Field not found')
    return templates.TemplateResponse('field_definition_form.html', {'request': request, 'definition': definition, 'data_types': FIELD_DATA_TYPES})

@app.post('/settings/fields/{definition_id}/edit')
def field_definition_update(definition_id: int, request: Request, label: str = Form(...), description: str = Form(''), data_type: str = Form('short_text'), readonly: str | None = Form(None), is_active: str | None = Form(None), searchable: str | None = Form(None), filterable: str | None = Form(None), chart_enabled: str | None = Form(None), excel_import: str | None = Form(None), excel_export: str | None = Form(None), bulk_edit: str | None = Form(None), is_default: str | None = Form(None), list_width: str | None = Form(None), db: Session = Depends(get_db)):
    _require_admin(request)
    definition = db.get(FieldDefinition, definition_id)
    if not definition: raise HTTPException(404, 'Field not found')
    if data_type not in FIELD_DATA_TYPES: raise HTTPException(400, 'Invalid data type')
    if not definition.is_system and definition.data_type != data_type and db.query(AssetFieldValue).filter(AssetFieldValue.field_definition_id == definition.id).count():
        raise HTTPException(400, 'The data type can only be changed while the custom field has no stored values.')
    definition.label=label.strip(); definition.description=description.strip() or None; definition.data_type=data_type
    definition.readonly=True if definition.field_name in SYSTEM_READONLY_FIELDS else readonly == 'on'
    definition.is_active=True if definition.is_system else is_active == 'on'
    definition.searchable=searchable == 'on'; definition.filterable=filterable == 'on'; definition.chart_enabled=chart_enabled == 'on'
    definition.excel_import=False if definition.readonly else excel_import == 'on'; definition.excel_export=excel_export == 'on'; definition.bulk_edit=False if definition.readonly else bulk_edit == 'on'; definition.is_default=is_default == 'on'; definition.list_width=_optional_form_int(list_width, field_name="list_width", minimum=40, maximum=800)
    for link in definition.category_links:
        link.label=definition.label; link.readonly=definition.readonly
    db.commit(); clear_translation_cache()
    return RedirectResponse('/settings/fields?toast_success=Field saved', status_code=303)

@app.post('/settings/fields/{definition_id}/delete')
def field_definition_delete(definition_id: int, request: Request, confirm_values: str | None = Form(None), db: Session = Depends(get_db)):
    _require_admin(request)
    definition = db.get(FieldDefinition, definition_id)
    if not definition: raise HTTPException(404, 'Field not found')
    if definition.is_system: raise HTTPException(400, 'System fields cannot be deleted.')
    count = db.query(AssetFieldValue).filter(AssetFieldValue.field_definition_id == definition.id).count()
    if count and confirm_values != 'on': raise HTTPException(400, f'This field contains values for {count} assets. Confirm deletion of all values.')
    db.delete(definition); db.commit()
    return RedirectResponse('/settings/fields?toast_success=Field deleted', status_code=303)

@app.post('/sync/meshcentral/settings')
async def meshcentral_settings_save(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    current = load_config()
    mesh = dict(current.get('meshcentral', {}))
    try:
        timeout_seconds = max(10, min(int(form.get('timeout_seconds') or 600), 3600))
    except (TypeError, ValueError):
        timeout_seconds = 600
    mesh.update({
        'enabled': form.get('enabled') == 'on',
        'url': (form.get('url') or '').strip(),
        'username': (form.get('username') or '').strip(),
        'password_env': (form.get('password_env') or 'MESHCENTRAL_PASSWORD').strip(),
        'tenant': (form.get('tenant') or '').strip(),
        'meshctrl_path': (form.get('meshctrl_path') or '/opt/meshcentral/node_modules/meshcentral/meshctrl.js').strip(),
        'verify_tls': form.get('verify_tls') == 'on',
        'include_details': form.get('include_details') == 'on',
        'timeout_seconds': timeout_seconds,
        'create_missing_assets': form.get('create_missing_assets') == 'on',
        'mark_missing_devices': form.get('mark_missing_devices') == 'on',
        'store_source_json': form.get('store_source_json') == 'on',
        'serial_match_manufacturer': form.get('serial_match_manufacturer') == 'on',
    })

    invalid_serial_values = []
    for line in str(form.get('invalid_serial_values') or '').splitlines():
        value = line.strip()
        if value and value.casefold() not in {item.casefold() for item in invalid_serial_values}:
            invalid_serial_values.append(value)
    mesh['invalid_serial_values'] = invalid_serial_values

    manufacturer_rules: dict[str, list[str]] = {}
    for line in str(form.get('manufacturer_invalid_serial_rules') or '').splitlines():
        line = line.strip()
        if not line or line.startswith('#'):
            continue
        if '|' not in line:
            continue
        manufacturer, serial = (part.strip() for part in line.split('|', 1))
        if not manufacturer or not serial:
            continue
        values = manufacturer_rules.setdefault(manufacturer, [])
        if serial.casefold() not in {item.casefold() for item in values}:
            values.append(serial)
    mesh['manufacturer_invalid_serial_rules'] = [
        {'manufacturer': manufacturer, 'values': values}
        for manufacturer, values in manufacturer_rules.items()
    ]

    submitted_match_order = [value for value in form.getlist('match_order') if value in {'node_id', 'mac_address', 'serial_number'}]
    mesh['match_order'] = submitted_match_order or ['node_id', 'mac_address', 'serial_number']
    # Do not retain plaintext passwords from older versions.
    mesh.pop('password', None)
    mesh.pop('field_mappings', None)
    mesh.pop('field_rules', None)
    save_config({'meshcentral': mesh})
    return RedirectResponse('/sync/meshcentral?toast_success=MeshCentral-Einstellungen gespeichert', status_code=303)


@app.post('/sync/meshcentral/categories/save')
async def meshcentral_category_mapping_save(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    categories = db.query(Category).all()
    valid_category_ids = {int(category.id) for category in categories}
    if not valid_category_ids:
        raise HTTPException(400, 'No asset categories are available.')

    try:
        fallback_category_id = int(form.get('mesh_fallback_category_id') or 0)
    except (TypeError, ValueError):
        fallback_category_id = 0
    if fallback_category_id not in valid_category_ids:
        raise HTTPException(400, 'Invalid fallback category.')

    category_mapping = {}
    for type_id in range(1, 9):
        raw_value = str(form.get(f'mesh_category_{type_id}') or '').strip()
        if not raw_value:
            category_mapping[str(type_id)] = None
            continue
        try:
            category_id = int(raw_value)
        except ValueError:
            raise HTTPException(400, f'Invalid category mapping for MeshCentral type {type_id}.')
        if category_id not in valid_category_ids:
            raise HTTPException(400, f'Unknown category for MeshCentral type {type_id}.')
        category_mapping[str(type_id)] = category_id

    current = load_config()
    mesh = dict(current.get('meshcentral', {}))
    mesh['fallback_category_id'] = fallback_category_id
    mesh['category_mapping'] = category_mapping
    save_config({'meshcentral': mesh})
    return RedirectResponse('/sync/meshcentral?toast_success=' + quote(_translate_request(request, 'settings.mesh_mapping_saved', 'MeshCentral category mapping saved')), status_code=303)


@app.post('/sync/meshcentral/mappings/save')
async def meshcentral_mappings_save(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    allowed_transforms = {
        'raw','string','integer','decimal','boolean','json','normalize_serial','normalize_mac',
        'active_ipv4_address','active_ipv4_mac','sum_memory_gb','format_memory_modules',
        'sum_storage_gb','format_drives','gpu_names','active_antivirus'
    }
    allowed_multi = {'first','join','json','count'}
    allowed_rules = {'fill_empty','meshcentral_wins','local_wins'}
    for row in db.query(MeshFieldMapping).all():
        prefix = f'mapping_{row.id}_'
        source = str(form.get(prefix + 'source_path') or '').strip()
        target = str(form.get(prefix + 'target_field_name') or '').strip()
        if not source or not target:
            continue
        row.source_path = source
        row.target_field_name = target
        transform = str(form.get(prefix + 'transform') or 'raw')
        row.transform = transform if transform in allowed_transforms else 'raw'
        multi = str(form.get(prefix + 'multi_value_mode') or 'first')
        row.multi_value_mode = multi if multi in allowed_multi else 'first'
        row.separator = str(form.get(prefix + 'separator') or '\\n').replace('\\n', '\n').replace('\\t', '\t')
        row.enabled = form.get(prefix + 'enabled') == 'on'
        try: row.priority = int(form.get(prefix + 'priority') or 0)
        except (TypeError, ValueError): row.priority = 0
        rule = str(form.get(prefix + 'update_rule') or 'fill_empty')
        row.update_rule = rule if rule in allowed_rules else 'fill_empty'
        row.description = str(form.get(prefix + 'description') or '').strip() or None
    db.commit()
    return RedirectResponse('/sync/meshcentral?toast_success=Mapping-Regeln gespeichert', status_code=303)


@app.post('/sync/meshcentral/mappings/new')
async def meshcentral_mapping_new(request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    form = await request.form()
    source = str(form.get('source_path') or '').strip()
    target = str(form.get('target_field_name') or '').strip()
    if not source or not target:
        return RedirectResponse('/sync/meshcentral?toast_error=Quellpfad und Zielfeld sind erforderlich', status_code=303)
    definition = db.query(FieldDefinition).filter(FieldDefinition.field_name == target, FieldDefinition.is_active.is_(True)).first()
    if not definition:
        return RedirectResponse('/sync/meshcentral?toast_error=Das Zielfeld existiert nicht oder ist inaktiv', status_code=303)
    max_priority = db.query(MeshFieldMapping).order_by(MeshFieldMapping.priority.desc()).first()
    row = MeshFieldMapping(
        source_path=source,
        target_field_name=target,
        transform=str(form.get('transform') or 'raw'),
        multi_value_mode=str(form.get('multi_value_mode') or 'first'),
        separator=str(form.get('separator') or '\\n').replace('\\n', '\n').replace('\\t', '\t'),
        enabled=form.get('enabled') == 'on',
        priority=(max_priority.priority + 10) if max_priority else 10,
        description=str(form.get('description') or '').strip() or None,
        update_rule=str(form.get('update_rule') or 'fill_empty'),
        is_system=False,
    )
    db.add(row)
    db.commit()
    return RedirectResponse('/sync/meshcentral?toast_success=Neue Mapping-Regel angelegt', status_code=303)


@app.post('/sync/meshcentral/mappings/{mapping_id}/delete')
def meshcentral_mapping_delete(mapping_id: int, request: Request, db: Session = Depends(get_db)):
    _require_admin(request)
    row = db.query(MeshFieldMapping).filter(MeshFieldMapping.id == mapping_id).first()
    if not row:
        raise HTTPException(status_code=404, detail='Mapping-Regel nicht gefunden')
    if row.is_system:
        return RedirectResponse('/sync/meshcentral?toast_error=System-Mappings können deaktiviert, aber nicht gelöscht werden', status_code=303)
    db.delete(row)
    db.commit()
    return RedirectResponse('/sync/meshcentral?toast_success=Mapping-Regel gelöscht', status_code=303)
