from __future__ import annotations

from flask import (
    Flask,
    render_template,
    render_template_string,
    request,
    redirect,
    url_for,
    Response,
    stream_with_context,
    jsonify,
    send_file,
    g,
    abort,
)
import pymysql
import requests
import os
import socket
import datetime
import ssl
import json
import re
import ftplib
import shlex
import smtplib
import secrets
from email.message import EmailMessage
import csv
from io import BytesIO, StringIO
import uuid
import logging
import hmac
import copy
import math
import time
from hubject_client import HubjectConfigurationError, load_hubject_config
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlencode, urljoin, quote, urlsplit, urlunsplit, urlparse
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from itertools import cycle
from statistics import mean
import subprocess
from ocpi_cdr_forwarder import build_cdr_endpoint
from services.location_repository import LocationRepository, timestamp_str as ocpi_timestamp_str
from services.ocpi_utils import FailureNotifier
from services.tariff_service import TariffService
from services.token_service import TokenService
from services.ocpi_validation import (
    DEFAULT_VERSION as DEFAULT_OCPI_VERSION,
    SUPPORTED_MODULES as SUPPORTED_OCPI_MODULES,
    ValidationIssue,
    normalize_version,
    validate_payloads,
)
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
from werkzeug.exceptions import HTTPException
from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage
from pymysql.converters import escape_string


MIN_FIRMWARE_SIZE_BYTES = 100 * 1024
from jinja2 import ChoiceLoader, DictLoader

# ---------------------------------------------------------------------------
# Diagnostic report handling
# ---------------------------------------------------------------------------

DIAGNOSTIC_REPORTS_DIR = Path(__file__).resolve().parent / "debug" / "diagnostic_reports"

# ---------------------------------------------------------------------------
# Fault detection trigger clustering
# ---------------------------------------------------------------------------

TRIGGER_CLUSTER_SECTIONS = [
    ("location", "Standort"),
    ("chargepoint", "Ladestation"),
    ("user", "Nutzer"),
    ("other", "Sonstiges"),
]

_VALID_TRIGGER_CLUSTERS = {key for key, _ in TRIGGER_CLUSTER_SECTIONS}


PIPELET_DASHBOARD_API_GROUPS: list[dict[str, Any]] = [
    {
        "title": "Authentication & basic navigation",
        "anchor": "authentication-basic-navigation",
        "endpoints": [
            {"method": "GET", "path": "/robots.txt", "description": "Disables web crawling."},
            {"method": "GET/POST", "path": "/set_language", "description": "Sets the UI language cookie."},
            {"method": "GET", "path": "/check", "description": "Health check endpoint used by upstreams."},
            {"method": "GET/POST", "path": "/", "description": "Login page."},
            {"method": "GET", "path": "/op_directlogin", "description": "Token-based login that sets the session cookie."},
            {"method": "GET", "path": "/logout", "description": "Logs out the current user."},
            {"method": "GET/POST", "path": "/op_change_password", "description": "Changes the admin password."},
            {"method": "GET", "path": "/op_api_docs", "description": "Swagger/Redoc viewer for documented API endpoints."},
        ],
    },
    {
        "title": "Dashboards, redirects, and monitoring",
        "anchor": "dashboards-redirects-monitoring",
        "endpoints": [
            {"method": "GET", "path": "/op_dashboard", "description": "Main redirect/connection dashboard."},
            {"method": "GET", "path": "/op_redirects", "description": "Lists redirect entries."},
            {"method": "GET", "path": "/op_marked_wallboxes/<station_id>/history", "description": "History of mark/unmark actions for a station."},
            {"method": "GET", "path": "/op_active_sessions", "description": "Active OCPP sessions summary."},
            {"method": "GET", "path": "/op_connection_info", "description": "Shows OCPP/MQTT connection settings."},
            {"method": "GET/POST", "path": "/op_performance", "description": "WebSocket performance statistics."},
            {"method": "GET/POST", "path": "/op_ocpi_handshakes", "description": "Credentials and handshake status for OCPI backends."},
            {"method": "GET", "path": "/op_ocpi_monitoring", "description": "Shows the OCPI API service status."},
            {"method": "GET", "path": "/op_ocpi_sessions", "description": "Live OCPP sessions and OCPI CDR queue with replay controls."},
            {"method": "GET", "path": "/op_ocpi_sync", "description": "OCPI sync dashboard with job, pull/push, and dead-letter status."},
            {"method": "GET", "path": "/api/op_session_status", "description": "JSON view of broker session states."},
            {"method": "GET", "path": "/api/op_cdr_queue", "description": "JSON view of OCPI CDR export queue."},
            {"method": "POST", "path": "/api/op_cdr_queue/<id>/replay", "description": "Trigger a manual OCPI CDR resend."},
            {"method": "POST", "path": "/api/op_ocpi_exports/<export_id>/replay", "description": "Replay a failed OCPI export for sessions or CDRs."},
            {"method": "GET", "path": "/op_ping_monitor", "description": "Ping monitor view."},
            {"method": "GET", "path": "/op_details", "description": "Recent OCPP messages for a station."},
            {"method": "GET", "path": "/op_mqtt_status", "description": "MQTT status overview."},
            {"method": "GET", "path": "/op_energymqtt", "description": "Energy MQTT topics and frequency."},
            {"method": "GET/POST", "path": "/op_energymqtt_ignore", "description": "Manage ignored MQTT topics."},
            {"method": "GET", "path": "/op_energymqtt_disconnected", "description": "Devices with no MQTT traffic for >6 hours."},
            {"method": "GET/POST", "path": "/op_ai_diagnostics", "description": "AI-based diagnostic reports; includes download/delete helpers."},
            {"method": "GET", "path": "/api/command-queue", "description": "List OCPI/OCPP command queue entries."},
            {"method": "POST", "path": "/api/command-queue/<id>/retry", "description": "Retry a queued OCPI command."},
            {"method": "POST", "path": "/api/command-queue/<id>/cancel", "description": "Cancel a queued OCPI command."},
            {"method": "GET", "path": "/api/op_dashboard_metrics", "description": "Aggregated KPIs and health indicators for the dashboard."},
        ],
    },
    {
        "title": "Redirect and proxy control",
        "anchor": "redirect-proxy-control",
        "endpoints": [
            {"method": "GET/POST", "path": "/op_edit and /v2/proxy_edit", "description": "Edit redirect entries and OCPI forwarding."},
            {"method": "POST", "path": "/op_toggle", "description": "Enable/disable proxy forwarding for a station."},
            {"method": "POST", "path": "/op_refreshStationList", "description": "Reloads the proxy station list."},
            {"method": "POST", "path": "/op_disconnect", "description": "Disconnects a station via proxy."},
            {"method": "POST", "path": "/op_delete_redirect", "description": "Deletes a redirect entry."},
            {"method": "POST", "path": "/op_wallboxSetConfig", "description": "Sets configuration on a wallbox via proxy."},
            {"method": "GET", "path": "/op_redirects", "description": "Redirect list (duplicate of dashboard section)."},
        ],
    },
    {
        "title": "Tenants, users, and IDs",
        "anchor": "tenants-users-ids",
        "endpoints": [
            {"method": "GET/POST", "path": "/op_tenants", "description": "Manage tenants."},
            {"method": "GET/POST", "path": "/op_tenant_users", "description": "Manage tenant users."},
            {"method": "GET/POST", "path": "/op_idtags", "description": "CRUD for RFID tags."},
            {"method": "GET/POST", "path": "/op_vehicle_catalog", "description": "Manage vehicle catalog entries."},
            {"method": "GET/POST", "path": "/op_vehicle_fleet", "description": "Manage vehicle fleet assignments."},
            {"method": "GET/POST", "path": "/op_rfid_mapping", "description": "Map RFID tags to vehicles."},
            {"method": "GET/POST", "path": "/op_rfid_free", "description": "Manage unassigned RFID tags."},
            {"method": "GET/POST", "path": "/op_server_rfid_global", "description": "Global RFID allow/deny list."},
            {"method": "GET/POST", "path": "/op_vouchers", "description": "Voucher management UI."},
            {"method": "GET", "path": "/api/op_vouchers", "description": "List vouchers (JSON)."},
            {"method": "POST", "path": "/api/op_vouchers", "description": "Create a voucher."},
            {"method": "PUT/PATCH", "path": "/api/op_vouchers", "description": "Update voucher fields."},
            {"method": "DELETE", "path": "/api/op_vouchers", "description": "Delete a voucher."},
        ],
    },
    {
        "title": "Target endpoints and instances",
        "anchor": "target-endpoints-instances",
        "endpoints": [
            {"method": "GET/POST", "path": "/op_targets", "description": "Manage target websocket servers."},
            {"method": "GET/POST", "path": "/op_broker_instances", "description": "Manage broker instances."},
            {"method": "GET/POST", "path": "/op_ocpp_routing_configuration", "description": "Configure OCPP routing rules."},
            {"method": "GET", "path": "/op_ocpp_routing_monitoring", "description": "Routing monitoring view."},
        ],
    },
    {
        "title": "Plug&Charge (PnC)",
        "anchor": "plug-and-charge",
        "endpoints": [
            {"method": "GET", "path": "/op_pnc_overview", "description": "Overview of PnC configuration."},
            {"method": "GET", "path": "/op_pnc_authorizations", "description": "List recent PnC authorizations."},
            {"method": "GET/POST", "path": "/op_pnc_certificates", "description": "Manage CSMS certificates."},
            {"method": "GET", "path": "/op_pnc_secc_check_digit", "description": "Check digit helper for SECC IDs."},
            {"method": "GET/POST", "path": "/op_pnc_hubject_settings", "description": "Configure Hubject credentials."},
            {"method": "GET", "path": "/api/pnc/hubject/settings", "description": "Read Hubject settings (JSON)."},
            {"method": "PUT/POST", "path": "/api/pnc/hubject/settings", "description": "Save Hubject settings (JSON)."},
            {"method": "POST", "path": "/api/pnc/hubject/reload", "description": "Reload Hubject configuration."},
            {"method": "GET/POST", "path": "/op_pnc_stations", "description": "PnC-enabled stations view."},
            {"method": "GET/POST", "path": "/op_pnc_commands", "description": "Run PnC commands via UI."},
            {"method": "GET/POST", "path": "/op_pnc_ocpp_certificates", "description": "UI for OCPP certificate actions."},
        ],
    },
    {
        "title": "Mini CPMS tooling",
        "anchor": "mini-cpms",
        "endpoints": [
            {"method": "GET/POST", "path": "/op_mini_cpms_connected_devices", "description": "Connected-device view for mini CPMS."},
            {"method": "POST", "path": "/op_mini_cpms_oicp_toggle", "description": "Toggle OICP publish state."},
            {"method": "GET/POST", "path": "/op_mini_cpms_server_startup_config", "description": "View/edit server startup config."},
            {"method": "GET/POST", "path": "/op_mini_cpms_cp_config", "description": "View/edit charge point configuration snapshots."},
            {"method": "GET/POST", "path": "/op_mini_cpms_cp_metadata/<chargepoint_id>", "description": "Metadata for a specific charge point."},
            {"method": "GET/POST", "path": "/op_mini_cpms_status", "description": "Connector/status overview."},
            {"method": "GET", "path": "/op_mini_cpms_charging_sessions", "description": "Charging session list."},
            {"method": "GET/POST", "path": "/op_mini_cpms_commands", "description": "Issue commands to connected stations."},
            {"method": "GET/POST", "path": "/op_mini_cpms_local_rfid", "description": "Manage local RFID mappings."},
            {"method": "GET/POST", "path": "/op_mini_cpms_local_lists", "description": "Manage local authorization lists."},
        ],
    },
    {
        "title": "Workflows and analytics",
        "anchor": "workflows-analytics",
        "endpoints": [
            {"method": "GET/POST", "path": "/op_workflows", "description": "Workflow execution history."},
            {"method": "GET/POST", "path": "/op_workflow_designer", "description": "Workflow designer UI."},
            {"method": "GET", "path": "/op_charging_sessions", "description": "Charging sessions overview."},
            {"method": "POST", "path": "/op_charging_sessions/<session_id>/star", "description": "Star/unstar a session."},
            {"method": "GET", "path": "/op_charging_sessions/<session_id>/export_sql", "description": "Export session as SQL."},
            {"method": "POST", "path": "/op_charging_sessions/import_sql", "description": "Import session SQL dump."},
            {"method": "GET", "path": "/op_charging_session_analysis", "description": "Session analysis dashboard."},
            {"method": "GET", "path": "/op_charging_session_analysis/training_data", "description": "Training data download."},
            {"method": "GET/POST", "path": "/op_vehicle_analytics_sessions", "description": "Vehicle analytics session list."},
            {"method": "GET", "path": "/op_vehicle_session_list", "description": "Vehicle session list view."},
            {"method": "POST", "path": "/op_vehicle_session_highlight", "description": "Highlight/unhighlight a vehicle session."},
            {"method": "POST", "path": "/op_station_highlight", "description": "Highlight/unhighlight a station."},
            {"method": "GET", "path": "/op_vehicle_session_analytics", "description": "Vehicle session analytics dashboard."},
        ],
    },
    {
        "title": "Configuration management",
        "anchor": "configuration-management",
        "endpoints": [
            {"method": "GET/POST", "path": "/op_cp_config", "description": "Charge point configuration snapshots."},
            {"method": "GET", "path": "/op_cp_config/config_entries", "description": "List stored config entries (JSON)."},
            {"method": "GET", "path": "/op_cp_config/compare", "description": "Compare configuration snapshots."},
            {"method": "POST", "path": "/op_cp_config/refresh", "description": "Refresh configuration cache."},
            {"method": "GET", "path": "/op_cp_authorization_log", "description": "Authorization log view."},
            {"method": "POST", "path": "/op_cp_config/update_entry", "description": "Update a stored configuration entry."},
            {"method": "GET", "path": "/op_ocpi_wallboxes", "description": "OCPI wallbox mapping view."},
            {"method": "GET/POST", "path": "/op_external_api", "description": "Configure external authorizeTransaction and OCPI backend."},
            {"method": "GET", "path": "/op_external_api/logs", "description": "Logs of external API callbacks."},
            {"method": "GET/POST", "path": "/op_ocpi", "description": "OCPI configuration UI."},
            {"method": "GET/POST", "path": "/op_ocpp_routing_configuration", "description": "(Also listed above) configure routing rules."},
            {"method": "GET/POST", "path": "/op_targets", "description": "(Also listed above) manage routing targets."},
        ],
    },
    {
        "title": "Proxy / redirect JSON APIs",
        "anchor": "proxy-json-apis",
        "endpoints": [
            {"method": "POST", "path": "/api/op_redirects", "description": "Create a redirect entry."},
            {"method": "POST", "path": "/api/op_redirects/upsert", "description": "Create or update a redirect entry."},
            {"method": "GET", "path": "/api/op_redirects", "description": "List redirect entries."},
            {"method": "DELETE", "path": "/api/op_redirects", "description": "Delete a redirect entry."},
            {"method": "PATCH", "path": "/api/op_redirects", "description": "Update redirect properties."},
        ],
    },
    {
        "title": "Diagnostics & firmware",
        "anchor": "diagnostics-firmware",
        "endpoints": [
            {"method": "POST", "path": "/op_download_diag", "description": "Download diagnostics for a station (form POST)."},
            {"method": "GET", "path": "/op_download_diag_get", "description": "Diagnostic download via query string."},
            {"method": "POST", "path": "/op_flush_reconnects", "description": "Reset reconnect counters."},
            {"method": "POST", "path": "/op_reboot_cp", "description": "Reboot a station via proxy."},
            {"method": "POST", "path": "/api/op_cp_restart", "description": "Token-protected proxy restart call (JSON API)."},
            {"method": "POST", "path": "/op_ftp_delete", "description": "Delete a file from FTP storage."},
            {"method": "GET", "path": "/op_ftp_files", "description": "List diagnostic FTP files."},
            {"method": "GET", "path": "/op_ftp_download", "description": "Download a diagnostic FTP file."},
            {"method": "GET", "path": "/op_virtual_charger", "description": "Virtual charger simulator UI."},
            {"method": "POST", "path": "/api/virtualcharger/start", "description": "Start a virtual charging session."},
            {"method": "GET", "path": "/api/virtualcharger/status", "description": "Virtual charger status."},
            {"method": "GET", "path": "/op_ftp_files", "description": "(Also lists diagnostics)."},
        ],
    },
    {
        "title": "Firmware management",
        "anchor": "firmware-management",
        "endpoints": [
            {"method": "GET", "path": "/op_firmware", "description": "Firmware overview."},
            {"method": "POST", "path": "/op_firmware_upload", "description": "Upload firmware file."},
            {"method": "POST", "path": "/op_firmware_delete", "description": "Delete firmware file by name."},
            {"method": "GET", "path": "/op_firmware_download/<fid>", "description": "Download firmware by internal ID."},
            {"method": "POST", "path": "/op_firmware_send", "description": "Start firmware update via proxy (station_id, filename)."},
            {"method": "GET", "path": "/static/firmware/<filename>", "description": "Serve firmware files without authentication."},
        ],
    },
    {
        "title": "Branding, system configuration, and external hooks",
        "anchor": "branding-system-configuration",
        "endpoints": [
            {"method": "GET/POST", "path": "/op_branding", "description": "Configure branding assets."},
            {"method": "GET/POST", "path": "/op_systemConfig", "description": "System configuration key-value store."},
            {"method": "GET/POST", "path": "/op_external_api", "description": "External API configuration (also listed above)."},
            {"method": "GET", "path": "/op_external_api/logs", "description": "External API callback logs."},
            {"method": "POST", "path": "/op_delete_redirect", "description": "Delete redirect (duplicate of proxy control)."},
            {"method": "POST", "path": "/op_wallboxSetConfig", "description": "Set configuration via proxy (duplicate of proxy control)."},
            {"method": "POST", "path": "/mock/charging-station/authorizeTransaction", "description": "Mock authorizeTransaction endpoint returning APPROVED."},
        ],
    },
    {
        "title": "Config checker and rules",
        "anchor": "config-checker-rules",
        "endpoints": [
            {"method": "GET", "path": "/op_", "description": "Config checker landing page."},
            {"method": "GET", "path": "/op_checker", "description": "Config checker overview."},
            {"method": "GET/POST", "path": "/op_fault_detection", "description": "Fault detection configuration."},
            {"method": "POST", "path": "/op_marked_wallboxes/<mark_id>/request_service", "description": "Request service for a marked wallbox."},
            {"method": "POST", "path": "/op_mark_clear", "description": "Clear a wallbox mark."},
            {"method": "POST", "path": "/op_mark_set", "description": "Mark a wallbox."},
            {"method": "GET/POST", "path": "/op_rulesets", "description": "Manage config rule sets."},
            {"method": "GET/POST", "path": "/op_rulesets/<rule_set>", "description": "Edit rules in a set."},
            {"method": "GET/POST", "path": "/op_station-rules", "description": "Assign stations to rule sets."},
            {"method": "GET", "path": "/op_station-rules/<station_id>", "description": "Station configuration vs. assigned rules."},
        ],
    },
    {
        "title": "File downloads and miscellaneous",
        "anchor": "file-downloads-misc",
        "endpoints": [
            {"method": "GET", "path": "/op_api_docs", "description": "API documentation viewer (duplicate of basics)."},
            {"method": "GET", "path": "/op_bhi_dashboard", "description": "Battery Health Index dashboard."},
            {"method": "GET", "path": "/op_bhi_methodology", "description": "BHI methodology page."},
            {"method": "GET", "path": "/op_bhi_degradation_tool", "description": "BHI degradation overview."},
            {"method": "GET/POST", "path": "/op_bhi_manual_odometer_entry", "description": "Manual odometer entry."},
            {"method": "GET/POST", "path": "/op_bhi_manual_soh_entry", "description": "Manual state-of-health entry."},
            {"method": "GET", "path": "/op_targets", "description": "(also in targets section) target management UI."},
            {"method": "GET", "path": "/api/docs/swagger.json", "description": "Swagger JSON for documented APIs."},
        ],
    },
]


DEFAULT_AC_CHARGING_LOSSES = Decimal("8")
_DECIMAL_TWO_PLACES = Decimal("0.01")
_DECIMAL_ONE_PLACE = Decimal("0.1")
_DECIMAL_THREE_PLACES = Decimal("0.001")
_TENANT_ENDPOINT_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,50}$")

DEFAULT_ACCESSIBILITY = "Test station"
DEFAULT_ACCESSIBILITY_LOCATION = "ParkingLot"

ACCESSIBILITY_CHOICES: tuple[str, ...] = (
    "Free publicly accessible",
    "Paying publicly accessible",
    "Restricted access",
    "Test station",
)

ACCESSIBILITY_LOCATION_CHOICES: tuple[str, ...] = (
    "ParkingGarage",
    "ParkingLot",
    "StreetSide",
    "IndoorGarage",
)

_ACCESSIBILITY_NORMALIZATION = {
    value.lower(): value for value in ACCESSIBILITY_CHOICES
}
_ACCESSIBILITY_NORMALIZATION.update({
    "test station": "Test station",
    "test_station": "Test station",
})

_ACCESSIBILITY_LOCATION_NORMALIZATION = {
    value.lower(): value for value in ACCESSIBILITY_LOCATION_CHOICES
}
_ACCESSIBILITY_LOCATION_NORMALIZATION.update({
    "parkinglog": "ParkingLot",
})


def _normalize_accessibility_value(value: Optional[Any]) -> str:
    if value is None:
        return DEFAULT_ACCESSIBILITY
    normalized = _ACCESSIBILITY_NORMALIZATION.get(str(value).strip().lower())
    return normalized or DEFAULT_ACCESSIBILITY


def _normalize_accessibility_location_value(value: Optional[Any]) -> str:
    if value is None:
        return DEFAULT_ACCESSIBILITY_LOCATION
    normalized = _ACCESSIBILITY_LOCATION_NORMALIZATION.get(
        str(value).strip().lower()
    )
    return normalized or DEFAULT_ACCESSIBILITY_LOCATION


def _normalize_country_code(value: Optional[Any]) -> Optional[str]:
    if value is None:
        return None
    text = str(value).strip().upper()
    if not text:
        return None
    if len(text) == 3 and text.isalpha():
        return text
    if len(text) == 2 and text.isalpha():
        mapped = ISO_3166_ALPHA2_TO_ALPHA3.get(text)
        if mapped:
            return mapped
    raise ValueError(
        "address_country muss einen gültigen ISO-3166 Alpha-3 Code enthalten."
    )


_HUBJECT_PAYMENT_OPTION_CHOICES: tuple[str, ...] = (
    "No Payment",
    "Direct",
    "Contract",
)
_HUBJECT_PAYMENT_OPTION_NORMALIZATION = {
    value.lower(): value for value in _HUBJECT_PAYMENT_OPTION_CHOICES
}
_HUBJECT_PAYMENT_OPTION_NORMALIZATION.update(
    {
        "nopayment": "No Payment",
        "no_payment": "No Payment",
        "no-payment": "No Payment",
    }
)


def _normalize_payment_options(value: Optional[Any]) -> list[str]:
    """Return canonical Hubject payment option values."""

    candidates: Iterable[Any]
    if value is None:
        candidates = DEFAULT_HUBJECT_PAYMENT_OPTIONS
    elif isinstance(value, str):
        text = value.strip()
        if not text:
            candidates = DEFAULT_HUBJECT_PAYMENT_OPTIONS
        else:
            try:
                parsed = json.loads(text)
            except (TypeError, ValueError):
                candidates = [part.strip() for part in text.split(",")]
            else:
                if isinstance(parsed, list):
                    candidates = parsed
                else:
                    candidates = [parsed]
    elif isinstance(value, (list, tuple, set)):
        candidates = value
    else:
        candidates = [value]

    normalized: list[str] = []
    seen: set[str] = set()
    for candidate in candidates:
        text = str(candidate or "").strip()
        if not text:
            continue
        canonical = _HUBJECT_PAYMENT_OPTION_NORMALIZATION.get(text.lower(), text)
        if canonical not in seen:
            normalized.append(canonical)
            seen.add(canonical)

    if not normalized:
        return list(DEFAULT_HUBJECT_PAYMENT_OPTIONS)
    return normalized
DEFAULT_PLUGS = "Type 2 Connector (Cable Attached)"
PLUG_TYPE_CHOICES: tuple[tuple[str, str], ...] = (
    (
        "Type 1 Connector (Cable Attached)",
        "Type 1 Connector (Cable Attached) – Kabelgebundener Typ-1-Stecker (IEC 62196-1 / SAE J1772)",
    ),
    (
        "Type 2 Outlet",
        "Type 2 Outlet – Typ-2-Steckdose (IEC 62196-1)",
    ),
    (
        "Type 2 Connector (Cable Attached)",
        "Type 2 Connector (Cable Attached) – Typ-2-Stecker mit fest angeschlossenem Kabel (IEC 62196-1)",
    ),
    (
        "Type 3 Outlet",
        "Type 3 Outlet – Typ-3-Steckdose (IEC 62196-1)",
    ),
    (
        "IEC 60309 Single Phase",
        "IEC 60309 Single Phase – Industriestecker einphasig (IEC 60309)",
    ),
    (
        "IEC 60309 Three Phase",
        "IEC 60309 Three Phase – Industriestecker dreiphasig (IEC 60309)",
    ),
    (
        "CCS Combo 2 Plug (Cable Attached)",
        "CCS Combo 2 Plug (Cable Attached) – DC-Stecker (\u201eCombined Charging System\u201c) mit aktivem Kabel, IEC62196-3 bzw. ISO/IEC 15118 Kontext",
    ),
    (
        "CCS Combo 1 Plug (Cable Attached)",
        "CCS Combo 1 Plug (Cable Attached) – DC-Stecker nach Typ 1/Kombi mit Kabel",
    ),
    (
        "CHAdeMO DC",
        "CHAdeMO DC – CHAdeMO DC-Stecker",
    ),
    (
        "Unspecified",
        "Unspecified – Nicht spezifiziert/oder unbekannter Steckertyp",
    ),
)
_PLUG_TYPE_NORMALIZATION = {value.lower(): value for value, _ in PLUG_TYPE_CHOICES}
DEFAULT_AUTHENTICATION_MODES = (
    "NFC RFID Classic",
    "REMOTE",
    "Direct Payment",
)
AUTHENTICATION_MODE_CHOICES = DEFAULT_AUTHENTICATION_MODES
_AUTHENTICATION_MODE_SET = {mode for mode in AUTHENTICATION_MODE_CHOICES}
DEFAULT_AUTHENTICATION_MODES_JSON = json.dumps(
    list(DEFAULT_AUTHENTICATION_MODES), ensure_ascii=False
)
VALUE_ADDED_SERVICE_CHOICES: tuple[str, ...] = (
    "Reservation",
    "DynamicPricing",
    "ParkingSensors",
    "MaximumPowerCharging",
    "PredictiveChargePointUsage",
    "ChargingPlans",
    "None",
    "RoofProvided",
)
_VALUE_ADDED_SERVICE_SET = {service for service in VALUE_ADDED_SERVICE_CHOICES}
_VALUE_ADDED_SERVICE_NORMALIZATION = {
    service.lower(): service for service in VALUE_ADDED_SERVICE_CHOICES
}
DEFAULT_HUBJECT_RENEWABLE_ENERGY = False
# Hubject expects the literal value "Not Available" (with a space) according to the
# OICP schema. Using "NotAvailable" causes the EVSE push to be rejected with a 400
# response, so keep the canonical string here.
DEFAULT_HUBJECT_CALIBRATION_DATA_AVAILABILITY = "Not Available"
# Hubject's schema expects specific PaymentOptionType literals. Normalize user input
# but keep the canonical spellings here to avoid unnecessary API rejections.
DEFAULT_HUBJECT_PAYMENT_OPTIONS = ("No Payment",)
DEFAULT_HUBJECT_VALUE_ADDED_SERVICES: tuple[str, ...] = ("None",)
DEFAULT_HUBJECT_VALUE_ADDED_SERVICES_JSON = json.dumps(
    list(DEFAULT_HUBJECT_VALUE_ADDED_SERVICES), ensure_ascii=False
)
DEFAULT_HUBJECT_CHARGING_FACILITIES: tuple[dict[str, Any], ...] = (
    {
        "ChargingFacilityStatus": "Available",
        "PowerType": "AC_3_PHASE",
        "Power": 22,
        "Voltage": 400,
        "Amperage": 32,
        "ChargingMode": "Mode_3",
    },
)
DEFAULT_HUBJECT_CHARGING_FACILITIES_JSON = json.dumps(
    list(DEFAULT_HUBJECT_CHARGING_FACILITIES), ensure_ascii=False
)


def _normalize_plug_type(value: Optional[Any], *, strict: bool = False) -> str:
    text = str(value or "").strip()
    if not text:
        return DEFAULT_PLUGS
    canonical = _PLUG_TYPE_NORMALIZATION.get(text.lower())
    if canonical:
        return canonical
    if strict:
        raise ValueError('Ungültige Plug-Auswahl.')
    return text
DEFAULT_HUBJECT_HOTLINE = "+498001234567"
DEFAULT_HUBJECT_IS_OPEN_24H = True
DEFAULT_HUBJECT_IS_COMPATIBLE = True
DEFAULT_HUBJECT_DYNAMIC_INFO_AVAILABLE = False
DEFAULT_HUBJECT_EVSE_STATUS = "Available"
HUBJECT_EVSE_STATUS_CHOICES: tuple[tuple[str, str], ...] = (
    ("Available", "Die Steckdose / EVSE ist verfügbar zum Laden."),
    ("Occupied", "Ein Fahrzeug lädt bzw. der Stecker steckt."),
    ("OutOfOrder", "Die EVSE ist ausgefallen oder hat einen Fehler."),
    ("Faulted", "Die EVSE ist ausgefallen oder hat einen Fehler."),
    ("Reserved", "Reserviert für Benutzer, ggf. Vorreservierung."),
    ("Unavailable", "Nicht verfügbar (z. B. Wartung, Sperre, Netzproblem)."),
    ("Blocked", "Gesperrt, z. B. wegen Nicht-Zahlung oder Zugangskontrolle."),
    ("Offline", "EVSE nicht erreichbar."),
)
DEFAULT_HUBJECT_TIMEOUT_SECONDS = 15.0

ISO_3166_ALPHA2_TO_ALPHA3: dict[str, str] = {
    "AT": "AUT",
    "BE": "BEL",
    "CH": "CHE",
    "CZ": "CZE",
    "DE": "DEU",
    "DK": "DNK",
    "ES": "ESP",
    "FI": "FIN",
    "FR": "FRA",
    "HU": "HUN",
    "IT": "ITA",
    "LI": "LIE",
    "LU": "LUX",
    "NL": "NLD",
    "NO": "NOR",
    "PL": "POL",
    "PT": "PRT",
    "SE": "SWE",
    "SK": "SVK",
    "US": "USA",
}

MAX_CHANGE_CONFIGURATION_ITEMS = 5
_DEFAULT_CHANGE_CONFIGURATION_ITEMS = tuple(
    {"enabled": False, "key": "", "value": ""}
    for _ in range(MAX_CHANGE_CONFIGURATION_ITEMS)
)

SERVER_STARTUP_DEFAULTS = {
    "local_authorize_offline": True,
    "authorize_remote_tx_requests": True,
    "local_auth_list_enabled": True,
    "authorization_cache_enabled": True,
    "change_availability_operative": True,
    "enforce_websocket_ping_interval": True,
    "trigger_meter_values_on_start": True,
    "trigger_status_notification_on_start": True,
    "trigger_boot_notification_on_message_before_boot": True,
    "heartbeat_interval": 300,
    "change_configuration_items": _DEFAULT_CHANGE_CONFIGURATION_ITEMS,
}

MAX_COMPARE_WALLBOXES = 5


OCPP_ROUTING_MESSAGE_CHOICES: tuple[tuple[str, str, str], ...] = (
    ("all", "__all__", "All OCPP Messages"),
    ("Authorize", "Authorize", "Authorize"),
    ("BootNotification", "BootNotification", "Boot Notification"),
    ("ChangeAvailability", "ChangeAvailability", "Change Availability"),
    ("GetConfiguration", "GetConfiguration", "GetConfiguration"),
    ("Heartbeat", "Heartbeat", "Heartbeat"),
    ("MeterValues", "MeterValues", "MeterValues"),
    ("RemoteStartTransaction", "RemoteStartTransaction", "RemoteStartTransaction"),
    ("RemoteStopTransaction", "RemoteStopTransaction", "RemoteStopTransaction"),
    ("StartTransaction", "StartTransaction", "StartTransaction"),
    ("StopTransaction", "StopTransaction", "StopTransaction"),
    ("UnlockConnector", "UnlockConnector", "UnlockConnector"),
    ("SetConfiguration", "SetConfiguration", "SetConfiguration"),
)


def _default_server_startup_config() -> dict[str, Any]:
    config = copy.deepcopy(SERVER_STARTUP_DEFAULTS)
    config["change_configuration_items"] = [
        dict(item) for item in config.get("change_configuration_items", [])
    ]
    return config

# ---------------------------------------------------------------------------
# Station identifier normalization
# ---------------------------------------------------------------------------


def normalize_station_id(path: str) -> str:
    """Return a station identifier without leading or trailing slashes."""
    return path.strip("/")


def _strip_if_str(value: Any) -> Any:
    if isinstance(value, str):
        return value.strip()
    return value


def _normalize_chargepoint_id(value: Any) -> str | None:
    """Normalize charge point identifiers read from the database or forms."""

    if value is None:
        return None

    if isinstance(value, (bytes, bytearray)):
        value = value.decode("utf-8", errors="ignore")

    text = str(value).strip()
    if not text:
        return None

    # Remove control characters that might slip in via CSV imports or manual edits.
    cleaned = text.replace("\r", "").replace("\n", "").replace("\t", "")
    return cleaned or None


def _coerce_db_bool(value: Any) -> bool:
    """Convert common database representations to a boolean."""

    if isinstance(value, bool):
        return value
    if isinstance(value, Decimal):
        return value != 0
    if isinstance(value, int):
        return value != 0
    if isinstance(value, (bytes, bytearray)):
        return any(value)
    if value is None:
        return False

    text = str(value).strip()
    if not text:
        return False

    try:
        return int(text) != 0
    except ValueError:
        return text.lower() in {"true", "yes", "on"}


def _parse_iso_datetime(value):
    if not value:
        return None
    try:
        cleaned = str(value).strip()
        if cleaned.endswith("Z"):
            cleaned = cleaned[:-1] + "+00:00"
        return datetime.datetime.fromisoformat(cleaned)
    except (TypeError, ValueError):
        return None


def _normalize_voucher_rfid(value: Any) -> str:
    if value is None:
        return ""
    return str(value).strip().upper()


def _parse_voucher_chargepoints(value: Any) -> list[str]:
    if value is None:
        return []
    if isinstance(value, (list, tuple, set)):
        entries = value
    else:
        entries = str(value).replace(";", ",").split(",")
    normalized: list[str] = []
    for entry in entries:
        cp_id = _normalize_chargepoint_id(entry)
        if cp_id:
            normalized.append(cp_id.upper())
    return sorted(set(normalized))


def _parse_voucher_valid_until(value: Any) -> datetime.datetime | None:
    if value is None:
        return None
    text = str(value).strip()
    if not text:
        return None
    try:
        parsed = datetime.datetime.fromisoformat(text)
    except ValueError:
        try:
            parsed = datetime.datetime.strptime(text, "%Y-%m-%d")
        except ValueError as exc:
            raise ValueError("Bitte ein gültiges Ablaufdatum angeben.") from exc
    return parsed


def _serialize_voucher_row(
    row: Mapping[str, Any], *, for_api: bool = False
) -> dict[str, Any]:
    """Normalize a voucher row for UI rendering or API responses."""

    energy_limit = Decimal(str(row.get("energy_kwh") or "0")).quantize(
        _DECIMAL_THREE_PLACES
    )
    used_wh = row.get("energy_used_wh") or 0
    used_kwh = (Decimal(int(used_wh)) / Decimal("1000")).quantize(
        _DECIMAL_THREE_PLACES
    )
    remaining = max(Decimal("0"), energy_limit - used_kwh).quantize(
        _DECIMAL_THREE_PLACES
    )

    allowed = _parse_voucher_chargepoints(row.get("allowed_chargepoints"))
    allowed_value: list[str] | str
    if for_api:
        allowed_value = allowed
    else:
        allowed_value = ",".join(allowed)

    valid_until_value = row.get("valid_until")
    if for_api and isinstance(valid_until_value, datetime.datetime):
        valid_until_value = valid_until_value.isoformat()

    energy_value: Decimal | str = energy_limit
    used_value: Decimal | str = used_kwh
    remaining_value: Decimal | str = remaining
    if for_api:
        energy_value = str(energy_limit)
        used_value = str(used_kwh)
        remaining_value = str(remaining)

    return {
        "id": row.get("id"),
        "rfid_uuid": row.get("rfid_uuid") or "",
        "energy_kwh": energy_value,
        "energy_used_wh": used_wh,
        "used_kwh": used_value,
        "remaining_kwh": remaining_value,
        "valid_until": valid_until_value,
        "allowed_chargepoints": allowed_value,
    }


def _parse_voucher_payload(
    payload: Mapping[str, Any], *, require_id: bool = False
) -> tuple[str, Decimal, datetime.datetime | None, str | None, int | None]:
    """Extract and validate voucher input fields."""

    voucher_id_raw = payload.get("id") or payload.get("voucher_id")
    if require_id:
        try:
            voucher_id = int(voucher_id_raw)
        except (TypeError, ValueError):
            raise ValueError("Ungültige Voucher-ID")
    else:
        voucher_id = None

    rfid_uuid = _normalize_voucher_rfid(payload.get("rfid_uuid"))
    if not rfid_uuid:
        raise ValueError("Bitte eine RFID UUID angeben.")

    energy_raw = str(payload.get("energy_kwh") or "").replace(",", ".")
    try:
        energy_kwh = Decimal(energy_raw)
    except (TypeError, InvalidOperation) as exc:
        raise ValueError("Bitte eine gültige Energiemenge in kWh angeben.") from exc
    if energy_kwh <= 0:
        raise ValueError("Die Energiemenge muss größer als 0 sein.")

    valid_until = _parse_voucher_valid_until(payload.get("valid_until"))
    allowed_cps = _parse_voucher_chargepoints(payload.get("allowed_chargepoints"))
    allowed_value = ",".join(allowed_cps) if allowed_cps else None

    return rfid_uuid, energy_kwh, valid_until, allowed_value, voucher_id


def _load_vouchers(
    conn: pymysql.connections.Connection, voucher_id: int | None = None
) -> list[dict[str, Any]]:
    query = (
        """
        SELECT id, rfid_uuid, energy_kwh, energy_used_wh, valid_until, allowed_chargepoints
        FROM op_vouchers
        """
    )
    params: tuple[Any, ...] = ()
    if voucher_id is not None:
        query += " WHERE id=%s"
        params = (voucher_id,)
    query += " ORDER BY rfid_uuid"

    with conn.cursor(pymysql.cursors.DictCursor) as cur:
        cur.execute(query, params)
        return cur.fetchall()


def _normalize_timestamp_to_utc(
    value: Optional[datetime.datetime],
) -> Optional[datetime.datetime]:
    """Return a timezone-aware UTC datetime for comparison operations."""

    if not isinstance(value, datetime.datetime):
        return None
    if value.tzinfo is None:
        return value.replace(tzinfo=datetime.timezone.utc)
    try:
        return value.astimezone(datetime.timezone.utc)
    except (OverflowError, OSError, ValueError):
        return value


def _format_datetime_local(value: Any) -> str:
    """Return a value suitable for datetime-local input fields."""

    if not value:
        return ""
    parsed = None
    if isinstance(value, datetime.datetime):
        parsed = value
    else:
        try:
            parsed = datetime.datetime.fromisoformat(str(value).replace("Z", ""))
        except Exception:
            return ""
    if parsed.tzinfo:
        parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
    return parsed.replace(microsecond=0).isoformat(timespec="minutes")


def _format_duration_seconds(seconds):
    if seconds is None:
        return "-"
    try:
        total = int(seconds)
    except (TypeError, ValueError):
        return "-"
    if total < 0:
        total = 0
    hours, remainder = divmod(total, 3600)
    minutes, secs = divmod(remainder, 60)
    parts = []
    if hours:
        parts.append(f"{hours}h")
    if minutes:
        parts.append(f"{minutes}m")
    if secs or not parts:
        parts.append(f"{secs}s")
    return " ".join(parts)


def _parse_optional_decimal(value, *, default=None):
    """Parse optional decimal input and return a quantized Decimal."""

    candidate = default
    if value is not None:
        cleaned = str(value).strip()
        if cleaned:
            normalized = cleaned.replace(",", ".")
            try:
                candidate = Decimal(normalized)
            except InvalidOperation as exc:
                raise ValueError(str(value)) from exc
    if candidate is None:
        return None
    return candidate.quantize(_DECIMAL_TWO_PLACES)


def _format_decimal_for_input(value):
    """Return a decimal value formatted for form inputs."""

    if value is None:
        return ""
    if isinstance(value, Decimal):
        normalized = value.normalize()
        text = format(normalized, "f")
    else:
        text = str(value)
    if "." in text:
        text = text.rstrip("0").rstrip(".")
    return text


def _safe_decimal(value):
    """Safely convert arbitrary numeric input to Decimal."""

    try:
        return Decimal(str(value))
    except (InvalidOperation, TypeError, ValueError):
        return None


def _post_ocpp_command(endpoint, payload):
    url = f"{OCPP_SERVER_API_BASE_URL}{endpoint}"
    try:
        response = requests.post(url, json=payload, timeout=10)
    except Exception as exc:
        raise RuntimeError(f"Failed to reach OCPP server: {exc}") from exc
    try:
        data = response.json()
    except ValueError:
        data = response.text
    if response.ok:
        return data
    if isinstance(data, dict) and data.get("error"):
        error_detail = data["error"]
    else:
        error_detail = data
    try:
        payload_text = json.dumps(payload, ensure_ascii=False)
    except Exception:
        payload_text = repr(payload)
    status_text = f"{response.status_code}: {response.reason or 'Unknown'}"
    message = (
        f"{status_text} – Aufruf: POST {url} mit Payload {payload_text} – "
        f"Serverantwort: {error_detail}"
    )
    raise RuntimeError(message)


def _get_ocpp_command(endpoint, params):
    url = f"{OCPP_SERVER_API_BASE_URL}{endpoint}"
    try:
        response = requests.get(url, params=params, timeout=10)
    except Exception as exc:
        raise RuntimeError(f"Failed to reach OCPP server: {exc}") from exc
    try:
        data = response.json()
    except ValueError:
        data = response.text
    if response.ok:
        return data
    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(data["error"])
    raise RuntimeError(str(data))


def _load_oicp_flags_for_stations(
    station_ids: Iterable[str],
) -> tuple[dict[str, bool], str | None]:
    normalized_ids: list[str] = []
    for candidate in station_ids:
        normalized = _normalize_chargepoint_id(candidate)
        if not normalized:
            continue
        if normalized in normalized_ids:
            continue
        normalized_ids.append(normalized)
    if not normalized_ids:
        return {}, None

    conn = None
    try:
        conn = get_db_conn()
        ensure_oicp_enable_table(conn)
        placeholders = ", ".join(["%s"] * len(normalized_ids))
        with conn.cursor() as cur:
            cur.execute(
                f"""
                SELECT chargepoint_id, enabled
                FROM op_server_oicp_enable
                WHERE chargepoint_id IN ({placeholders})
                """,
                normalized_ids,
            )
            rows = cur.fetchall()
        flags: dict[str, bool] = {}
        for row in rows:
            if isinstance(row, Mapping):
                cp_raw = row.get("chargepoint_id")
                enabled_raw = row.get("enabled")
            else:
                cp_raw = row[0]
                enabled_raw = row[1]
            cp_id = _normalize_chargepoint_id(cp_raw)
            if not cp_id:
                continue
            flags[cp_id] = _coerce_db_bool(enabled_raw)
        return flags, None
    except Exception as exc:
        app.logger.warning("Failed to load OICP flags", exc_info=True)
        return {}, f"OICP-Flags konnten nicht geladen werden: {exc}"
    finally:
        if conn is not None:
            conn.close()


def fetch_connected_stations(
    include_oicp_flags: bool = False, include_protocols: bool = False
):
    extra_errors: list[str] = []
    def _format_timestamp_display(raw_value: str | None) -> str:
        ts_dt = _parse_iso_datetime(raw_value)
        if ts_dt and ts_dt.tzinfo:
            return ts_dt.astimezone(datetime.timezone.utc).strftime(
                "%Y-%m-%d %H:%M:%S UTC"
            )
        if ts_dt:
            return ts_dt.strftime("%Y-%m-%d %H:%M:%S")
        return raw_value or "-"

    ocpp_version_lookup: dict[str, str] = {}
    liveness_by_station: dict[str, dict[str, Any]] = {}
    if include_protocols:
        try:
            resp = requests.get(CONNECTED_ENDPOINT, timeout=5)
            resp.raise_for_status()
            connected_payload = resp.json()
            for entry in connected_payload.get("connected", []):
                protocol_display = _normalize_ocpp_version_display(
                    entry.get("ocpp_subprotocol")
                )
                station_id = normalize_station_id(entry.get("station_id") or "")
                if station_id and protocol_display:
                    ocpp_version_lookup[station_id] = protocol_display
        except Exception as exc:  # pragma: no cover - network access
            extra_errors.append(f"OCPP-Versionen konnten nicht geladen werden: {exc}")

    liveness_params = {"sort": "at_risk"}
    if STATION_LIVENESS_WARNING_SECONDS is not None:
        liveness_params["threshold"] = STATION_LIVENESS_WARNING_SECONDS
    try:
        liveness_resp = requests.get(
            f"{OCPP_SERVER_API_BASE_URL}/api/station_liveness",
            params=liveness_params,
            timeout=5,
        )
        liveness_resp.raise_for_status()
        liveness_payload = liveness_resp.json()
        for entry in liveness_payload.get("stations", []):
            raw_station = entry.get("station_id") or entry.get("chargepoint_id") or ""
            normalized_station = normalize_station_id(raw_station)
            if normalized_station:
                liveness_by_station[normalized_station] = entry
    except Exception as exc:  # pragma: no cover - network access
        extra_errors.append(f"Liveness-Daten konnten nicht geladen werden: {exc}")

    try:
        response = requests.get(
            f"{OCPP_SERVER_API_BASE_URL}/api/connectedStations", timeout=5
        )
        response.raise_for_status()
        payload = response.json()
    except Exception as exc:
        app.logger.warning("Failed to fetch connected stations", exc_info=True)
        return [], str(exc)

    stations = []
    for item in payload.get("stations", []):
        raw_id = item.get("chargepoint_id") or item.get("station_id") or ""
        normalized_id = normalize_station_id(raw_id)
        since_raw = item.get("connected_since")
        since_display = _format_timestamp_display(since_raw)
        availability_raw = item.get("availability")
        availability_state = str(availability_raw).strip() if availability_raw else "Unknown"
        if availability_state not in {"Operative", "Inoperative"}:
            availability_state = "Unknown"
        availability_display = (
            availability_state
            if availability_state in {"Operative", "Inoperative"}
            else "Unbekannt"
        )
        availability_badge_class = {
            "Operative": "bg-gradient-success",
            "Inoperative": "bg-gradient-danger",
        }.get(availability_state, "bg-gradient-secondary")

        connector_entries: list[dict[str, Any]] = []
        for connector in item.get("connectors") or []:
            connector_entries.append(
                {
                    "connector_id": connector.get("connectorId"),
                    "status": connector.get("status"),
                    "error_code": connector.get("errorCode"),
                    "transaction_id": connector.get("transactionId"),
                    "start_method": connector.get("startMethod"),
                }
            )

        liveness_entry = liveness_by_station.get(normalized_id, {})
        last_seen_raw = liveness_entry.get("last_seen") if liveness_entry else None
        last_seen_display = (
            _format_timestamp_display(last_seen_raw) if liveness_entry else "-"
        )
        remaining_seconds_raw = liveness_entry.get("seconds_until_timeout")
        remaining_seconds: int | None = None
        if isinstance(remaining_seconds_raw, (int, float)):
            remaining_seconds = max(int(remaining_seconds_raw), 0)

        timeout_seconds_raw = liveness_entry.get("stale_timeout_seconds")
        timeout_seconds: int | None = None
        if isinstance(timeout_seconds_raw, (int, float)):
            timeout_seconds = max(int(timeout_seconds_raw), 0)

        stale_event_count_raw = liveness_entry.get("stale_event_count")
        stale_event_count: int | None
        if isinstance(stale_event_count_raw, (int, float)):
            stale_event_count = max(int(stale_event_count_raw), 0)
        else:
            stale_event_count = None

        timeout_risk = False
        timeout_badge_class = "bg-gradient-secondary"
        if remaining_seconds is not None:
            timeout_risk = (
                STATION_LIVENESS_WARNING_SECONDS is not None
                and remaining_seconds <= STATION_LIVENESS_WARNING_SECONDS
            )
            timeout_badge_class = (
                "bg-gradient-warning" if timeout_risk else "bg-gradient-success"
            )

        stations.append(
            {
                "station_id": normalized_id,
                "connected_since_display": since_display,
                "connected_since_raw": since_raw,
                "connected_seconds": item.get("connected_seconds"),
                "connected_duration": _format_duration_seconds(
                    item.get("connected_seconds")
                ),
                "last_seen_display": last_seen_display,
                "last_seen_raw": last_seen_raw,
                "availability_state": availability_state,
                "availability_display": availability_display,
                "availability_badge_class": availability_badge_class,
                "connectors": connector_entries,
                "seconds_until_timeout": remaining_seconds,
                "timeout_remaining_display": _format_duration_seconds(
                    remaining_seconds
                )
                if remaining_seconds is not None
                else None,
                "timeout_badge_class": timeout_badge_class,
                "timeout_risk": timeout_risk,
                "stale_event_count": stale_event_count,
                "stale_timeout_seconds": timeout_seconds,
            }
        )

    if include_oicp_flags:
        flags, flag_error = _load_oicp_flags_for_stations(
            station.get("station_id") for station in stations
        )
        for station in stations:
            station_id = station.get("station_id")
            station["oicp_enabled"] = flags.get(station_id, False)
        if flag_error:
            extra_errors.append(flag_error)

    if include_protocols and stations:
        for station in stations:
            normalized = station.get("station_id") or ""
            station["ocpp_version"] = ocpp_version_lookup.get(normalized)

    def _sort_key(entry: dict[str, Any]):
        remaining = entry.get("seconds_until_timeout")
        numeric_remaining = (
            float(remaining) if isinstance(remaining, (int, float)) else float("inf")
        )
        return (
            0 if entry.get("timeout_risk") else 1,
            numeric_remaining,
            entry.get("station_id") or "",
        )

    if liveness_by_station:
        stations.sort(key=_sort_key)
    else:
        stations.sort(key=lambda entry: entry.get("station_id") or "")
    combined_errors = "; ".join(error for error in extra_errors if error) or None
    return stations, combined_errors


_STATUS_INDICATOR_SUCCESS = {"available", "charging"}
_STATUS_INDICATOR_WARNING = {
    "preparing",
    "finishing",
    "suspendedev",
    "suspendedevse",
    "reserved",
}


def _determine_status_indicator(status: str, error_code: str | None) -> str:
    normalized_status = status.lower()
    normalized_error = (error_code or "").strip().lower()
    if normalized_error and normalized_error not in {"noerror", "none"}:
        return "danger"
    if normalized_status in _STATUS_INDICATOR_SUCCESS:
        return "success"
    if normalized_status in _STATUS_INDICATOR_WARNING:
        return "warning"
    return "danger"


def fetch_status_notifications():
    conn = None
    rows = []
    error_message = None
    try:
        conn = get_db_conn()
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT chargepoint_id, connector_id, status, error_code, info,
                       vendor_id, status_timestamp, updated_at
                FROM op_server_statusnotification
                ORDER BY chargepoint_id, connector_id
                """
            )
            rows = cur.fetchall()
    except Exception as exc:
        error_message = str(exc)
    finally:
        if conn is not None:
            conn.close()

    formatted = []
    for row in rows:
        raw_station_id = row.get("chargepoint_id") or ""
        status_value = row.get("status") or "Unknown"
        error_code = row.get("error_code") or ""
        info_value = row.get("info") or ""
        vendor_value = row.get("vendor_id") or ""
        status_ts = row.get("status_timestamp")
        updated_at = row.get("updated_at")
        formatted.append(
            {
                "chargepoint_id": normalize_station_id(raw_station_id),
                "connector_id": row.get("connector_id"),
                "status": status_value,
                "error_code": error_code,
                "info": info_value,
                "vendor_id": vendor_value,
                "status_timestamp": status_ts.strftime("%Y-%m-%d %H:%M:%S")
                if status_ts
                else "",
                "updated_at": updated_at.strftime("%Y-%m-%d %H:%M:%S")
                if updated_at
                else "",
                "indicator": _determine_status_indicator(status_value, error_code),
            }
        )

    return formatted, error_message


_LOCAL_AUTH_STATUS_CHOICES = [
    ("Accepted", "Accepted"),
    ("Blocked", "Blocked"),
    ("Expired", "Expired"),
    ("ConcurrentTx", "ConcurrentTx"),
    ("Invalid", "Invalid"),
]

_LOCAL_AUTH_STATUS_VALUES = {choice[0] for choice in _LOCAL_AUTH_STATUS_CHOICES}


def _parse_local_list_expiry(value):
    if not value:
        return None
    cleaned = str(value).strip()
    if not cleaned:
        return None
    cleaned = cleaned.replace(" ", "T", 1)
    try:
        dt = datetime.datetime.fromisoformat(cleaned)
    except ValueError as exc:
        raise ValueError("Ungültiges Ablaufdatum. Bitte ISO-Format verwenden.") from exc
    return dt


def _format_local_list_expiry_display(dt):
    if not dt:
        return ""
    if isinstance(dt, datetime.datetime):
        return dt.strftime("%Y-%m-%d %H:%M")
    return str(dt)


def _format_local_list_expiry_input(dt):
    if not dt or not isinstance(dt, datetime.datetime):
        return ""
    return dt.strftime("%Y-%m-%dT%H:%M")


def _format_local_list_expiry_payload(dt):
    if not dt:
        return None
    if isinstance(dt, datetime.datetime):
        return dt.replace(microsecond=0).isoformat()
    return str(dt)


def _load_local_rfid_entries(station_id):
    if not station_id:
        return []

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT id, station_id, id_tag, status, expiry_date, parent_id_tag
                FROM op_server_rfid
                WHERE station_id = %s
                ORDER BY id_tag
                """,
                (station_id,),
            )
            rows = cur.fetchall()
    finally:
        conn.close()
    return rows


def _load_all_rfid_entries():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT id, station_id, id_tag, status, expiry_date, parent_id_tag
                FROM op_server_rfid
                ORDER BY station_id, id_tag
                """
            )
            rows = cur.fetchall()
    finally:
        conn.close()
    return rows

# ---------------------------------------------------------------------------
# Helper functions for op_config storage
# ---------------------------------------------------------------------------


def get_config_value(key):
    triggers: list[dict] = []

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT config_value FROM op_config WHERE config_key=%s",
                (key,),
            )
            row = cur.fetchone()
            return row["config_value"] if row else None
    finally:
        conn.close()


def ensure_config_default(key: str, value: str) -> str:
    existing = get_config_value(key)
    if existing is None:
        set_config_value(key, value)
        return value
    return existing


def set_config_value(key, value):
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                INSERT INTO op_config (config_key, config_value)
                VALUES (%s, %s) AS new(config_key, config_value)
                ON DUPLICATE KEY UPDATE config_value = new.config_value
                """,
                (key, value),
            )
            conn.commit()
    finally:
        conn.close()


def get_dashboard_color_scheme() -> str:
    scheme = (get_config_value(DASHBOARD_COLOR_SCHEME_KEY) or "").strip().lower()
    if scheme in COLOR_SCHEME_VARIANTS:
        return scheme
    return DEFAULT_DASHBOARD_COLOR_SCHEME


def set_dashboard_color_scheme(scheme: str) -> None:
    if scheme not in COLOR_SCHEME_VARIANTS:
        raise ValueError(f"Unknown dashboard color scheme '{scheme}'")
    set_config_value(DASHBOARD_COLOR_SCHEME_KEY, scheme)


def get_dashboard_tenants() -> list[dict[str, Any]]:
    """Return dashboard tenant metadata sorted by name and identifier."""

    conn = None
    try:
        conn = get_db_conn()
        with conn.cursor() as cur:
            cur.execute(
                "SELECT tenant_id, name FROM op_tenants ORDER BY name, tenant_id"
            )
            rows = cur.fetchall()
        return list(rows)
    except Exception:
        LOGGER.exception("Failed to fetch dashboard tenants")
        return []
    finally:
        if conn is not None:
            try:
                conn.close()
            except Exception:
                LOGGER.warning(
                    "Failed to close tenant database connection", exc_info=True
                )


def _resolve_logo_full_path(relative_path: str) -> str | None:
    if not relative_path:
        return None
    normalized = relative_path.replace("\\", "/").lstrip("/")
    candidate = os.path.abspath(os.path.join(app.static_folder, normalized))
    static_root = os.path.abspath(app.static_folder)
    try:
        common = os.path.commonpath([static_root, candidate])
    except ValueError:
        return None
    if common != static_root:
        return None
    return candidate


def _load_logo_path_from_config_value(value: str | None) -> str | None:
    if not value:
        return None
    normalized = str(value).replace("\\", "/").strip()
    if not normalized:
        return None
    full_path = _resolve_logo_full_path(normalized)
    if not full_path or not os.path.isfile(full_path):
        return None
    return normalized


def _get_dashboard_logo_path_from_keys(keys: tuple[str, ...]) -> str | None:
    for key in keys:
        path = _load_logo_path_from_config_value(get_config_value(key))
        if path:
            return path
    return None


def get_dashboard_logo_path(theme: str | None = None) -> str | None:
    if theme == "light":
        return _get_dashboard_logo_path_from_keys(
            (DASHBOARD_LOGO_LIGHT_KEY, DASHBOARD_LOGO_KEY)
        )
    if theme == "dark":
        return _get_dashboard_logo_path_from_keys((DASHBOARD_LOGO_DARK_KEY,))
    return _get_dashboard_logo_path_from_keys(
        (DASHBOARD_LOGO_LIGHT_KEY, DASHBOARD_LOGO_KEY)
    ) or _get_dashboard_logo_path_from_keys((DASHBOARD_LOGO_DARK_KEY,))


def _remove_dashboard_logo_file(relative_path: str) -> None:
    full_path = _resolve_logo_full_path(relative_path)
    if not full_path:
        return
    try:
        os.remove(full_path)
    except OSError:
        pass


def _store_dashboard_logo_path(variant: str, relative_path: str | None) -> None:
    value = relative_path or ""
    if variant == "light":
        set_config_value(DASHBOARD_LOGO_LIGHT_KEY, value)
        set_config_value(DASHBOARD_LOGO_KEY, value)
    elif variant == "dark":
        set_config_value(DASHBOARD_LOGO_DARK_KEY, value)


def _maybe_delete_logo_file(relative_path: str, variant: str) -> None:
    other_variant = "dark" if variant == "light" else "light"
    other_path = get_dashboard_logo_path(other_variant)
    if other_path and other_path == relative_path:
        return
    _remove_dashboard_logo_file(relative_path)


def _parse_bool_config(value):
    if value is None:
        return False
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return bool(value)
    value_str = str(value).strip().lower()
    return value_str in {"1", "true", "yes", "on"}


def send_dashboard_email(subject: str, body: str, recipients: list[str]) -> None:
    if not recipients:
        raise RuntimeError("Kein Empfänger für die E-Mail angegeben")

    mail_cfg: Mapping[str, Any] = _config.get("mail") if isinstance(_config.get("mail"), Mapping) else {}

    smtp_host = get_config_value("smtp_host") or mail_cfg.get("server")
    if not smtp_host:
        raise RuntimeError("SMTP-Host (smtp_host) ist nicht konfiguriert (op_config oder config.json)")

    smtp_port_raw = get_config_value("smtp_port") or mail_cfg.get("port")
    try:
        smtp_port = int(smtp_port_raw) if smtp_port_raw else 25
    except (TypeError, ValueError):
        smtp_port = 25

    smtp_user = get_config_value("smtp_user") or mail_cfg.get("username") or None
    smtp_password = get_config_value("smtp_password") or mail_cfg.get("password") or None
    smtp_sender = get_config_value("smtp_sender") or mail_cfg.get("default_sender") or smtp_user
    if not smtp_sender:
        raise RuntimeError("Absenderadresse (smtp_sender) ist nicht konfiguriert (op_config oder config.json)")

    use_ssl = _parse_bool_config(get_config_value("smtp_use_ssl") or mail_cfg.get("use_ssl"))
    use_tls = _parse_bool_config(get_config_value("smtp_use_tls") or mail_cfg.get("use_tls")) if not use_ssl else False

    message = EmailMessage()
    message["Subject"] = subject
    message["From"] = smtp_sender
    message["To"] = ", ".join(recipients)
    message.set_content(body)

    try:
        if use_ssl:
            with smtplib.SMTP_SSL(smtp_host, smtp_port) as client:
                if smtp_user and smtp_password:
                    client.login(smtp_user, smtp_password)
                client.send_message(message)
        else:
            with smtplib.SMTP(smtp_host, smtp_port) as client:
                client.ehlo()
                if use_tls:
                    client.starttls()
                    client.ehlo()
                if smtp_user and smtp_password:
                    client.login(smtp_user, smtp_password)
                client.send_message(message)
    except smtplib.SMTPException as exc:  # pragma: no cover - network interaction
        raise RuntimeError(f"Fehler beim E-Mail-Versand: {exc}") from exc

# ---------------------------------------------------------------------------
# Configuration handling
# ---------------------------------------------------------------------------

# Optional global configuration file used by the other scripts as well
CONFIG_FILE = "config.json"
_CONFIG_PATH = Path(CONFIG_FILE)

try:
    with _CONFIG_PATH.open("r", encoding="utf-8") as f:
        _config = json.load(f)
except FileNotFoundError:
    _config = {}

alert_logger = logging.getLogger("dashboard_alerts")
_alert_notifier: FailureNotifier | None = None


def _build_alert_notifier(cfg: Mapping[str, Any] | None) -> FailureNotifier:
    alerts_cfg: Mapping[str, Any] | None = None
    if isinstance(cfg, Mapping):
        if isinstance(cfg.get("alerts"), Mapping):
            alerts_cfg = cfg.get("alerts")
        ocpi_cfg = cfg.get("ocpi_api") if isinstance(cfg.get("ocpi_api"), Mapping) else {}
        ocpi_alerts = ocpi_cfg.get("alerts") if isinstance(ocpi_cfg, Mapping) else {}
        if isinstance(ocpi_alerts, Mapping):
            combined = dict(alerts_cfg or {})
            combined.update(ocpi_alerts)
            alerts_cfg = combined
    return FailureNotifier.from_config(alerts_cfg, logger=alert_logger)


_alert_notifier = _build_alert_notifier(_config)

if _CONFIG_PATH.exists():
    _CONFIG_BASE_DIR = _CONFIG_PATH.resolve().parent
else:
    _CONFIG_BASE_DIR = Path(__file__).resolve().parent
_hubject_cfg = _config.get("hubject", {})


def _load_config_file() -> dict[str, Any]:
    try:
        with _CONFIG_PATH.open("r", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return {}


def _write_config_file(config: Mapping[str, Any]) -> None:
    temp_path = _CONFIG_PATH.with_suffix(".tmp")
    with temp_path.open("w", encoding="utf-8") as f:
        json.dump(config, f, ensure_ascii=False, indent=4, sort_keys=True)
    temp_path.replace(_CONFIG_PATH)


def _serialize_config_path(path: Path | None) -> str | None:
    if path is None:
        return None
    try:
        return str(path.relative_to(_CONFIG_BASE_DIR))
    except ValueError:
        return str(path)


_PNC_RELOAD_FLAG_PATH = (_CONFIG_BASE_DIR / "debug" / "pnc_reload.flag").resolve()

def _normalize_hidden_menu_name(name: str) -> str:
    return str(name).strip().lower().replace("-", "_")


def _load_hidden_menus(raw_config) -> set[str]:
    hidden: set[str] = set()

    def _add_entry(value: str) -> None:
        normalized = _normalize_hidden_menu_name(value)
        if normalized:
            hidden.add(normalized)

    if isinstance(raw_config, dict):
        for name, enabled in raw_config.items():
            if _parse_bool_config(enabled):
                _add_entry(name)
    else:
        for entry in raw_config or []:
            if isinstance(entry, str):
                _add_entry(entry)
            elif isinstance(entry, dict):
                for name, enabled in entry.items():
                    if _parse_bool_config(enabled):
                        _add_entry(name)

    return hidden


_dashboard_hidden_menus = _load_hidden_menus(_config.get("dashboard_hide_menus", []))

_logout_url = _config.get("logout_url")
if not _logout_url:
    _logout_url = "/logout"

log_cfg = _config.get("log_levels", {})
level_name = log_cfg.get("dashboard", "INFO").upper()
LOG_LEVEL = getattr(logging, level_name, logging.INFO)
logging.basicConfig(
    level=LOG_LEVEL,
    format="%(asctime)s %(levelname)s:%(name)s:%(message)s",
)

LOGGER = logging.getLogger(__name__)
logger = LOGGER
audit_logger = logging.getLogger("audit")
if not audit_logger.handlers:
    Path("debug").mkdir(parents=True, exist_ok=True)
    audit_handler = logging.FileHandler("debug/audit.log")
    audit_handler.setLevel(logging.INFO)
    audit_handler.setFormatter(
        logging.Formatter("%(asctime)s %(levelname)s:%(name)s:%(message)s")
    )
    audit_logger.addHandler(audit_handler)
audit_logger.setLevel(logging.INFO)

# Database credentials can be provided via environment or config.json
_mysql_cfg = {
    "host": "82.165.76.205",
    "port": 3306,
    "user": "walle",
    "password": "zm0dem123",
    "db": "ocppproxy",
    "charset": "utf8mb4",
}
_mysql_cfg.update(_config.get("mysql", {}))

DB_HOST = os.getenv("DB_HOST", _mysql_cfg.get("host"))
DB_PORT = int(os.getenv("DB_PORT", _mysql_cfg.get("port", 3306)))
DB_USER = os.getenv("DB_USER", _mysql_cfg.get("user"))
DB_PASSWORD = os.getenv("DB_PASSWORD", _mysql_cfg.get("password"))
DB_NAME = os.getenv("DB_NAME", _mysql_cfg.get("db"))
DB_CHARSET = _mysql_cfg.get("charset", "utf8mb4")

# ftp://diag:rem0tec0nnect2026x@217.160.79.201/home/diag
FTP_HOST = os.getenv('FTP_HOST', '217.160.79.201')
FTP_USER = os.getenv('FTP_USER', 'diag')
FTP_PASSWORD = os.getenv('FTP_PASSWORD', 'rem0tec0nnect2026x')
FTP_DIR = os.getenv('FTP_DIR', '/')

VIRTUAL_CHARGER_API = _config.get("virtual_charger_api", "http://127.0.0.1:9752")

# Utility to get a new DB connection
def get_db_conn():
    return pymysql.connect(
        host=DB_HOST,
        port=DB_PORT,
        user=DB_USER,
        password=DB_PASSWORD,
        db=DB_NAME,
        charset=DB_CHARSET,
        cursorclass=pymysql.cursors.DictCursor
    )


_tariff_service: TariffService | None = None
_token_service: TokenService | None = None


def get_tariff_service() -> TariffService:
    """Return a shared TariffService instance using the configured MySQL connection."""

    global _tariff_service
    if _tariff_service is None:
        mysql_cfg = {
            "host": DB_HOST,
            "port": DB_PORT,
            "user": DB_USER,
            "password": DB_PASSWORD,
            "db": DB_NAME,
            "charset": DB_CHARSET,
        }
        fallback_tariffs = _config.get("tariffs", []) if isinstance(_config, Mapping) else []
        _tariff_service = TariffService(mysql_cfg, fallback_tariffs=fallback_tariffs)
        _tariff_service.ensure_schema()
    return _tariff_service


def get_token_service() -> TokenService:
    """Return a shared TokenService instance using the dashboard DB config."""

    global _token_service
    if _token_service is None:
        _token_service = TokenService(get_db_conn)
    return _token_service


_SCHEMA_LOGGER = logging.getLogger(__name__)
_OP_REDIRECTS_COLUMNS_ENSURED = False
_CHARGING_SESSIONS_VEHICLE_COLUMN_ENSURED = False
_VEHICLE_SESSION_HIGHLIGHTS_TABLE_ENSURED = False
_STATION_HIGHLIGHTS_TABLE_ENSURED = False
_OICP_ENABLE_TABLE_ENSURED = False
_CP_METADATA_TABLE_ENSURED = False
_SERVER_CONFIG_TABLE_ENSURED = False
_CHARGING_SESSION_STARS_TABLE_ENSURED = False
_OCPP_ROUTING_RULES_TABLE_ENSURED = False
_OP_MESSAGES_ENDPOINT_COLUMN_ENSURED = False


def ensure_op_redirects_columns(conn=None):
    """Ensure optional op_redirects columns exist before use."""

    global _OP_REDIRECTS_COLUMNS_ENSURED
    if _OP_REDIRECTS_COLUMNS_ENSURED:
        return

    own_connection = False
    if conn is None:
        conn = get_db_conn()
        own_connection = True

    required_columns = {
        "ws_url": "ALTER TABLE op_redirects ADD COLUMN ws_url varchar(255) NOT NULL DEFAULT '' AFTER source_url",
        "location_name": "ALTER TABLE op_redirects ADD COLUMN location_name varchar(255) DEFAULT NULL",
        "location_link": "ALTER TABLE op_redirects ADD COLUMN location_link varchar(512) DEFAULT NULL",
        "webui_remote_access_url": "ALTER TABLE op_redirects ADD COLUMN webui_remote_access_url varchar(512) DEFAULT NULL",
        "load_management_remote_access_url": "ALTER TABLE op_redirects ADD COLUMN load_management_remote_access_url varchar(512) DEFAULT NULL",
        "strict_availability": "ALTER TABLE op_redirects ADD COLUMN strict_availability tinyint(1) NOT NULL DEFAULT 0 AFTER measure_ping",
        "charging_analytics": "ALTER TABLE op_redirects ADD COLUMN charging_analytics tinyint(1) NOT NULL DEFAULT 0 AFTER strict_availability",
        "extended_session_log": "ALTER TABLE op_redirects ADD COLUMN extended_session_log tinyint(1) NOT NULL DEFAULT 0 AFTER charging_analytics",
        "pnc_enabled": "ALTER TABLE op_redirects ADD COLUMN pnc_enabled tinyint(1) NOT NULL DEFAULT 0 AFTER charging_analytics",
        "ocpp_subprotocol": "ALTER TABLE op_redirects ADD COLUMN ocpp_subprotocol varchar(32) DEFAULT NULL AFTER ws_url",
        "comment": "ALTER TABLE op_redirects ADD COLUMN comment varchar(200) DEFAULT NULL",
        "disconnect_alert_enabled": "ALTER TABLE op_redirects ADD COLUMN disconnect_alert_enabled tinyint(1) NOT NULL DEFAULT 0 AFTER pnc_enabled",
        "disconnect_alert_email": "ALTER TABLE op_redirects ADD COLUMN disconnect_alert_email varchar(255) DEFAULT NULL AFTER disconnect_alert_enabled",
        "tenant_id": "ALTER TABLE op_redirects ADD COLUMN tenant_id int DEFAULT NULL",
    }

    try:
        with conn.cursor() as cur:
            cur.execute("SHOW COLUMNS FROM op_redirects")
            existing_columns = {row["Field"] for row in cur.fetchall()}

        missing_ddls = [
            ddl for column, ddl in required_columns.items() if column not in existing_columns
        ]

        if not missing_ddls:
            _OP_REDIRECTS_COLUMNS_ENSURED = True
            return

        with conn.cursor() as cur:
            for ddl in missing_ddls:
                cur.execute(ddl)
        conn.commit()
        _OP_REDIRECTS_COLUMNS_ENSURED = True
    except Exception:
        _SCHEMA_LOGGER.warning("Failed to ensure op_redirects columns", exc_info=True)
        raise
    finally:
        if own_connection and conn:
            conn.close()


def _ensure_redirect_columns_startup():
    try:
        ensure_op_redirects_columns()
    except Exception:
        # Already logged inside ensure_op_redirects_columns; continue startup without aborting
        pass


_ensure_redirect_columns_startup()


def ensure_oicp_enable_table(conn=None):
    """Ensure the dedicated OICP enablement table exists."""

    global _OICP_ENABLE_TABLE_ENSURED
    if _OICP_ENABLE_TABLE_ENSURED:
        return

    own_connection = False
    if conn is None:
        conn = get_db_conn()
        own_connection = True

    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_server_oicp_enable (
                    chargepoint_id VARCHAR(255) PRIMARY KEY,
                    enabled TINYINT(1) NOT NULL DEFAULT 0
                )
                """
            )
        conn.commit()
        _OICP_ENABLE_TABLE_ENSURED = True
    except Exception:
        if conn:
            conn.rollback()
        _SCHEMA_LOGGER.warning(
            "Failed to ensure op_server_oicp_enable table", exc_info=True
        )
        raise
    finally:
        if own_connection and conn:
            conn.close()


def ensure_cp_metadata_table(conn=None):
    """Ensure the cp_server_cp_metadata table exists."""

    global _CP_METADATA_TABLE_ENSURED
    if _CP_METADATA_TABLE_ENSURED:
        return

    own_connection = False
    if conn is None:
        conn = get_db_conn()
        own_connection = True

    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS cp_server_cp_metadata (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    chargepoint_id VARCHAR(255) NOT NULL,
                    operator_id VARCHAR(32) DEFAULT NULL,
                    operator_name VARCHAR(255) DEFAULT NULL,
                    evse_id VARCHAR(255) DEFAULT NULL,
                    latitude DECIMAL(10,7) DEFAULT NULL,
                    longitude DECIMAL(10,7) DEFAULT NULL,
                    address_country VARCHAR(3) DEFAULT NULL,
                    address_city VARCHAR(255) DEFAULT NULL,
                    address_street VARCHAR(255) DEFAULT NULL,
                    address_house_number VARCHAR(64) DEFAULT NULL,
                    address_postal_code VARCHAR(20) DEFAULT NULL,
                    charging_station_name_de VARCHAR(255) DEFAULT NULL,
                    charging_station_name_en VARCHAR(255) DEFAULT NULL,
                    charging_station_image TEXT DEFAULT NULL,
                    additional_data TEXT DEFAULT NULL,
                    default_action_type VARCHAR(32) NOT NULL DEFAULT 'fullLoad',
                    accessibility VARCHAR(255) DEFAULT NULL,
                    accessibility_location VARCHAR(255) DEFAULT NULL,
                    authentication_modes TEXT,
                    plugs VARCHAR(255) DEFAULT NULL,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    UNIQUE KEY uniq_cp_metadata_chargepoint (chargepoint_id)
                ) CHARACTER SET utf8mb4
                """
            )
            column_specs = (
                ('accessibility', "VARCHAR(255) DEFAULT NULL", DEFAULT_ACCESSIBILITY),
                (
                    'accessibility_location',
                    "VARCHAR(255) DEFAULT NULL",
                    DEFAULT_ACCESSIBILITY_LOCATION,
                ),
                ('authentication_modes', "TEXT", DEFAULT_AUTHENTICATION_MODES_JSON),
                ('plugs', "VARCHAR(255) DEFAULT NULL", DEFAULT_PLUGS),
                (
                    'value_added_services',
                    "TEXT",
                    DEFAULT_HUBJECT_VALUE_ADDED_SERVICES_JSON,
                ),
                (
                    'charging_facilities',
                    "TEXT",
                    DEFAULT_HUBJECT_CHARGING_FACILITIES_JSON,
                ),
                ('last_data_push_at', "DATETIME DEFAULT NULL", None),
                ('last_data_push_action', "VARCHAR(32) DEFAULT NULL", None),
                ('last_data_push_status', "VARCHAR(255) DEFAULT NULL", None),
                ('last_data_push_success', "TINYINT(1) DEFAULT NULL", None),
                ('last_status_push_at', "DATETIME DEFAULT NULL", None),
                ('last_status_push_action', "VARCHAR(32) DEFAULT NULL", None),
                ('last_status_value', "VARCHAR(64) DEFAULT NULL", None),
                ('last_status_push_status', "VARCHAR(255) DEFAULT NULL", None),
                ('last_status_push_success', "TINYINT(1) DEFAULT NULL", None),
            )
            for column_name, definition, default_value in column_specs:
                cur.execute(
                    "SHOW COLUMNS FROM cp_server_cp_metadata LIKE %s",
                    (column_name,),
                )
                if cur.fetchone():
                    continue
                cur.execute(
                    f"ALTER TABLE cp_server_cp_metadata ADD COLUMN {column_name} {definition}"
                )
                if default_value is None:
                    continue
                if column_name == 'authentication_modes':
                    cur.execute(
                        """
                        UPDATE cp_server_cp_metadata
                        SET authentication_modes=%s
                        WHERE authentication_modes IS NULL OR authentication_modes=''
                        """,
                        (default_value,),
                    )
                else:
                    cur.execute(
                        f"""
                        UPDATE cp_server_cp_metadata
                        SET {column_name}=%s
                        WHERE {column_name} IS NULL OR {column_name}=''
                        """,
                        (default_value,),
                    )
        conn.commit()
        _CP_METADATA_TABLE_ENSURED = True
    except Exception:
        if conn:
            conn.rollback()
        _SCHEMA_LOGGER.warning(
            "Failed to ensure cp_server_cp_metadata table", exc_info=True
        )
        raise
    finally:
        if own_connection and conn:
            conn.close()


def ensure_server_config_table(conn=None):
    """Ensure the op_server_config table exists with default values."""

    global _SERVER_CONFIG_TABLE_ENSURED
    if _SERVER_CONFIG_TABLE_ENSURED:
        return

    own_connection = False
    if conn is None:
        conn = get_db_conn()
        own_connection = True

    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_server_config (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    local_authorize_offline TINYINT(1) NOT NULL DEFAULT 1,
                    authorize_remote_tx_requests TINYINT(1) NOT NULL DEFAULT 1,
                    local_auth_list_enabled TINYINT(1) NOT NULL DEFAULT 1,
                    authorization_cache_enabled TINYINT(1) NOT NULL DEFAULT 1,
                    change_availability_operative TINYINT(1) NOT NULL DEFAULT 1,
                    enforce_websocket_ping_interval TINYINT(1) NOT NULL DEFAULT 1,
                    send_heartbeat_change_on_boot TINYINT(1) NOT NULL DEFAULT 1,
                    trigger_meter_values_on_start TINYINT(1) NOT NULL DEFAULT 1,
                    trigger_status_notification_on_start TINYINT(1) NOT NULL DEFAULT 1,
                    trigger_boot_notification_on_message_before_boot TINYINT(1) NOT NULL DEFAULT 1,
                    heartbeat_interval INT NOT NULL DEFAULT 300,
                    change_configuration_enabled_1 TINYINT(1) NOT NULL DEFAULT 0,
                    change_configuration_key_1 VARCHAR(255) DEFAULT NULL,
                    change_configuration_value_1 VARCHAR(255) DEFAULT NULL,
                    change_configuration_enabled_2 TINYINT(1) NOT NULL DEFAULT 0,
                    change_configuration_key_2 VARCHAR(255) DEFAULT NULL,
                    change_configuration_value_2 VARCHAR(255) DEFAULT NULL,
                    change_configuration_enabled_3 TINYINT(1) NOT NULL DEFAULT 0,
                    change_configuration_key_3 VARCHAR(255) DEFAULT NULL,
                    change_configuration_value_3 VARCHAR(255) DEFAULT NULL,
                    change_configuration_enabled_4 TINYINT(1) NOT NULL DEFAULT 0,
                    change_configuration_key_4 VARCHAR(255) DEFAULT NULL,
                    change_configuration_value_4 VARCHAR(255) DEFAULT NULL,
                    change_configuration_enabled_5 TINYINT(1) NOT NULL DEFAULT 0,
                    change_configuration_key_5 VARCHAR(255) DEFAULT NULL,
                    change_configuration_value_5 VARCHAR(255) DEFAULT NULL,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
                )
                """
            )
            cur.execute(
                "SHOW COLUMNS FROM op_server_config LIKE 'enforce_websocket_ping_interval'"
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_server_config
                    ADD COLUMN enforce_websocket_ping_interval TINYINT(1) NOT NULL DEFAULT 1
                    AFTER change_availability_operative
                    """
                )

            cur.execute(
                "SHOW COLUMNS FROM op_server_config LIKE 'send_heartbeat_change_on_boot'"
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_server_config
                    ADD COLUMN send_heartbeat_change_on_boot TINYINT(1) NOT NULL DEFAULT 1
                    AFTER enforce_websocket_ping_interval
                    """
                )

            cur.execute(
                "SHOW COLUMNS FROM op_server_config LIKE 'trigger_meter_values_on_start'"
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_server_config
                    ADD COLUMN trigger_meter_values_on_start TINYINT(1) NOT NULL DEFAULT 1
                    AFTER send_heartbeat_change_on_boot
                    """
                )

            cur.execute(
                "SHOW COLUMNS FROM op_server_config LIKE 'trigger_status_notification_on_start'"
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_server_config
                    ADD COLUMN trigger_status_notification_on_start TINYINT(1) NOT NULL DEFAULT 1
                    AFTER trigger_meter_values_on_start
                    """
                )

            cur.execute(
                "SHOW COLUMNS FROM op_server_config LIKE 'trigger_boot_notification_on_message_before_boot'"
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_server_config
                    ADD COLUMN trigger_boot_notification_on_message_before_boot TINYINT(1) NOT NULL DEFAULT 1
                    AFTER trigger_status_notification_on_start
                    """
                )

            after_column = "heartbeat_interval"
            for idx in range(1, MAX_CHANGE_CONFIGURATION_ITEMS + 1):
                enabled_col = f"change_configuration_enabled_{idx}"
                key_col = f"change_configuration_key_{idx}"
                value_col = f"change_configuration_value_{idx}"
                cur.execute(
                    "SHOW COLUMNS FROM op_server_config LIKE %s",
                    (enabled_col,),
                )
                if not cur.fetchone():
                    cur.execute(
                        f"""
                        ALTER TABLE op_server_config
                        ADD COLUMN {enabled_col} TINYINT(1) NOT NULL DEFAULT 0 AFTER {after_column}
                        """
                    )
                after_column = enabled_col
                cur.execute(
                    "SHOW COLUMNS FROM op_server_config LIKE %s",
                    (key_col,),
                )
                if not cur.fetchone():
                    cur.execute(
                        f"""
                        ALTER TABLE op_server_config
                        ADD COLUMN {key_col} VARCHAR(255) DEFAULT NULL AFTER {after_column}
                        """
                    )
                after_column = key_col
                cur.execute(
                    "SHOW COLUMNS FROM op_server_config LIKE %s",
                    (value_col,),
                )
                if not cur.fetchone():
                    cur.execute(
                        f"""
                        ALTER TABLE op_server_config
                        ADD COLUMN {value_col} VARCHAR(255) DEFAULT NULL AFTER {after_column}
                        """
                    )
                after_column = value_col
            cur.execute("SELECT COUNT(*) AS cnt FROM op_server_config")
            row = cur.fetchone()
            count = row["cnt"] if isinstance(row, Mapping) else row[0]
            if count == 0:
                cur.execute(
                    """
                    INSERT INTO op_server_config (
                        id,
                        local_authorize_offline,
                        authorize_remote_tx_requests,
                        local_auth_list_enabled,
                        authorization_cache_enabled,
                        change_availability_operative,
                        enforce_websocket_ping_interval,
                        send_heartbeat_change_on_boot,
                        trigger_meter_values_on_start,
                        trigger_status_notification_on_start,
                        trigger_boot_notification_on_message_before_boot,
                        heartbeat_interval
                    ) VALUES (1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 300)
                    """
                )
        conn.commit()
        _SERVER_CONFIG_TABLE_ENSURED = True
    except Exception:
        if conn:
            conn.rollback()
        _SCHEMA_LOGGER.warning("Failed to ensure op_server_config table", exc_info=True)
        raise
    finally:
        if own_connection and conn:
            conn.close()


def ensure_ocpp_routing_rules_table(conn=None):
    """Ensure the op_ocpp_routing_rules table exists before use."""

    global _OCPP_ROUTING_RULES_TABLE_ENSURED
    if _OCPP_ROUTING_RULES_TABLE_ENSURED:
        return

    own_connection = False
    if conn is None:
        conn = get_db_conn()
        own_connection = True

    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_ocpp_routing_rules (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    chargepoint_id VARCHAR(255) NOT NULL,
                    message_type VARCHAR(64) NOT NULL,
                    enabled TINYINT(1) NOT NULL DEFAULT 0,
                    ocpp_backend_url VARCHAR(512) NULL,
                    ocpp_protocol VARCHAR(32) NULL,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    UNIQUE KEY uniq_chargepoint_message (chargepoint_id, message_type)
                ) CHARACTER SET utf8mb4
                """
            )
            # Ensure legacy installations get the additional columns as well.
            required_columns = {
                'ocpp_backend_url': "VARCHAR(512) NULL",
                'ocpp_protocol': "VARCHAR(32) NULL",
            }
            for column, definition in required_columns.items():
                cur.execute(
                    "SHOW COLUMNS FROM op_ocpp_routing_rules LIKE %s",
                    (column,),
                )
                if not cur.fetchone():
                    cur.execute(
                        f"ALTER TABLE op_ocpp_routing_rules ADD COLUMN {column} {definition}"
                    )
        conn.commit()
        _OCPP_ROUTING_RULES_TABLE_ENSURED = True
    except Exception:
        if conn:
            conn.rollback()
        _SCHEMA_LOGGER.warning(
            "Failed to ensure op_ocpp_routing_rules table", exc_info=True
        )
        raise
    finally:
        if own_connection and conn:
            conn.close()


def _parse_server_config_row(row: Mapping[str, Any] | None) -> dict[str, Any]:
    config = _default_server_startup_config()
    if not row:
        return config

    def _as_bool(value, default):
        if value is None:
            return default
        if isinstance(value, bool):
            return value
        if isinstance(value, (int, float)):
            return bool(int(value))
        text = str(value).strip().lower()
        if not text:
            return default
        if text in {"1", "true", "yes", "on", "enabled"}:
            return True
        if text in {"0", "false", "no", "off", "disabled"}:
            return False
        return default

    config["local_authorize_offline"] = _as_bool(
        row.get("local_authorize_offline"), config["local_authorize_offline"]
    )
    config["authorize_remote_tx_requests"] = _as_bool(
        row.get("authorize_remote_tx_requests"),
        config["authorize_remote_tx_requests"],
    )
    config["local_auth_list_enabled"] = _as_bool(
        row.get("local_auth_list_enabled"), config["local_auth_list_enabled"]
    )
    config["authorization_cache_enabled"] = _as_bool(
        row.get("authorization_cache_enabled"), config["authorization_cache_enabled"]
    )
    config["change_availability_operative"] = _as_bool(
        row.get("change_availability_operative"),
        config["change_availability_operative"],
    )
    config["enforce_websocket_ping_interval"] = _as_bool(
        row.get("enforce_websocket_ping_interval"),
        config["enforce_websocket_ping_interval"],
    )
    config["trigger_meter_values_on_start"] = _as_bool(
        row.get("trigger_meter_values_on_start"),
        config["trigger_meter_values_on_start"],
    )
    config["trigger_status_notification_on_start"] = _as_bool(
        row.get("trigger_status_notification_on_start"),
        config["trigger_status_notification_on_start"],
    )
    config["trigger_boot_notification_on_message_before_boot"] = _as_bool(
        row.get("trigger_boot_notification_on_message_before_boot"),
        config["trigger_boot_notification_on_message_before_boot"],
    )
    heartbeat_value = row.get("heartbeat_interval")
    try:
        heartbeat_int = int(str(heartbeat_value).strip())
    except (TypeError, ValueError, AttributeError):
        heartbeat_int = config["heartbeat_interval"]
    if heartbeat_int > 0:
        config["heartbeat_interval"] = heartbeat_int
    items: list[dict[str, Any]] = []
    for idx in range(1, MAX_CHANGE_CONFIGURATION_ITEMS + 1):
        enabled = _as_bool(
            row.get(f"change_configuration_enabled_{idx}"), False
        )
        key = row.get(f"change_configuration_key_{idx}")
        value = row.get(f"change_configuration_value_{idx}")
        items.append(
            {
                "enabled": enabled,
                "key": ("" if key is None else str(key)).strip(),
                "value": ("" if value is None else str(value)).strip(),
            }
        )
    if items:
        config["change_configuration_items"] = items
    return config


def ensure_idtags_table():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_idtags (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    real_uid VARCHAR(255) UNIQUE,
                    virtual_uid VARCHAR(255) NOT NULL,
                    comment VARCHAR(255)
                ) CHARACTER SET utf8mb4
                """
            )
            conn.commit()
    finally:
        conn.close()


def ensure_rfid_tables():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_rfid_mapping (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    chargepoint_id VARCHAR(255),
                    rfid_list VARCHAR(255),
                    single_uuid VARCHAR(50),
                    freecharging TINYINT(1) DEFAULT 0
                )
                """,
            )
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_server_rfid_lists (
                    rfid_list_id VARCHAR(255),
                    uuid VARCHAR(50)
                )
                """,
            )
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_emsp_tokens (
                    uid VARCHAR(255) PRIMARY KEY,
                    auth_id VARCHAR(255),
                    issuer VARCHAR(255),
                    valid TINYINT(1) DEFAULT 1,
                    whitelist VARCHAR(32),
                    local_rfid VARCHAR(255),
                    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    KEY idx_auth_id (auth_id),
                    KEY idx_local_rfid (local_rfid)
                )
                """,
            )
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_server_rfid (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    station_id VARCHAR(255) NOT NULL,
                    id_tag VARCHAR(50) NOT NULL,
                    status VARCHAR(32) NOT NULL DEFAULT 'Accepted',
                    expiry_date DATETIME NULL,
                    parent_id_tag VARCHAR(50) DEFAULT NULL,
                    UNIQUE KEY uniq_station_tag (station_id, id_tag)
                ) CHARACTER SET utf8mb4
                """,
            )
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_server_rfid_global (
                    uuid VARCHAR(50) PRIMARY KEY,
                    status VARCHAR(20) NOT NULL DEFAULT 'accepted'
                )
                """,
            )
            cur.execute(
                "SHOW COLUMNS FROM op_server_rfid_global LIKE 'status'"
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_server_rfid_global
                    ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'accepted' AFTER uuid
                    """
                )
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_rfid_free (
                    chargepoint_id VARCHAR(255) PRIMARY KEY
                )
                """,
            )
            conn.commit()
    finally:
        conn.close()


def ensure_voucher_tables():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_vouchers (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    rfid_uuid VARCHAR(64) NOT NULL,
                    energy_kwh DECIMAL(12,3) NOT NULL,
                    energy_used_wh INT NOT NULL DEFAULT 0,
                    valid_until DATETIME NULL,
                    allowed_chargepoints TEXT,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    UNIQUE KEY uniq_rfid (rfid_uuid)
                ) CHARACTER SET utf8mb4
                """
            )
        conn.commit()
    finally:
        conn.close()


def ensure_fault_detection_tables():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_fault_detection_rules (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    pattern_title VARCHAR(255) NOT NULL DEFAULT '',
                    pattern VARCHAR(255) NOT NULL,
                    explanation VARCHAR(255) NOT NULL,
                    criticality ENUM('low', 'medium', 'high') NOT NULL DEFAULT 'medium',
                    is_active TINYINT(1) NOT NULL DEFAULT 1,
                    created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP
                ) CHARACTER SET utf8mb4
                """
            )
            cur.execute(
                """
                SELECT 1
                FROM information_schema.columns
                WHERE table_schema = DATABASE()
                  AND table_name = 'op_fault_detection_rules'
                  AND column_name = 'pattern_title'
                LIMIT 1
                """
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_fault_detection_rules
                    ADD COLUMN pattern_title VARCHAR(255) NOT NULL DEFAULT ''
                        AFTER id
                    """
                )
            cur.execute(
                """
                SELECT 1
                FROM information_schema.columns
                WHERE table_schema = DATABASE()
                  AND table_name = 'op_fault_detection_rules'
                  AND column_name = 'criticality'
                LIMIT 1
                """
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_fault_detection_rules
                    ADD COLUMN criticality ENUM('low', 'medium', 'high') NOT NULL DEFAULT 'medium'
                        AFTER explanation
                    """
                )
            cur.execute(
                """
                SELECT 1
                FROM information_schema.columns
                WHERE table_schema = DATABASE()
                  AND table_name = 'op_fault_detection_rules'
                  AND column_name = 'is_active'
                LIMIT 1
                """
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_fault_detection_rules
                    ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1
                        AFTER criticality
                    """
                )
            cur.execute(
                "UPDATE op_fault_detection_rules SET criticality = 'medium' WHERE criticality IS NULL"
            )
            cur.execute(
                "UPDATE op_fault_detection_rules SET is_active = 1 WHERE is_active IS NULL"
            )
            cur.execute(
                """
                UPDATE op_fault_detection_rules
                SET pattern_title = CASE
                        WHEN pattern_title IS NULL OR pattern_title = '' THEN pattern
                        ELSE pattern_title
                    END
                """
            )
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_station_marks (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    station_id VARCHAR(255) NOT NULL,
                    reason VARCHAR(255) NOT NULL,
                    source VARCHAR(32) NOT NULL DEFAULT 'manual',
                    pattern_id INT DEFAULT NULL,
                    created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    service_requested_at TIMESTAMP NULL DEFAULT NULL,
                    UNIQUE KEY uniq_station_source_pattern (station_id, source, pattern_id),
                    CONSTRAINT fk_station_marks_pattern
                        FOREIGN KEY (pattern_id)
                        REFERENCES op_fault_detection_rules(id)
                        ON DELETE CASCADE
                ) CHARACTER SET utf8mb4
                """
            )
            cur.execute(
                """
                SELECT 1
                FROM information_schema.columns
                WHERE table_schema = DATABASE()
                  AND table_name = 'op_station_marks'
                  AND column_name = 'service_requested_at'
                LIMIT 1
                """
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_station_marks
                    ADD COLUMN service_requested_at TIMESTAMP NULL DEFAULT NULL
                        AFTER updated_at
                    """
                )
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_fault_detection_trigger (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    report_reason_id INT NOT NULL,
                    report_reason_trigger VARCHAR(255) NOT NULL,
                    report_reason_title VARCHAR(255) NOT NULL,
                    report_reason_description TEXT NOT NULL,
                    cluster VARCHAR(255) DEFAULT 'other',
                    is_enabled TINYINT(1) NOT NULL DEFAULT 1,
                    threshold INT NOT NULL DEFAULT 1,
                    priority ENUM('LOW', 'MEDIUM', 'HIGH') NOT NULL DEFAULT 'MEDIUM',
                    UNIQUE KEY uniq_op_fault_detection_trigger_reason_id (report_reason_id),
                    UNIQUE KEY uniq_op_fault_detection_trigger_reason_trigger (report_reason_trigger)
                ) CHARACTER SET utf8mb4
                """
            )
            cur.execute(
                """
                SELECT 1
                FROM information_schema.columns
                WHERE table_schema = DATABASE()
                  AND table_name = 'op_fault_detection_trigger'
                  AND column_name = 'cluster'
                LIMIT 1
                """
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_fault_detection_trigger
                    ADD COLUMN cluster VARCHAR(255) DEFAULT 'other'
                        AFTER report_reason_description
                    """
                )
            cur.execute(
                "ALTER TABLE op_fault_detection_trigger MODIFY threshold INT NOT NULL DEFAULT 1"
            )
            cur.execute(
                "ALTER TABLE op_fault_detection_trigger MODIFY is_enabled TINYINT(1) NOT NULL DEFAULT 1"
            )
            cur.execute(
                "UPDATE op_fault_detection_trigger SET threshold = 1 WHERE threshold IS NULL"
            )
            cur.execute(
                "UPDATE op_fault_detection_trigger SET is_enabled = 1 WHERE is_enabled IS NULL"
            )
            conn.commit()
    finally:
        conn.close()


def ensure_vehicle_catalog_table():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_vehicle_catalog (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    name VARCHAR(255) NOT NULL,
                    manufacturer VARCHAR(255) NOT NULL,
                    model VARCHAR(255) NOT NULL,
                    battery_size_gross_kwh DECIMAL(10, 2) DEFAULT NULL,
                    battery_size_net_kwh DECIMAL(10, 2) DEFAULT NULL,
                    ac_charging_losses_percent DECIMAL(5, 2) DEFAULT 8.00,
                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
                ) CHARACTER SET utf8mb4
                """
            )
            conn.commit()
    finally:
        conn.close()


def ensure_vehicle_fleet_table():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_vehicle_fleet (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    name VARCHAR(255) NOT NULL,
                    vehicle_catalog_id INT DEFAULT NULL,
                    build_year INT DEFAULT NULL,
                    vin VARCHAR(64) DEFAULT NULL,
                    license_plate VARCHAR(64) DEFAULT NULL,
                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    KEY idx_vehicle_catalog (vehicle_catalog_id)
                ) CHARACTER SET utf8mb4
                """,
            )
            conn.commit()
    finally:
        conn.close()


def ensure_tenant_tables():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_tenants (
                    tenant_id INT AUTO_INCREMENT PRIMARY KEY,
                    name VARCHAR(255) NOT NULL,
                    comment TEXT NULL,
                    endpoint VARCHAR(50) NOT NULL,
                    UNIQUE KEY uniq_op_tenants_endpoint (endpoint)
                ) ENGINE=InnoDB CHARACTER SET utf8mb4
                """,
            )
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_tenant_users (
                    user_id INT AUTO_INCREMENT PRIMARY KEY,
                    tenant_id INT NOT NULL,
                    name VARCHAR(255) NOT NULL,
                    email VARCHAR(255) NOT NULL,
                    login VARCHAR(64) NOT NULL,
                    password VARCHAR(255) NOT NULL,
                    hidden_menus TEXT NULL,
                    UNIQUE KEY uniq_op_tenant_users_login (login),
                    KEY idx_op_tenant_users_tenant (tenant_id),
                    CONSTRAINT fk_op_tenant_users_tenant
                        FOREIGN KEY (tenant_id) REFERENCES op_tenants (tenant_id)
                        ON DELETE CASCADE
                ) ENGINE=InnoDB CHARACTER SET utf8mb4
                """,
            )
            cur.execute(
                """
                SELECT 1
                FROM information_schema.columns
                WHERE table_schema = DATABASE()
                  AND table_name = 'op_tenant_users'
                  AND column_name = 'hidden_menus'
                LIMIT 1
                """
            )
            if not cur.fetchone():
                cur.execute(
                    """
                    ALTER TABLE op_tenant_users
                    ADD COLUMN hidden_menus TEXT NULL AFTER password
                    """
                )
            conn.commit()
    finally:
        conn.close()


def ensure_evse_id_mapping_table(conn=None):
    own_connection = False
    if conn is None:
        conn = get_db_conn()
        own_connection = True

    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_evse_id_mapping (
                    chargepoint_id VARCHAR(255) NOT NULL,
                    public_evse_id VARCHAR(255) NOT NULL,
                    PRIMARY KEY (chargepoint_id),
                    UNIQUE KEY uniq_op_evse_id_mapping_public_evse_id (public_evse_id)
                ) CHARACTER SET utf8mb4
                """,
            )
        conn.commit()
    finally:
        if own_connection:
            conn.close()


def _parse_tenant_form(form):
    """Validate tenant form data and return normalized fields with errors."""

    errors: list[str] = []

    name = (form.get("name") or "").strip()
    comment = (form.get("comment") or "").strip()
    endpoint = (form.get("endpoint") or "").strip()

    if not name:
        errors.append("Name is required.")
    elif len(name) > 255:
        errors.append("Name must be 255 characters or fewer.")

    if not endpoint:
        errors.append("Endpoint is required.")
    elif len(endpoint) > 50:
        errors.append("Endpoint must be 50 characters or fewer.")
    elif not _TENANT_ENDPOINT_PATTERN.match(endpoint):
        errors.append("Endpoint may only contain letters, digits, '-' or '_'.")

    return {
        "name": name,
        "comment": comment,
        "endpoint": endpoint,
    }, errors


MAIN_MENU_CHOICES: tuple[tuple[str, str], ...] = (
    ("ocpp_broker", "OCPP Broker"),
    ("digital_charger_twins", "Digital Charger Twins"),
    ("plug_and_charge", "Plug & Charge"),
    ("tenants", "Tenants"),
    ("mini_cpms", "Core CPMS"),
    ("charging_analytics", "Charging Analytics"),
    ("energy", "Energy"),
    ("vehicle_inspection", "Vehicle Inspection"),
    ("maintenance", "Maintenance"),
)


def _parse_tenant_user_form(
    form,
    *,
    require_password: bool,
    valid_tenant_ids: set[int],
    menu_choices: Iterable[tuple[str, str]] = MAIN_MENU_CHOICES,
):
    """Validate tenant user data and return normalized fields with errors."""

    errors: list[str] = []

    name = (form.get("name") or "").strip()
    email = (form.get("email") or "").strip()
    login = (form.get("login") or "").strip()
    password = (form.get("password") or "").strip()
    tenant_id_raw = (form.get("tenant_id") or "").strip()

    tenant_id: int | None = None
    if tenant_id_raw:
        try:
            tenant_id = int(tenant_id_raw)
        except (TypeError, ValueError):
            errors.append("Invalid tenant selected.")
        else:
            if tenant_id not in valid_tenant_ids:
                errors.append("Selected tenant does not exist.")
    else:
        errors.append("A tenant assignment is required.")

    if not name:
        errors.append("Name is required.")
    elif len(name) > 255:
        errors.append("Name must be 255 characters or fewer.")

    if not email:
        errors.append("E-mail is required.")
    elif len(email) > 255:
        errors.append("E-mail must be 255 characters or fewer.")

    if not login:
        errors.append("Login is required.")
    elif len(login) > 64:
        errors.append("Login must be 64 characters or fewer.")

    if require_password and not password:
        errors.append("Password is required.")

    available_menu_keys = {_normalize_hidden_menu_name(key) for key, _ in menu_choices}
    visible_raw = {(_normalize_hidden_menu_name(value)) for value in form.getlist("visible_menus")}
    hidden_menus = sorted(available_menu_keys - visible_raw)

    return {
        "tenant_id": tenant_id,
        "name": name,
        "email": email,
        "login": login,
        "password": password,
        "hidden_menus": json.dumps(hidden_menus),
    }, errors


def _ensure_default_ocpi_backend(conn, cur) -> Optional[int]:
    cur.execute("SELECT backend_id FROM op_ocpi_backends LIMIT 1")
    row = cur.fetchone()
    if row:
        return row["backend_id"]

    ocpi_url = get_config_value("ocpi_backend_url")
    ocpi_token = get_config_value("ocpi_backend_token")
    ocpi_modules = get_config_value("ocpi_backend_modules") or "cdrs"
    ocpi_enabled = 1 if (get_config_value("ocpi_backend_enabled") or "0") == "1" else 0

    if not ocpi_url:
        try:
            with open(CONFIG_FILE, "r", encoding="utf-8") as f:
                cfg = json.load(f)
        except OSError:
            cfg = {}
        ocpi_url = (
            cfg.get("ocpi", {}).get("cdrs_endpoint")
            if isinstance(cfg.get("ocpi"), dict)
            else None
        )
    if not ocpi_url:
        return None

    cur.execute(
        """
        INSERT INTO op_ocpi_backends (name, url, remote_versions_url, token, modules, enabled)
        VALUES (%s, %s, %s, %s, %s, %s)
        """,
        (
            "Default OCPI Backend",
            ocpi_url,
            ocpi_url,
            ocpi_token or "",
            ocpi_modules,
            ocpi_enabled,
        ),
    )
    conn.commit()
    return cur.lastrowid


def ensure_ocpi_backend_tables():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_ocpi_backends (
                    backend_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
                    name VARCHAR(255) NOT NULL,
                    url VARCHAR(1024) NOT NULL,
                    remote_versions_url VARCHAR(1024) DEFAULT NULL,
                    peer_versions_url VARCHAR(1024) DEFAULT NULL,
                    active_version VARCHAR(16) DEFAULT NULL,
                    token TEXT,
                    peer_token TEXT,
                    credentials_token TEXT,
                    modules VARCHAR(255) NOT NULL DEFAULT 'cdrs',
                    enabled TINYINT(1) NOT NULL DEFAULT 1,
                    last_credentials_status VARCHAR(255) NULL,
                    last_credentials_at TIMESTAMP NULL DEFAULT NULL,
                    created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    KEY idx_op_ocpi_backends_enabled (enabled)
                ) CHARACTER SET utf8mb4
                """,
            )

            cur.execute("SHOW COLUMNS FROM op_ocpi_backends LIKE 'remote_versions_url'")
            if not cur.fetchone():
                cur.execute("ALTER TABLE op_ocpi_backends ADD COLUMN remote_versions_url VARCHAR(1024) DEFAULT NULL AFTER url")

            cur.execute("SHOW COLUMNS FROM op_ocpi_backends LIKE 'peer_versions_url'")
            if not cur.fetchone():
                cur.execute("ALTER TABLE op_ocpi_backends ADD COLUMN peer_versions_url VARCHAR(1024) DEFAULT NULL AFTER remote_versions_url")

            cur.execute("SHOW COLUMNS FROM op_ocpi_backends LIKE 'active_version'")
            if not cur.fetchone():
                cur.execute("ALTER TABLE op_ocpi_backends ADD COLUMN active_version VARCHAR(16) DEFAULT NULL AFTER peer_versions_url")

            cur.execute("SHOW COLUMNS FROM op_ocpi_backends LIKE 'peer_token'")
            if not cur.fetchone():
                cur.execute("ALTER TABLE op_ocpi_backends ADD COLUMN peer_token TEXT NULL AFTER token")

            cur.execute("SHOW COLUMNS FROM op_ocpi_backends LIKE 'credentials_token'")
            if not cur.fetchone():
                cur.execute("ALTER TABLE op_ocpi_backends ADD COLUMN credentials_token TEXT NULL AFTER peer_token")

            cur.execute("SHOW COLUMNS FROM op_ocpi_backends LIKE 'last_credentials_status'")
            if not cur.fetchone():
                cur.execute(
                    "ALTER TABLE op_ocpi_backends ADD COLUMN last_credentials_status VARCHAR(255) NULL AFTER enabled"
                )

            cur.execute("SHOW COLUMNS FROM op_ocpi_backends LIKE 'last_credentials_at'")
            if not cur.fetchone():
                cur.execute(
                    "ALTER TABLE op_ocpi_backends ADD COLUMN last_credentials_at TIMESTAMP NULL DEFAULT NULL AFTER last_credentials_status"
                )

            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_ocpi_wallbox_backends (
                    station_id VARCHAR(50) NOT NULL,
                    backend_id INT NOT NULL,
                    enabled TINYINT(1) NOT NULL DEFAULT 1,
                    priority INT NOT NULL DEFAULT 100,
                    created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    PRIMARY KEY (station_id, backend_id),
                    KEY idx_op_ocpi_wallbox_backends_backend_id (backend_id),
                    CONSTRAINT fk_ocpi_wallbox_backend_backend FOREIGN KEY (backend_id) REFERENCES op_ocpi_backends(backend_id) ON DELETE CASCADE
                ) CHARACTER SET utf8mb4
                """,
            )

            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_ocpi_handshakes (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    backend_id INT NOT NULL,
                    state ENUM('open','closed') NOT NULL DEFAULT 'open',
                    status VARCHAR(255) NOT NULL,
                    detail TEXT NULL,
                    token TEXT NULL,
                    peer_url VARCHAR(1024) NULL,
                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    KEY idx_backend_state (backend_id, state),
                    CONSTRAINT fk_ocpi_handshake_backend FOREIGN KEY (backend_id) REFERENCES op_ocpi_backends(backend_id) ON DELETE CASCADE
                ) CHARACTER SET utf8mb4
                """,
            )

            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_ocpi_handshakes (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    backend_id INT NOT NULL,
                    state ENUM('open','closed') NOT NULL DEFAULT 'open',
                    status VARCHAR(255) NOT NULL,
                    detail TEXT NULL,
                    token TEXT NULL,
                    peer_url VARCHAR(1024) NULL,
                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    KEY idx_backend_state (backend_id, state),
                    CONSTRAINT fk_ocpi_handshake_backend FOREIGN KEY (backend_id) REFERENCES op_ocpi_backends(backend_id) ON DELETE CASCADE
                ) CHARACTER SET utf8mb4
                """,
            )

            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_ocpi_command_results (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    backend_id INT NULL,
                    command VARCHAR(64) NOT NULL,
                    module VARCHAR(64) NOT NULL,
                    ocpi_command_id VARCHAR(64) NULL,
                    success TINYINT(1) NOT NULL DEFAULT 0,
                    response_status VARCHAR(255) NULL,
                    response_body TEXT NULL,
                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    KEY idx_backend_module (backend_id, module)
                ) CHARACTER SET utf8mb4
                """,
            )

            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_ocpi_command_queue (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    backend_id INT NULL,
                    ocpi_command_id VARCHAR(64) NOT NULL,
                    command VARCHAR(64) NOT NULL,
                    status ENUM('queued','in_progress','succeeded','failed','cancelled') NOT NULL DEFAULT 'queued',
                    failure_count INT NOT NULL DEFAULT 0,
                    attempt_count INT NOT NULL DEFAULT 0,
                    station_id VARCHAR(255) NULL,
                    evse_id VARCHAR(255) NULL,
                    connector_id INT NULL,
                    transaction_id VARCHAR(64) NULL,
                    reservation_id VARCHAR(64) NULL,
                    ocpp_status VARCHAR(255) NULL,
                    ocpp_message TEXT NULL,
                    payload TEXT NULL,
                    last_error TEXT NULL,
                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    KEY idx_status (status),
                    KEY idx_backend_command (backend_id, command)
                ) CHARACTER SET utf8mb4
                """,
            )

            cur.execute("SHOW TABLES LIKE 'op_ocpi_wallboxes'")
            has_old_table = cur.fetchone() is not None

            backend_id = _ensure_default_ocpi_backend(conn, cur)

            if has_old_table and backend_id is not None:
                cur.execute("SELECT station_id, enabled FROM op_ocpi_wallboxes")
                for row in cur.fetchall():
                    cur.execute(
                        """
                        INSERT INTO op_ocpi_wallbox_backends (station_id, backend_id, enabled, priority)
                        VALUES (%s, %s, %s, %s)
                        ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), priority = VALUES(priority)
                        """,
                        (row["station_id"], backend_id, row["enabled"], 100),
                    )
                conn.commit()

            cur.execute("SHOW TABLES LIKE 'op_ocpi_exports'")
            has_exports_table = cur.fetchone() is not None
            if has_exports_table:
                cur.execute("SHOW COLUMNS FROM op_ocpi_exports LIKE 'backend_id'")
                if not cur.fetchone():
                    cur.execute(
                        "ALTER TABLE op_ocpi_exports ADD COLUMN backend_id INT NULL AFTER station_id"
                    )
                    conn.commit()
    finally:
        conn.close()


def _extract_ocpi_data(payload: Any) -> Any:
    if isinstance(payload, Mapping) and "data" in payload:
        return payload.get("data")
    return payload


def _ocpi_active_environment(cfg: Optional[Mapping[str, Any]] = None) -> str:
    env_raw = None
    if isinstance(cfg, Mapping):
        env_raw = cfg.get("environment")
    env = str(env_raw or "prod").lower()
    return "sandbox" if env == "sandbox" else "prod"


def _ocpi_env_value(cfg: Optional[Mapping[str, Any]], key: str) -> Any:
    if not isinstance(cfg, Mapping):
        return None
    env = _ocpi_active_environment(cfg)
    env_value = cfg.get(f"{key}_{env}")
    if env_value not in (None, ""):
        return env_value
    return cfg.get(key)


def _ocpi_versions_base_url() -> str:
    ocpi_api_cfg = _config.get("ocpi_api", {}) if isinstance(_config, Mapping) else {}
    base_url = os.environ.get("OCPI_API_BASE_URL") or _ocpi_env_value(ocpi_api_cfg, "base_url")
    if base_url:
        return str(base_url).rstrip("/")

    host = request.host.split(":")[0] if request else "127.0.0.1"
    scheme = "https" if str(ocpi_api_cfg.get("use_ssl", "")).lower() == "true" else "http"
    port = ocpi_api_cfg.get("port", 9760)
    return f"{scheme}://{host}:{port}"


def _ocpi_business_details() -> dict[str, Any]:
    details: dict[str, Any] = {}
    raw = _config.get("ocpi_api", {}).get("business_details") if isinstance(_config, Mapping) else None
    if isinstance(raw, Mapping):
        details.update(raw)
    if not details.get("name"):
        name = _config.get("ocpi_api", {}).get("business_name") if isinstance(_config, Mapping) else None
        details["name"] = name or "Pipelet"
    website = _config.get("ocpi_api", {}).get("business_website") if isinstance(_config, Mapping) else None
    if website and not details.get("website"):
        details["website"] = website
    return details


def _location_repo() -> LocationRepository:
    mysql_cfg = _config.get("mysql", {}) if isinstance(_config, Mapping) else {}
    return LocationRepository(mysql_cfg, tariff_service=get_tariff_service())


def _load_ocpi_tariffs() -> list[dict[str, Any]]:
    try:
        tariffs, _ = get_tariff_service().list_tariffs(limit=500)
        return tariffs
    except Exception:
        logger.debug("Falling back to config tariffs; tariff service lookup failed", exc_info=True)
    tariffs: list[dict[str, Any]] = []
    raw_tariffs = _config.get("tariffs") if isinstance(_config, Mapping) else None
    if isinstance(raw_tariffs, list):
        tariffs.extend([dict(item) for item in raw_tariffs if isinstance(item, Mapping)])
    return tariffs


def _available_tariff_ids() -> set[str]:
    ids: set[str] = set()
    for tariff in _load_ocpi_tariffs():
        tariff_id = tariff.get("id")
        if tariff_id:
            ids.add(str(tariff_id))
    return ids


def _group_tariff_assignments(assignments: Iterable[Mapping[str, Any]]) -> dict[str, list[dict[str, Any]]]:
    grouped: dict[str, list[dict[str, Any]]] = {}
    for entry in assignments:
        tariff_id = entry.get("tariff_id")
        if tariff_id is None:
            continue
        grouped.setdefault(str(tariff_id), []).append(dict(entry))
    return grouped


def _load_tariff_locations() -> list[dict[str, Any]]:
    repo = _location_repo()
    options: list[dict[str, Any]] = []
    for record in repo.location_records():
        location = record.get("location") or {}
        options.append(
            {
                "id": location.get("id"),
                "name": location.get("name") or location.get("id"),
                "evses": [
                    {
                        "uid": evse.get("uid"),
                        "status": evse.get("status"),
                    }
                    for evse in (location.get("evses") or [])
                ],
            }
        )
    return options


def _load_tariff_backends() -> list[dict[str, Any]]:
    conn = None
    rows: list[dict[str, Any]] = []
    try:
        conn = get_db_conn()
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_ocpi_backends'")
            if cur.fetchone():
                cur.execute("SELECT backend_id, name, modules, enabled FROM op_ocpi_backends ORDER BY name")
                rows = cur.fetchall()
    except Exception:
        logger.debug("Failed to load OCPI backends for tariff surcharges", exc_info=True)
    finally:
        if conn:
            conn.close()

    filtered: list[dict[str, Any]] = []
    for row in rows:
        modules = str(row.get("modules") or "")
        module_set = {item.strip() for item in modules.split(",") if item.strip()}
        if not module_set or "tariffs" in module_set:
            filtered.append(row)
    return filtered


DEFAULT_OCPI_RETRY_INTERVAL = 120
DEFAULT_OCPI_RETRY_BATCH_SIZE = 50
DEFAULT_OCPI_RETRY_MAX_ATTEMPTS = 3


SUPPORTED_OCPI_VERSIONS: tuple[str, ...] = ("2.3", "2.2", "2.1.1")


def _record_handshake_event(
    backend_id: int,
    *,
    state: str,
    status: str,
    detail: Optional[str] = None,
    token: Optional[str] = None,
    peer_url: Optional[str] = None,
) -> None:
    """Persist a handshake status update."""

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                INSERT INTO op_ocpi_handshakes (backend_id, state, status, detail, token, peer_url)
                VALUES (%s, %s, %s, %s, %s, %s)
                """,
                (backend_id, state, status, detail, token, peer_url),
            )
        conn.commit()
    finally:
        conn.close()


def _persist_credentials_status(
    backend_id: int,
    *,
    peer_token: Optional[str],
    peer_url: Optional[str],
    remote_versions_url: Optional[str],
    active_version: Optional[str],
    status: str,
) -> None:
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                UPDATE op_ocpi_backends
                SET peer_token=COALESCE(%s, peer_token),
                    peer_versions_url=COALESCE(%s, peer_versions_url),
                    remote_versions_url=COALESCE(%s, remote_versions_url),
                    active_version=COALESCE(%s, active_version),
                    last_credentials_status=%s,
                    last_credentials_at=NOW()
                WHERE backend_id=%s
                """,
                    (peer_token, peer_url, remote_versions_url, active_version, status, backend_id),
                )
        conn.commit()

        try:
            _record_handshake_event(
                backend_id,
                state="closed" if peer_token else "open",
                status=status,
                detail=f"versions_url={remote_versions_url or ''}",
                token=peer_token,
                peer_url=peer_url,
            )
        except Exception:
            logger.debug("Failed to persist handshake event", exc_info=True)
    finally:
        conn.close()


def _build_local_credentials_payload(backend: Mapping[str, Any]) -> dict[str, Any]:
    credentials_token = backend.get("credentials_token") or secrets.token_hex(32)
    if not backend.get("credentials_token"):
        conn = get_db_conn()
        try:
            with conn.cursor() as cur:
                cur.execute(
                    "UPDATE op_ocpi_backends SET credentials_token=%s WHERE backend_id=%s",
                    (credentials_token, backend.get("backend_id")),
                )
            conn.commit()
        finally:
            conn.close()

    ocpi_api_cfg = _config.get("ocpi_api", {}) if isinstance(_config, Mapping) else {}
    party_id = ocpi_api_cfg.get("party_id") or "WAL"
    country_code = ocpi_api_cfg.get("country_code") or "DE"
    return {
        "token": credentials_token,
        "url": f"{_ocpi_versions_base_url().rstrip('/')}/ocpi/versions",
        "roles": [
            {
                "role": "CPO",
                "party_id": party_id,
                "country_code": country_code,
                "business_details": _ocpi_business_details(),
            }
        ],
    }


def _select_supported_version(versions_data: Iterable[Any]) -> tuple[Optional[str], Optional[str]]:
    advertised: dict[str, Any] = {}
    for entry in versions_data:
        if not isinstance(entry, Mapping):
            continue
        version = str(entry.get("version") or "").strip()
        url = entry.get("url")
        if version and url:
            advertised[version] = url
    for version in SUPPORTED_OCPI_VERSIONS:
        if version in advertised:
            return version, advertised[version]
    return None, None


def _load_backend_for_credentials(backend_id: int) -> Optional[dict[str, Any]]:
    conn = get_db_conn()
    backend: Optional[dict[str, Any]] = None
    try:
        with conn.cursor(pymysql.cursors.DictCursor) as cur:
            cur.execute(
                """
                SELECT backend_id, name, url, remote_versions_url, peer_versions_url, token, peer_token, credentials_token, modules, enabled
                FROM op_ocpi_backends
                WHERE backend_id=%s
                """,
                (backend_id,),
            )
            backend = cur.fetchone()
            if backend and not backend.get("credentials_token"):
                new_token = secrets.token_hex(32)
                cur.execute(
                    "UPDATE op_ocpi_backends SET credentials_token=%s WHERE backend_id=%s",
                    (new_token, backend_id),
                )
                backend["credentials_token"] = new_token
        conn.commit()
    finally:
        conn.close()
    return backend


def _perform_credentials_exchange(backend_id: int) -> tuple[bool, str]:
    backend = _load_backend_for_credentials(backend_id)
    if not backend:
        try:
            _record_handshake_event(backend_id, state="open", status="backend missing")
        except Exception:
            logger.debug("Failed to persist handshake event", exc_info=True)
        return False, translate_text('Backend not found.')

    versions_url = (
        (backend.get("remote_versions_url") or "").strip()
        or (backend.get("peer_versions_url") or "").strip()
        or (backend.get("url") or "").strip()
    )
    token = (backend.get("token") or backend.get("peer_token") or "").strip()

    if not versions_url:
        try:
            _record_handshake_event(
                backend_id,
                state="open",
                status="versions url missing",
                detail="no versions url configured",
            )
        except Exception:
            logger.debug("Failed to persist handshake event", exc_info=True)
        return False, translate_text('Please configure the OCPI versions URL before exchanging credentials.')
    if not token:
        try:
            _record_handshake_event(
                backend_id,
                state="open",
                status="bootstrap token missing",
                detail="no peer token or bootstrap token available",
            )
        except Exception:
            logger.debug("Failed to persist handshake event", exc_info=True)
        return False, translate_text('A bootstrap token is required to contact the peer credentials endpoint.')

    headers = {"Authorization": token if token.startswith("Token ") else f"Token {token}"}

    try:
        versions_resp = requests.get(versions_url, headers=headers, timeout=15)
        versions_payload = versions_resp.json()
    except ValueError as exc:
        _persist_credentials_status(
            backend_id,
            peer_token=None,
            peer_url=None,
            remote_versions_url=versions_url,
            active_version=None,
            status=f"versions parse failed: {exc}",
        )
        return False, translate_text('Failed to parse versions response: %(error)s', error=str(exc))
    except Exception as exc:  # pragma: no cover - network call
        _persist_credentials_status(
            backend_id,
            peer_token=None,
            peer_url=None,
            remote_versions_url=versions_url,
            active_version=None,
            status=f"versions failed: {exc}",
        )
        return False, translate_text('Failed to reach the peer versions endpoint: %(error)s', error=str(exc))

    versions_data = _extract_ocpi_data(versions_payload) or []
    if versions_resp.status_code >= 400:
        _persist_credentials_status(
            backend_id,
            peer_token=None,
            peer_url=None,
            remote_versions_url=versions_url,
            active_version=None,
            status=f"versions status {versions_resp.status_code}",
        )
        return False, translate_text(
            'Peer versions endpoint returned status %(status)s.', status=versions_resp.status_code
        )
    selected_version, version_url = _select_supported_version(versions_data)

    if not selected_version or not version_url:
        _persist_credentials_status(
            backend_id,
            peer_token=None,
            peer_url=None,
            remote_versions_url=versions_url,
            active_version=None,
            status="no compatible version",
        )
        return False, translate_text('The peer did not advertise a compatible OCPI version.')

    try:
        details_resp = requests.get(version_url, headers=headers, timeout=15)
        details_payload = details_resp.json()
    except ValueError as exc:
        _persist_credentials_status(
            backend_id,
            peer_token=None,
            peer_url=None,
            remote_versions_url=versions_url,
            active_version=selected_version,
            status=f"details parse failed: {exc}",
        )
        return False, translate_text('Failed to parse version details: %(error)s', error=str(exc))
    except Exception as exc:  # pragma: no cover - network call
        _persist_credentials_status(
            backend_id,
            peer_token=None,
            peer_url=None,
            remote_versions_url=versions_url,
            active_version=selected_version,
            status=f"details failed: {exc}",
        )
        return False, translate_text('Failed to fetch version details: %(error)s', error=str(exc))

    endpoints = []
    details_data = _extract_ocpi_data(details_payload) or {}
    if details_resp.status_code >= 400:
        _persist_credentials_status(
            backend_id,
            peer_token=None,
            peer_url=None,
            remote_versions_url=versions_url,
            active_version=selected_version,
            status=f"details status {details_resp.status_code}",
        )
        return False, translate_text(
            'Peer version endpoint returned status %(status)s.', status=details_resp.status_code
        )
    if isinstance(details_data, Mapping):
        endpoints = details_data.get("endpoints") or []
    credentials_url = None
    for endpoint in endpoints:
        if isinstance(endpoint, Mapping) and endpoint.get("identifier") == "credentials":
            credentials_url = endpoint.get("url")
            break

    if not credentials_url:
        _persist_credentials_status(
            backend_id,
            peer_token=None,
            peer_url=None,
            remote_versions_url=versions_url,
            active_version=selected_version,
            status="credentials endpoint missing",
        )
        return False, translate_text('The peer did not provide a credentials endpoint for the selected version.')

    payload = _build_local_credentials_payload(backend)
    try:
        cred_resp = requests.post(credentials_url, json=payload, headers=headers, timeout=15)
        if cred_resp.status_code in (404, 405):
            cred_resp = requests.put(credentials_url, json=payload, headers=headers, timeout=15)
        cred_data = cred_resp.json()
    except ValueError as exc:
        _persist_credentials_status(
            backend_id,
            peer_token=None,
            peer_url=None,
            remote_versions_url=versions_url,
            active_version=selected_version,
            status=f"credentials parse failed: {exc}",
        )
        return False, translate_text('Peer response did not contain valid JSON: %(error)s', error=str(exc))
    except Exception as exc:  # pragma: no cover - network call
        _persist_credentials_status(
            backend_id,
            peer_token=None,
            peer_url=None,
            remote_versions_url=versions_url,
            active_version=selected_version,
            status=f"credentials failed: {exc}",
        )
        return False, translate_text('Sending credentials failed: %(error)s', error=str(exc))

    response_data = _extract_ocpi_data(cred_data) or {}
    peer_token = response_data.get("token") if isinstance(response_data, Mapping) else None
    peer_url = response_data.get("url") if isinstance(response_data, Mapping) else None
    status_message = f"{cred_resp.status_code}"

    _persist_credentials_status(
        backend_id,
        peer_token=peer_token,
        peer_url=peer_url,
        remote_versions_url=versions_url,
        active_version=selected_version,
        status=f"credentials response {status_message}",
    )

    if peer_token:
        return True, translate_text('Credentials exchange completed successfully.')
    return False, translate_text('Credentials exchange finished but no peer token was returned.')


def ensure_external_api_logging_table():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_broker_api_logging (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    chargepoint_id VARCHAR(255),
                    partner_id VARCHAR(255),
                    module VARCHAR(255),
                    endpoint VARCHAR(255) NOT NULL,
                    request_payload LONGTEXT,
                    response_code INT,
                    response_body LONGTEXT,
                    KEY idx_created_at (created_at),
                    KEY idx_endpoint (endpoint),
                    KEY idx_partner (partner_id),
                    KEY idx_module (module),
                    KEY idx_response_code (response_code)
                ) CHARACTER SET utf8mb4
                """,
            )

            cur.execute(
                "SHOW COLUMNS FROM op_broker_api_logging LIKE 'response_body'"
            )
            column_exists = cur.fetchone() is not None
            if not column_exists:
                cur.execute(
                    """
                    ALTER TABLE op_broker_api_logging
                        ADD COLUMN response_body LONGTEXT
                    """
                )
            cur.execute(
                """
                SHOW COLUMNS FROM op_broker_api_logging LIKE 'partner_id'
                """
            )
            partner_exists = cur.fetchone() is not None
            if not partner_exists:
                cur.execute(
                    """
                    ALTER TABLE op_broker_api_logging
                        ADD COLUMN partner_id VARCHAR(255) AFTER chargepoint_id
                    """
                )
            cur.execute(
                """
                SHOW COLUMNS FROM op_broker_api_logging LIKE 'module'
                """
            )
            module_exists = cur.fetchone() is not None
            if not module_exists:
                cur.execute(
                    """
                    ALTER TABLE op_broker_api_logging
                        ADD COLUMN module VARCHAR(255) AFTER partner_id
                    """
                )

            cur.execute(
                "SHOW INDEX FROM op_broker_api_logging WHERE Key_name='idx_partner'"
            )
            partner_index = cur.fetchone() is not None
            if not partner_index:
                cur.execute(
                    """
                    ALTER TABLE op_broker_api_logging
                        ADD KEY idx_partner (partner_id)
                    """
                )
            cur.execute(
                "SHOW INDEX FROM op_broker_api_logging WHERE Key_name='idx_module'"
            )
            module_index = cur.fetchone() is not None
            if not module_index:
                cur.execute(
                    """
                    ALTER TABLE op_broker_api_logging
                        ADD KEY idx_module (module)
                    """
                )
            cur.execute(
                "SHOW INDEX FROM op_broker_api_logging WHERE Key_name='idx_response_code'"
            )
            status_index = cur.fetchone() is not None
            if not status_index:
                cur.execute(
                    """
                    ALTER TABLE op_broker_api_logging
                        ADD KEY idx_response_code (response_code)
                    """
                )
            conn.commit()
    finally:
        conn.close()


_ADMIN_AUDIT_READY = False


def ensure_admin_audit_table():
    """Ensure the admin audit trail table exists."""

    global _ADMIN_AUDIT_READY
    if _ADMIN_AUDIT_READY:
        return

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_admin_audit (
                    id BIGINT AUTO_INCREMENT PRIMARY KEY,
                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                    actor VARCHAR(255),
                    action VARCHAR(255) NOT NULL,
                    target_type VARCHAR(255) NOT NULL,
                    target_id VARCHAR(255) NULL,
                    details LONGTEXT,
                    KEY idx_created_at (created_at),
                    KEY idx_actor (actor),
                    KEY idx_target_type (target_type)
                ) CHARACTER SET utf8mb4
                """
            )
        conn.commit()
        _ADMIN_AUDIT_READY = True
    except Exception:
        app.logger.warning("Failed to ensure admin audit table", exc_info=True)
    finally:
        conn.close()


def _record_admin_audit(action: str, target_type: str, target_id: str | None = None, details: object | None = None) -> None:
    """Persist an admin audit entry without interrupting the user flow."""

    actor = get_dashboard_user() or "api"
    try:
        ensure_admin_audit_table()
    except Exception:
        app.logger.warning("Unable to prepare admin audit table", exc_info=True)
        return

    serialized_details = ""
    if details is not None:
        try:
            serialized_details = json.dumps(details, ensure_ascii=False, default=str)
        except Exception:
            serialized_details = str(details)

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                INSERT INTO op_admin_audit (actor, action, target_type, target_id, details)
                VALUES (%s, %s, %s, %s, %s)
                """,
                (actor, action, target_type, target_id, serialized_details),
            )
        conn.commit()
    except Exception:
        app.logger.warning("Failed to persist admin audit entry", exc_info=True)
    finally:
        conn.close()

    try:
        audit_logger.info(
            json.dumps(
                {
                    "event": "admin_audit",
                    "actor": actor,
                    "action": action,
                    "target_type": target_type,
                    "target_id": target_id,
                    "details": details,
                },
                ensure_ascii=False,
                default=str,
            )
        )
    except Exception:
        audit_logger.info("admin_audit | %s | %s | %s", actor, action, target_id)


def ensure_op_messages_endpoint_column(conn=None):
    """Ensure op_messages tables expose the ocpp_endpoint column."""

    global _OP_MESSAGES_ENDPOINT_COLUMN_ENSURED
    if _OP_MESSAGES_ENDPOINT_COLUMN_ENSURED:
        return

    own_connection = False
    if conn is None:
        conn = get_db_conn()
        own_connection = True

    try:
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_messages%';")
            table_rows = cur.fetchall()
            table_names = [list(row.values())[0] for row in table_rows if row]

        if not table_names:
            _OP_MESSAGES_ENDPOINT_COLUMN_ENSURED = True
            return

        altered_tables: list[str] = []
        with conn.cursor() as cur:
            for table_name in table_names:
                cur.execute(
                    """
                    SELECT 1
                      FROM information_schema.columns
                     WHERE table_schema = DATABASE()
                       AND table_name = %s
                       AND column_name = 'ocpp_endpoint'
                     LIMIT 1
                    """,
                    (table_name,),
                )
                if cur.fetchone():
                    continue
                cur.execute(
                    f"ALTER TABLE `{table_name}` ADD COLUMN ocpp_endpoint varchar(25) DEFAULT NULL"
                )
                altered_tables.append(table_name)

        if altered_tables:
            conn.commit()
        _OP_MESSAGES_ENDPOINT_COLUMN_ENSURED = True
    except Exception:
        if conn:
            conn.rollback()
        _SCHEMA_LOGGER.warning(
            "Failed to ensure ocpp_endpoint column on op_messages tables", exc_info=True
        )
        raise
    finally:
        if own_connection and conn:
            conn.close()


def ensure_charging_sessions_vehicle_column(conn=None):
    """Ensure the op_charging_sessions table has the vehicle assignment column."""

    global _CHARGING_SESSIONS_VEHICLE_COLUMN_ENSURED
    if _CHARGING_SESSIONS_VEHICLE_COLUMN_ENSURED:
        return

    own_connection = False
    if conn is None:
        conn = get_db_conn()
        own_connection = True

    try:
        with conn.cursor() as cur:
            missing_alters: list[str] = []

            cur.execute(
                """
                SELECT 1
                FROM information_schema.columns
                WHERE table_schema = DATABASE()
                  AND table_name = 'op_charging_sessions'
                  AND column_name = 'vehicle_id'
                LIMIT 1
                """
            )
            vehicle_column_exists = cur.fetchone() is not None
            if not vehicle_column_exists:
                missing_alters.append(
                    "ALTER TABLE op_charging_sessions ADD COLUMN vehicle_id INT NOT NULL DEFAULT 0 AFTER id_tag"
                )

            cur.execute(
                """
                SELECT 1
                FROM information_schema.columns
                WHERE table_schema = DATABASE()
                  AND table_name = 'op_charging_sessions'
                  AND column_name = 'ocmf_energy_wh'
                LIMIT 1
                """
            )
            ocmf_column_exists = cur.fetchone() is not None
            if not ocmf_column_exists:
                missing_alters.append(
                    "ALTER TABLE op_charging_sessions ADD COLUMN ocmf_energy_wh INT NOT NULL DEFAULT 0 AFTER energyChargedWh"
                )

            for statement in missing_alters:
                cur.execute(statement)

        if missing_alters:
            conn.commit()
        _CHARGING_SESSIONS_VEHICLE_COLUMN_ENSURED = True
    finally:
        if own_connection and conn:
            conn.close()


def ensure_charging_session_stars_table(conn=None):
    """Ensure the helper table for starred charging sessions exists."""

    global _CHARGING_SESSION_STARS_TABLE_ENSURED
    if _CHARGING_SESSION_STARS_TABLE_ENSURED:
        return

    own_connection = False
    if conn is None:
        conn = get_db_conn()
        own_connection = True

    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_charging_session_stars (
                    session_id INT NOT NULL,
                    starred TINYINT(1) NOT NULL DEFAULT 1,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                        ON UPDATE CURRENT_TIMESTAMP,
                    PRIMARY KEY (session_id),
                    CONSTRAINT fk_session_star_session
                        FOREIGN KEY (session_id) REFERENCES op_charging_sessions (id)
                        ON DELETE CASCADE
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
                """
            )
        conn.commit()
        _CHARGING_SESSION_STARS_TABLE_ENSURED = True
    finally:
        if own_connection and conn:
            conn.close()


def ensure_vehicle_session_highlights_table(conn=None):
    """Ensure that the highlight table for vehicle sessions exists."""

    global _VEHICLE_SESSION_HIGHLIGHTS_TABLE_ENSURED
    if _VEHICLE_SESSION_HIGHLIGHTS_TABLE_ENSURED:
        return

    own_connection = False
    if conn is None:
        conn = get_db_conn()
        own_connection = True

    try:
        with conn.cursor() as cur:
            desired_collation = "utf8mb4_0900_ai_ci"
            charset = "utf8mb4"

            # Try to align the highlight table's collation with the one used by
            # the op_charging_sessions.transaction_id column in order to avoid
            # collation-mismatch errors when joining on this field.
            cur.execute("SHOW FULL COLUMNS FROM op_charging_sessions LIKE 'transaction_id'")
            source_column_info = cur.fetchone() or {}
            source_collation = source_column_info.get("Collation")
            if source_collation:
                desired_collation = source_collation
                charset = source_collation.split("_", 1)[0] or charset

            cur.execute(
                f"""
                CREATE TABLE IF NOT EXISTS op_vehicle_session_highlights (
                    transaction_id VARCHAR(191) NOT NULL,
                    PRIMARY KEY (transaction_id)
                ) CHARACTER SET {charset} COLLATE {desired_collation}
                """
            )
            cur.execute(
                """
                SELECT TABLE_COLLATION
                  FROM information_schema.TABLES
                 WHERE TABLE_SCHEMA = DATABASE()
                   AND TABLE_NAME = 'op_vehicle_session_highlights'
                """
            )
            table_info = cur.fetchone() or {}
            table_collation = table_info.get("TABLE_COLLATION")
            if table_collation and table_collation != desired_collation:
                cur.execute(
                    f"""
                    ALTER TABLE op_vehicle_session_highlights
                    CONVERT TO CHARACTER SET {charset} COLLATE {desired_collation}
                    """
                )
            cur.execute("SHOW FULL COLUMNS FROM op_vehicle_session_highlights LIKE 'transaction_id'")
            column_info = cur.fetchone() or {}
            column_collation = column_info.get("Collation")
            if column_collation and column_collation != desired_collation:
                cur.execute(
                    f"""
                    ALTER TABLE op_vehicle_session_highlights
                    MODIFY transaction_id VARCHAR(191)
                    CHARACTER SET {charset} COLLATE {desired_collation} NOT NULL
                    """
                )
        conn.commit()
        _VEHICLE_SESSION_HIGHLIGHTS_TABLE_ENSURED = True
    finally:
        if own_connection and conn:
            conn.close()


def ensure_station_highlights_table(conn=None):
    """Ensure the highlight table for wallboxes exists."""

    global _STATION_HIGHLIGHTS_TABLE_ENSURED
    if _STATION_HIGHLIGHTS_TABLE_ENSURED:
        return

    own_connection = False
    if conn is None:
        conn = get_db_conn()
        own_connection = True

    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_station_highlights (
                    station_id VARCHAR(191) NOT NULL,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                    PRIMARY KEY (station_id)
                ) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci
                """
            )
        conn.commit()
        _STATION_HIGHLIGHTS_TABLE_ENSURED = True
    finally:
        if own_connection and conn:
            conn.close()


_ALLOWED_METER_MEASURANDS = {
    "energy.active.import.register",
    "energy.active.import.interval",
    "soc",
    "power.active.import",
    "power.offered",
    "current.import",
    "current.offered",
    "voltage",
}


def _convert_energy_to_kwh(value, unit_lower):
    if unit_lower in {"wh", "watt-hour", "watt-hours"}:
        return value / Decimal(1000)
    return value


def _convert_power_to_kw(value, unit_lower):
    if unit_lower in {"w", "watt", "watts"}:
        return value / Decimal(1000)
    return value


def _identity_converter(value, unit_lower):
    return value


_MEASURAND_CHART_SETTINGS = {
    "energy.active.import.register": {
        "axis": "energy",
        "label": "Energy Charged",
        "unit_label": "kWh",
        "color": "#5e72e4",
        "converter": _convert_energy_to_kwh,
        "group_by_location": False,
        "group_by_phase": False,
        "hidden_by_default": False,
    },
    "energy.active.import.interval": {
        "axis": "energy",
        "label": "Energy Interval",
        "unit_label": "kWh",
        "color": "#4fd1c5",
        "converter": _convert_energy_to_kwh,
        "group_by_location": True,
        "group_by_phase": False,
        "hidden_by_default": True,
        "stepped": True,
    },
    "soc": {
        "axis": "soc",
        "label": "SoC",
        "unit_label": "%",
        "color": "#2dce89",
        "converter": _identity_converter,
        "group_by_location": False,
        "group_by_phase": False,
        "hidden_by_default": False,
    },
    "power.active.import": {
        "axis": "power",
        "label": "Power Active Import",
        "unit_label": "kW",
        "color": "#11cdef",
        "converter": _convert_power_to_kw,
        "group_by_location": True,
        "group_by_phase": False,
        "hidden_by_default": False,
    },
    "power.offered": {
        "axis": "power",
        "label": "Power Offered",
        "unit_label": "kW",
        "color": "#ffd600",
        "converter": _convert_power_to_kw,
        "group_by_location": True,
        "group_by_phase": False,
        "hidden_by_default": True,
    },
    "current.import": {
        "axis": "current",
        "label": "Current Import",
        "unit_label": "A",
        "color": "#f5365c",
        "converter": _identity_converter,
        "group_by_location": True,
        "group_by_phase": True,
        "hidden_by_default": True,
    },
    "current.offered": {
        "axis": "current",
        "label": "Current Offered",
        "unit_label": "A",
        "color": "#7b2ff7",
        "converter": _identity_converter,
        "group_by_location": True,
        "group_by_phase": False,
        "hidden_by_default": True,
    },
    "voltage": {
        "axis": "voltage",
        "label": "Voltage",
        "unit_label": "V",
        "color": "#fb6340",
        "converter": _identity_converter,
        "group_by_location": True,
        "group_by_phase": True,
        "hidden_by_default": True,
    },
    "weather.temperature": {
        "axis": "temperature",
        "label": "Ambient Temperature",
        "unit_label": "°C",
        "color": "#f4a261",
        "converter": _identity_converter,
        "group_by_location": False,
        "group_by_phase": False,
        "hidden_by_default": False,
    },
}


_CHART_AXIS_SETTINGS = {
    "energy": {
        "id": "energyAxis",
        "title": "Energy (kWh)",
        "position": "left",
        "beginAtZero": True,
    },
    "soc": {
        "id": "socAxis",
        "title": "State of Charge (%)",
        "position": "right",
        "suggestedMin": 0,
        "suggestedMax": 100,
        "grid": {"drawOnChartArea": False},
    },
    "power": {
        "id": "powerAxis",
        "title": "Power (kW)",
        "position": "left",
        "offset": True,
        "beginAtZero": True,
        "grid": {"drawOnChartArea": False},
    },
    "current": {
        "id": "currentAxis",
        "title": "Current (A)",
        "position": "right",
        "offset": True,
        "beginAtZero": True,
        "grid": {"drawOnChartArea": False},
    },
    "voltage": {
        "id": "voltageAxis",
        "title": "Voltage (V)",
        "position": "left",
        "offset": True,
    },
    "temperature": {
        "id": "temperatureAxis",
        "title": "Temperature (°C)",
        "position": "right",
        "grid": {"drawOnChartArea": False},
    },
}


_CHART_COLOR_PALETTE = [
    "#5e72e4",
    "#2dce89",
    "#11cdef",
    "#f5365c",
    "#fb6340",
    "#ffd600",
    "#172b4d",
    "#8965e0",
    "#20c997",
    "#ff8d72",
]


def _format_location_display(location):
    if not location:
        return ""
    text = str(location).strip()
    if not text:
        return ""
    upper = text.upper()
    if upper in {"EV", "EVSE", "INLET", "OUTLET"}:
        return upper
    return text.title()


def _format_phase_display(phase):
    if not phase:
        return ""
    text = str(phase).strip()
    if not text:
        return ""
    return text.upper()


def _extract_meter_samples_from_message(message, fallback_timestamp=None):
    """Return structured meter value samples from a raw OCPP message."""

    samples = []
    try:
        payload = json.loads(message)
    except Exception:
        return samples

    action = None
    body = None
    if isinstance(payload, list) and len(payload) >= 4:
        action = payload[2]
        body = payload[3]
    elif isinstance(payload, dict):
        action = payload.get("action") or payload.get("messageType")
        body = payload.get("payload") or payload

    action = (action or "").lower()

    def _normalize_timestamp(value):
        dt = _parse_iso_datetime(value)
        if dt is None and isinstance(fallback_timestamp, datetime.datetime):
            return fallback_timestamp
        return dt or fallback_timestamp

    def _append_sample(
        ts_value,
        raw_value,
        unit=None,
        *,
        measurand=None,
        location=None,
        phase=None,
        context=None,
    ):
        numeric = _safe_decimal(raw_value)
        if numeric is None:
            return
        ts = _normalize_timestamp(ts_value)
        normalized_measurand = (measurand or "Energy.Active.Import.Register").strip()
        measurand_key = normalized_measurand.lower()
        if measurand_key and measurand_key not in _ALLOWED_METER_MEASURANDS:
            return
        entry = {
            "timestamp": ts,
            "value": numeric,
            "unit": unit,
            "measurand": normalized_measurand,
        }
        if location:
            entry["location"] = location
        if phase:
            entry["phase"] = phase
        if context:
            entry["context"] = context
        samples.append(entry)

    if isinstance(body, dict):
        if action == "metervalues":
            for entry in body.get("meterValue") or []:
                entry_ts = entry.get("timestamp")
                sampled_values = entry.get("sampledValue") or []
                for sample in sampled_values:
                    measurand_value = sample.get("measurand")
                    normalized = (
                        str(measurand_value).strip()
                        if measurand_value is not None
                        else None
                    )
                    measurand_key = (normalized or "energy.active.import.register").lower()
                    if measurand_key not in _ALLOWED_METER_MEASURANDS:
                        continue
                    _append_sample(
                        entry_ts,
                        sample.get("value"),
                        sample.get("unit"),
                        measurand=normalized,
                        location=sample.get("location"),
                        phase=sample.get("phase"),
                        context=sample.get("context"),
                    )
        elif action == "stoptransaction":
            transaction_data = body.get("transactionData") or []
            for entry in transaction_data:
                entry_ts = entry.get("timestamp")
                for sample in entry.get("sampledValue") or []:
                    measurand_value = sample.get("measurand")
                    normalized = (
                        str(measurand_value).strip()
                        if measurand_value is not None
                        else None
                    )
                    measurand_key = (normalized or "energy.active.import.register").lower()
                    if measurand_key not in _ALLOWED_METER_MEASURANDS:
                        continue
                    _append_sample(
                        entry_ts,
                        sample.get("value"),
                        sample.get("unit"),
                        measurand=normalized,
                        location=sample.get("location"),
                        phase=sample.get("phase"),
                        context=sample.get("context"),
                    )
            if body.get("meterStop") is not None:
                _append_sample(
                    body.get("timestamp"),
                    body.get("meterStop"),
                    "Wh",
                    measurand="Energy.Active.Import.Register",
                )

    return samples


def _collect_meter_values_for_transaction(conn, transaction_id, start=None, end=None):
    """Collect meter values for a transaction from monthly message tables."""

    if not transaction_id:
        return []

    with conn.cursor() as cur:
        cur.execute("SHOW TABLES LIKE 'op_messages_%'")
        tables = [next(iter(row.values())) for row in cur.fetchall()]

    available = {
        table.replace("op_messages_", ""): table for table in tables if table.startswith("op_messages_")
    }

    months = set()
    for candidate in (start, end):
        if isinstance(candidate, datetime.datetime):
            months.add(candidate.strftime("%y%m"))
    if not months:
        months.add(datetime.datetime.utcnow().strftime("%y%m"))

    transaction_text = str(transaction_id)
    patterns = [f'%"transactionId":"{transaction_text}"%', f'%"transaction_id":"{transaction_text}"%']
    if transaction_text.isdigit():
        patterns.append(f'%"transactionId":{transaction_text}%')
        patterns.append(f'%"transaction_id":{transaction_text}%')

    results = []
    for month in sorted(months):
        table = available.get(month)
        if not table:
            continue
        placeholders = " OR ".join(["message LIKE %s"] * len(patterns))
        query = f"SELECT timestamp, message FROM {table} WHERE {placeholders} ORDER BY timestamp"
        with conn.cursor() as cur:
            cur.execute(query, tuple(patterns))
            rows = cur.fetchall()
        for row in rows:
            row_ts = row.get("timestamp")
            samples = _extract_meter_samples_from_message(row.get("message"), row_ts)
            results.extend(samples)

    return results


def _normalize_datetime_for_db(dt: Optional[datetime.datetime]) -> Optional[datetime.datetime]:
    if not isinstance(dt, datetime.datetime):
        return None
    if dt.tzinfo is None:
        return dt
    try:
        return dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
    except (OverflowError, OSError, ValueError):
        return dt.replace(tzinfo=None)


def _collect_weather_temperature_series(
    conn,
    chargepoint_id: Optional[str],
    start: Optional[datetime.datetime],
    end: Optional[datetime.datetime],
):
    if not chargepoint_id:
        return []

    start_dt = _normalize_datetime_for_db(start)
    if start_dt is None:
        return []

    end_dt = _normalize_datetime_for_db(end)
    if end_dt is None:
        end_dt = datetime.datetime.utcnow()

    if end_dt < start_dt:
        end_dt = start_dt

    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT observation_time, temperature
              FROM bhi_timeseries_weather
             WHERE chargepoint_id = %s
               AND observation_time BETWEEN %s AND %s
             ORDER BY observation_time
            """,
            (chargepoint_id, start_dt, end_dt),
        )
        rows = cur.fetchall()

    series = []
    for row in rows:
        obs_time = row.get("observation_time")
        temp_value = _safe_decimal(row.get("temperature"))
        if not isinstance(obs_time, datetime.datetime) or temp_value is None:
            continue
        series.append((obs_time, temp_value))

    return series


def _build_meter_chart_payload(
    meter_samples, session_start: datetime.datetime | None = None, session_end: datetime.datetime | None = None
):
    """Transform raw meter samples into Chart.js-ready structures.

    session_start/end are optional timestamps that, when provided, ensure the chart x-axis
    includes the full session window even if no meter samples exist at the boundaries.
    """

    meter_samples = meter_samples or []

    if not meter_samples and not session_start and not session_end:
        return [], [], {}

    ordered_samples = []

    if isinstance(session_start, datetime.datetime):
        ordered_samples.append((-2, {"timestamp": session_start, "_boundary": "start"}))

    if isinstance(session_end, datetime.datetime):
        ordered_samples.append((-1, {"timestamp": session_end, "_boundary": "end"}))

    ordered_samples.extend((sequence, sample) for sequence, sample in enumerate(meter_samples))

    def _sample_sort_key(item):
        sequence, sample = item
        ts = sample.get("timestamp")
        if isinstance(ts, datetime.datetime):
            try:
                if ts.tzinfo:
                    sort_value = ts.astimezone(datetime.timezone.utc).timestamp()
                else:
                    sort_value = ts.replace(tzinfo=datetime.timezone.utc).timestamp()
            except (OverflowError, OSError, ValueError):
                sort_value = sequence
            return (0, sort_value, sequence)
        return (1, sequence, 0)

    ordered_samples.sort(key=_sample_sort_key)

    labels = []
    label_index = {}
    fallback_counter = 0
    fallback_labels = {}

    used_axes = set()
    dataset_points = {}
    dataset_meta = {}

    color_iterator = cycle(_CHART_COLOR_PALETTE)
    used_colors = set()
    palette_size = len(_CHART_COLOR_PALETTE)

    def _pick_color(preferred):
        candidate = (preferred or "").strip()
        all_colors_consumed = palette_size and len(used_colors) >= palette_size
        if candidate and (candidate not in used_colors or all_colors_consumed):
            used_colors.add(candidate)
            return candidate
        if not palette_size:
            return candidate or "#000000"
        for _ in range(palette_size):
            next_color = next(color_iterator)
            if next_color not in used_colors:
                used_colors.add(next_color)
                return next_color
        # All palette colors are already in use; reuse the next color in sequence.
        next_color = next(color_iterator)
        used_colors.add(next_color)
        return next_color

    def _ensure_label(timestamp, sequence):
        nonlocal fallback_counter
        if isinstance(timestamp, datetime.datetime):
            key = ("dt", timestamp)
            label_text = timestamp.strftime("%Y-%m-%d %H:%M:%S")
        else:
            key = ("seq", sequence)
            label_number = fallback_labels.get(sequence)
            if label_number is None:
                fallback_counter += 1
                label_number = fallback_counter
                fallback_labels[sequence] = label_number
            label_text = f"Sample {label_number}"
        existing = label_index.get(key)
        if existing is not None:
            return existing
        index = len(labels)
        label_index[key] = index
        labels.append(label_text)
        return index

    for sequence, sample in ordered_samples:
        timestamp = sample.get("timestamp")
        label_idx = _ensure_label(timestamp, sequence)

        measurand_value = sample.get("measurand") or "Energy.Active.Import.Register"
        measurand_key = str(measurand_value).strip().lower()
        config = _MEASURAND_CHART_SETTINGS.get(measurand_key)
        if not config:
            continue

        location_value = sample.get("location") if config.get("group_by_location", True) else None
        phase_value = sample.get("phase") if config.get("group_by_phase", False) else None

        location_key = str(location_value or "").strip().lower() if location_value else ""
        phase_key = str(phase_value or "").strip().upper() if phase_value else ""
        dataset_key = (measurand_key, location_key, phase_key)

        raw_value = sample.get("value")
        numeric_value = raw_value if isinstance(raw_value, Decimal) else _safe_decimal(raw_value)
        if numeric_value is None:
            continue

        unit_lower = str(sample.get("unit") or "").strip().lower()
        converter = config.get("converter") or _identity_converter
        try:
            converted_value = converter(numeric_value, unit_lower)
        except (InvalidOperation, ZeroDivisionError):
            continue

        converted_decimal = (
            converted_value
            if isinstance(converted_value, Decimal)
            else _safe_decimal(converted_value)
        )
        if converted_decimal is None:
            continue

        try:
            point_value = float(converted_decimal)
        except (TypeError, ValueError):
            continue

        if dataset_key not in dataset_meta:
            base_label = config.get("label") or measurand_value
            label_parts = [base_label]
            if location_value:
                formatted_location = _format_location_display(location_value)
                if formatted_location:
                    label_parts.append(formatted_location)
            if phase_value:
                formatted_phase = _format_phase_display(phase_value)
                if formatted_phase:
                    label_parts.append(formatted_phase)
            combined_label = base_label if len(label_parts) == 1 else f"{base_label} ({', '.join(label_parts[1:])})"

            color = _pick_color(config.get("color"))
            dataset_meta[dataset_key] = {
                "label": combined_label,
                "axis": config.get("axis"),
                "unit": config.get("unit_label"),
                "color": color,
                "stepped": config.get("stepped", False),
                "hidden": config.get("hidden_by_default", False),
            }

        dataset_points.setdefault(dataset_key, {})[label_idx] = point_value
        axis_name = dataset_meta[dataset_key].get("axis")
        if axis_name:
            used_axes.add(axis_name)

    if not dataset_points:
        return [], [], {}

    datasets = []
    for key, points in dataset_points.items():
        meta = dataset_meta.get(key)
        if not meta:
            continue
        axis_conf = _CHART_AXIS_SETTINGS.get(meta.get("axis"))
        axis_id = axis_conf.get("id") if axis_conf else "y"

        data_series = [None] * len(labels)
        for idx, value in points.items():
            if 0 <= idx < len(data_series):
                data_series[idx] = value

        dataset_entry = {
            "label": meta["label"],
            "data": data_series,
            "yAxisID": axis_id,
            "borderColor": meta["color"],
            "backgroundColor": meta["color"],
            "fill": False,
            "spanGaps": True,
            "pointRadius": 2,
            "borderWidth": 2,
        }
        if not meta.get("stepped"):
            dataset_entry["tension"] = 0.1
        else:
            dataset_entry["stepped"] = True
        if meta.get("hidden"):
            dataset_entry["hidden"] = True
        if meta.get("unit"):
            dataset_entry["tooltipUnit"] = meta["unit"]

        datasets.append(dataset_entry)

    axes = {}
    for axis_key in sorted(used_axes):
        axis_conf = _CHART_AXIS_SETTINGS.get(axis_key)
        if not axis_conf:
            continue
        axis_id = axis_conf["id"]
        axis_entry = {
            "type": "linear",
            "position": axis_conf.get("position", "left"),
            "title": {"display": True, "text": axis_conf.get("title", axis_id)},
        }
        if axis_conf.get("grid") is not None:
            axis_entry["grid"] = dict(axis_conf["grid"])
        if axis_conf.get("offset"):
            axis_entry["offset"] = True
        if axis_conf.get("beginAtZero"):
            axis_entry["beginAtZero"] = True
        if axis_conf.get("suggestedMin") is not None:
            axis_entry["suggestedMin"] = axis_conf["suggestedMin"]
        if axis_conf.get("suggestedMax") is not None:
            axis_entry["suggestedMax"] = axis_conf["suggestedMax"]

        axes[axis_id] = axis_entry

    return labels, datasets, axes


def _format_decimal_value(value: Optional[Decimal], places: int = 3) -> Optional[str]:
    """Format a Decimal value with a fixed number of decimal places."""

    if value is None:
        return None
    decimal_value = value if isinstance(value, Decimal) else _safe_decimal(value)
    if decimal_value is None:
        return None

    quantizer_map = {
        1: _DECIMAL_ONE_PLACE,
        2: _DECIMAL_TWO_PLACES,
        3: _DECIMAL_THREE_PLACES,
    }
    quantizer = quantizer_map.get(places)
    if quantizer is None:
        try:
            quantizer = Decimal(1).scaleb(-places)
        except (InvalidOperation, ValueError):
            return None

    try:
        quantized = decimal_value.quantize(quantizer, rounding=ROUND_HALF_UP)
    except (InvalidOperation, ValueError):
        return None

    return format(quantized, f".{places}f")


def _build_soc_analytics(meter_samples, session_start):
    """Calculate SoC-based analytics metrics from meter samples."""

    if not meter_samples:
        return None

    energy_entries: list[tuple[datetime.datetime, Decimal]] = []
    for sample in meter_samples:
        measurand = str(sample.get("measurand") or "").strip().lower()
        if measurand != "energy.active.import.register":
            continue
        timestamp = _normalize_timestamp_to_utc(sample.get("timestamp"))
        if timestamp is None:
            continue
        raw_value = sample.get("value")
        numeric_value = raw_value if isinstance(raw_value, Decimal) else _safe_decimal(raw_value)
        if numeric_value is None:
            continue
        unit_lower = str(sample.get("unit") or "").strip().lower()
        try:
            converted_value = _convert_energy_to_kwh(numeric_value, unit_lower)
        except (InvalidOperation, ZeroDivisionError):
            continue
        energy_decimal = (
            converted_value
            if isinstance(converted_value, Decimal)
            else _safe_decimal(converted_value)
        )
        if energy_decimal is None:
            continue
        energy_entries.append((timestamp, energy_decimal))

    energy_entries = [
        (ts, value)
        for ts, value in energy_entries
        if isinstance(ts, datetime.datetime)
    ]
    energy_entries.sort(key=lambda item: item[0])
    if not energy_entries:
        return None

    soc_entries: list[tuple[datetime.datetime, Decimal]] = []
    for sample in meter_samples:
        measurand = str(sample.get("measurand") or "").strip().lower()
        if measurand != "soc":
            continue
        timestamp = _normalize_timestamp_to_utc(sample.get("timestamp"))
        if timestamp is None:
            continue
        raw_value = sample.get("value")
        numeric_value = raw_value if isinstance(raw_value, Decimal) else _safe_decimal(raw_value)
        if numeric_value is None:
            continue
        soc_entries.append((timestamp, numeric_value))

    soc_entries.sort(key=lambda item: item[0])
    if len(soc_entries) < 2:
        return None

    aligned_points: list[dict[str, Any]] = []
    energy_index = 0
    last_energy_value: Optional[Decimal] = None
    for timestamp, soc_value in soc_entries:
        while energy_index < len(energy_entries) and energy_entries[energy_index][0] <= timestamp:
            last_energy_value = energy_entries[energy_index][1]
            energy_index += 1
        if last_energy_value is None:
            continue
        aligned_points.append(
            {
                "timestamp": timestamp,
                "soc": soc_value,
                "energy": last_energy_value,
            }
        )

    if len(aligned_points) < 2:
        return None

    first_point = aligned_points[0]
    last_point = aligned_points[-1]
    soc_delta_total = last_point["soc"] - first_point["soc"]
    energy_delta_total = last_point["energy"] - first_point["energy"]

    overall_ratio: Optional[Decimal] = None
    if (
        isinstance(soc_delta_total, Decimal)
        and soc_delta_total > Decimal(0)
        and isinstance(energy_delta_total, Decimal)
        and energy_delta_total >= Decimal(0)
    ):
        try:
            overall_ratio = energy_delta_total / soc_delta_total
        except (InvalidOperation, ZeroDivisionError):
            overall_ratio = None

    if overall_ratio is None:
        return None

    formatted_overall = _format_decimal_value(overall_ratio, 3)
    if formatted_overall is None:
        return None

    threshold = None
    normalized_session_start = _normalize_timestamp_to_utc(session_start)
    if normalized_session_start is not None:
        threshold = normalized_session_start + datetime.timedelta(minutes=15)

    if threshold is None:
        filtered_points = aligned_points
    else:
        filtered_points = [point for point in aligned_points if point["timestamp"] >= threshold]

    segment_details: list[dict[str, Any]] = []
    chart_points: list[dict[str, float]] = []
    if len(filtered_points) >= 2:
        for index in range(1, len(filtered_points)):
            previous = filtered_points[index - 1]
            current = filtered_points[index]
            soc_delta = current["soc"] - previous["soc"]
            energy_delta = current["energy"] - previous["energy"]
            if (
                isinstance(soc_delta, Decimal)
                and isinstance(energy_delta, Decimal)
                and soc_delta > Decimal(0)
                and energy_delta >= Decimal(0)
            ):
                try:
                    segment_ratio = energy_delta / soc_delta
                except (InvalidOperation, ZeroDivisionError):
                    continue

                soc_midpoint: Optional[Decimal] = None
                try:
                    if isinstance(previous["soc"], Decimal) and isinstance(current["soc"], Decimal):
                        soc_midpoint = (previous["soc"] + current["soc"]) / Decimal(2)
                except (InvalidOperation, ZeroDivisionError):
                    soc_midpoint = None

                segment_details.append(
                    {
                        "ratio": segment_ratio,
                        "soc_start": previous.get("soc"),
                        "soc_end": current.get("soc"),
                        "soc_midpoint": soc_midpoint,
                    }
                )

    result = {
        "kwh_per_percentage": f"{formatted_overall} kWh/%",
        "min_kwh_per_percentage": "-",
        "max_kwh_per_percentage": "-",
        "state_of_health": "No Anomalies",
        "segments": [],
        "chart_points": chart_points,
    }

    def _select_soc_value(entry: dict[str, Any]) -> Optional[Decimal]:
        for key in ("soc_midpoint", "soc_end", "soc_start"):
            value = entry.get(key)
            if isinstance(value, Decimal):
                return value
        return None

    if segment_details:
        min_segment = min(segment_details, key=lambda entry: entry["ratio"])
        max_segment = max(segment_details, key=lambda entry: entry["ratio"])

        min_formatted = _format_decimal_value(min_segment["ratio"], 3)
        max_formatted = _format_decimal_value(max_segment["ratio"], 3)

        min_soc_display = None
        max_soc_display = None

        min_soc_value = _select_soc_value(min_segment)
        max_soc_value = _select_soc_value(max_segment)

        if min_soc_value is not None:
            min_soc_display = _format_decimal_value(min_soc_value, 1)
        if max_soc_value is not None:
            max_soc_display = _format_decimal_value(max_soc_value, 1)

        if min_formatted is not None:
            if min_soc_display is not None:
                result["min_kwh_per_percentage"] = f"{min_formatted} kWh/% @ {min_soc_display}% SoC"
            else:
                result["min_kwh_per_percentage"] = f"{min_formatted} kWh/%"
        if max_formatted is not None:
            if max_soc_display is not None:
                result["max_kwh_per_percentage"] = f"{max_formatted} kWh/% @ {max_soc_display}% SoC"
            else:
                result["max_kwh_per_percentage"] = f"{max_formatted} kWh/%"

        trimmed_segments = []
        if len(segment_details) > 2:
            trimmed_segments = segment_details[1:-1]

        for segment in trimmed_segments:
            formatted_ratio = _format_decimal_value(segment.get("ratio"), 3)
            if formatted_ratio is None:
                continue

            soc_start_display = _format_decimal_value(segment.get("soc_start"), 1)
            soc_end_display = _format_decimal_value(segment.get("soc_end"), 1)

            soc_range_display = None
            if soc_start_display is not None and soc_end_display is not None:
                soc_range_display = f"{soc_start_display}% → {soc_end_display}%"

            result["segments"].append(
                {
                    "range": soc_range_display or "-",
                    "kwh_per_percentage": f"{formatted_ratio} kWh/%",
                }
            )

            ratio_value = segment.get("ratio")
            soc_value = _select_soc_value(segment)
            if not (
                isinstance(ratio_value, Decimal)
                and soc_value is not None
                and isinstance(soc_value, Decimal)
            ):
                continue

            try:
                chart_points.append(
                    {
                        "soc": round(float(soc_value), 3),
                        "kwh_per_percentage": round(float(ratio_value), 3),
                    }
                )
            except (InvalidOperation, ValueError, TypeError):
                continue

    return result


def load_runtime_config():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT config_key, config_value FROM op_config")
            rows = cur.fetchall()
    finally:
        conn.close()
    return {r["config_key"]: r["config_value"] for r in rows}

_DEFAULT_PROXY_IP = "82.165.103.236"
_DEFAULT_PROXY_PORT = 7020
_DEFAULT_PROXY_API_PORT = 9900


def _parse_int(value, default):
    try:
        return int(value)
    except (TypeError, ValueError):
        return default


def _is_truthy(value) -> bool:
    if isinstance(value, bool):
        return value
    if value is None:
        return False
    return str(value).strip().lower() in {"1", "true", "yes", "on"}


def _build_proxy_settings(cfg: dict) -> dict:
    def _proxy_value(key: str):
        value = cfg.get(key)
        if isinstance(value, str):
            value = value.strip()
        if value is None or value == "":
            return _config.get(key)
        return value

    fallback_ip = (_proxy_value("ocpp_proxy_ip") or _DEFAULT_PROXY_IP).strip() or _DEFAULT_PROXY_IP
    use_ssl = _is_truthy(_proxy_value("ocpp_proxy_ssl"))
    default_port = 443 if use_ssl else _DEFAULT_PROXY_PORT
    configured_port = _parse_int(_proxy_value("ocpp_proxy_port"), default_port)
    configured_api_port = _proxy_value("ocpp_proxy_api_port")
    default_api_port = 443 if use_ssl else _DEFAULT_PROXY_API_PORT
    api_port = _parse_int(configured_api_port, default_api_port)

    override_host = (_proxy_value("ocpp_proxy_url") or "").strip()
    scheme = "https" if use_ssl else "http"
    candidate = override_host or fallback_ip
    if "://" not in candidate:
        candidate_with_scheme = f"{scheme}://{candidate}"
    else:
        candidate_with_scheme = candidate

    parsed = urlsplit(candidate_with_scheme)
    host = parsed.hostname or fallback_ip
    path = parsed.path or ""
    if path == "/":
        path = ""
    port = parsed.port if parsed.port is not None else configured_port
    if configured_api_port is None and parsed.port is not None:
        api_port = parsed.port

    netloc_host = host or fallback_ip
    if ":" in netloc_host and not netloc_host.startswith("["):
        netloc_host = f"[{netloc_host}]"
    base_path = path.rstrip("/")

    ocpp_netloc = netloc_host
    if port is not None:
        ocpp_netloc = f"{ocpp_netloc}:{port}"

    api_netloc = netloc_host
    if api_port is not None:
        api_netloc = f"{api_netloc}:{api_port}"

    base_host_url = urlunsplit((scheme, netloc_host, base_path, "", ""))
    base_url = urlunsplit((scheme, api_netloc, base_path, "", ""))
    ws_scheme = "wss" if scheme == "https" else "ws"
    ws_base_url = urlunsplit((ws_scheme, ocpp_netloc, base_path, "", ""))

    return {
        "ip": fallback_ip,
        "port": port,
        "api_port": api_port,
        "use_ssl": use_ssl,
        "display_base_url": base_host_url,
        "base_url": base_url,
        "ws_base_url": ws_base_url,
    }


def _check_endpoint_reachability(url: str, timeout: float = 3.0) -> tuple[bool, Optional[str]]:
    """Check whether an HTTP endpoint is reachable."""

    if not url:
        return False, "URL ist nicht konfiguriert."

    try:
        # We only care whether we can reach the host/port, not the HTTP status code.
        requests.get(url, timeout=timeout)
        return True, None
    except Exception as exc:
        return False, str(exc)


def _fetch_monitoring_json(
    base_url: str,
    endpoint: str,
    *,
    timeout: float = 4.0,
    params: Optional[Mapping[str, object]] = None,
) -> tuple[Optional[object], dict[str, object]]:
    """Fetch monitoring data with short timeouts and structured errors."""

    result: dict[str, object] = {"url": None, "error": None, "status": None}
    if not base_url:
        result["error"] = "API-Basis ist nicht konfiguriert."
        return None, result

    base = base_url.rstrip("/") + "/"
    url = urljoin(base, endpoint.lstrip("/"))
    result["url"] = url
    try:
        response = requests.get(url, params=params or None, timeout=timeout)
    except Exception as exc:
        result["error"] = str(exc)
        return None, result

    result["status"] = response.status_code
    if not response.ok:
        body_text: str | None
        try:
            body_text = response.text
        except Exception:
            body_text = None
        status_reason = response.reason or "HTTP-Fehler"
        detail = body_text.strip() if body_text else None
        result["error"] = f"{response.status_code} {status_reason}" + (f" – {detail}" if detail else "")
        return None, result

    try:
        data = response.json()
    except ValueError:
        result["error"] = "Ungültige JSON-Antwort."
        return None, result

    return data, result


def _check_mqtt_reachability(host: str, port_value: Any, timeout: float = 3.0) -> tuple[Optional[bool], Optional[str]]:
    """Attempt to open a TCP connection to the configured MQTT broker."""

    host = (host or "").strip()
    port_text = ""
    if port_value is None:
        port_text = ""
    else:
        port_text = str(port_value).strip()

    if not host or not port_text:
        return None, "MQTT-Broker oder Port sind nicht gesetzt."

    try:
        port = int(port_text)
    except (TypeError, ValueError):
        return False, "Ungültiger Portwert."

    if port <= 0:
        return False, "Ungültiger Portwert."

    try:
        with socket.create_connection((host, port), timeout):
            return True, None
    except OSError as exc:
        return False, str(exc)


_runtime_cfg = load_runtime_config()
_proxy_settings = _build_proxy_settings(_runtime_cfg)
_proxy_ip = _proxy_settings["ip"]
_proxy_port = _proxy_settings["port"]
_proxy_api_port = _proxy_settings["api_port"]
PROXY_DISPLAY_BASE_URL = _proxy_settings["display_base_url"]
PROXY_BASE_URL = _proxy_settings["base_url"]
PROXY_BASE_WS = _proxy_settings["ws_base_url"]
CONNECTED_ENDPOINT = PROXY_BASE_URL.rstrip("/") + "/getConnecteEVSE"
STATS_ENDPOINT = PROXY_BASE_URL.rstrip("/") + "/connectedWallboxes"
ACTIVE_SESSIONS_ENDPOINT = PROXY_BASE_URL.rstrip("/") + "/activeSessions"
BROKER_STATUS_ENDPOINT = PROXY_BASE_URL.rstrip("/") + "/brokerStatus"
CONNECTION_STATS_ENDPOINT = PROXY_BASE_URL.rstrip("/") + "/connectionStats"

BROKER_INSTANCE_DEFAULT_KEY = "default_broker_base_url"
BROKER_INSTANCE_DEFAULT_OCPP_PORT_KEY = "default_broker_ocpp_port"
BROKER_INSTANCE_DEFAULT_API_PORT_KEY = "default_broker_api_port"


def ensure_broker_instances_table(conn) -> None:
    with conn.cursor() as cur:
        cur.execute(
            """
            CREATE TABLE IF NOT EXISTS op_broker_instances (
                id INT AUTO_INCREMENT PRIMARY KEY,
                name VARCHAR(255) NOT NULL,
                base_url VARCHAR(512) NOT NULL,
                ocpp_port INT NOT NULL DEFAULT 80,
                api_port INT NOT NULL DEFAULT 9900,
                created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
                updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                UNIQUE KEY op_broker_instances_base_ports (base_url, ocpp_port, api_port)
            )
            ENGINE=InnoDB
            DEFAULT CHARSET=utf8mb4
            COLLATE=utf8mb4_unicode_ci;
            """
        )
        cur.execute("SHOW COLUMNS FROM op_broker_instances LIKE 'ocpp_port'")
        has_ocpp_port = cur.fetchone()
        if not has_ocpp_port:
            cur.execute(
                "ALTER TABLE op_broker_instances ADD COLUMN ocpp_port INT NOT NULL DEFAULT 80 AFTER base_url"
            )
        cur.execute("SHOW COLUMNS FROM op_broker_instances LIKE 'api_port'")
        has_api_port = cur.fetchone()
        if not has_api_port:
            cur.execute(
                "ALTER TABLE op_broker_instances ADD COLUMN api_port INT NOT NULL DEFAULT 9900 AFTER ocpp_port"
            )
        cur.execute(
            "SHOW INDEX FROM op_broker_instances WHERE Key_name='op_broker_instances_base_url'"
        )
        if cur.fetchone():
            cur.execute("ALTER TABLE op_broker_instances DROP INDEX op_broker_instances_base_url")
        cur.execute(
            "SHOW INDEX FROM op_broker_instances WHERE Key_name='op_broker_instances_base_ports'"
        )
        if not cur.fetchone():
            cur.execute(
                "ALTER TABLE op_broker_instances ADD UNIQUE KEY op_broker_instances_base_ports (base_url, ocpp_port, api_port)"
            )
    conn.commit()


def _normalize_broker_base_url(value: str) -> str:
    text = (value or "").strip()
    if not text:
        raise ValueError(translate_text("Broker endpoint must not be empty."))

    parsed = urlparse(text if "://" in text else f"http://{text}")
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise ValueError(translate_text("Broker endpoint must be a valid http(s) target."))
    if parsed.port is not None:
        raise ValueError(
            translate_text("Port values belong in the port fields, not in the base URL.")
        )

    host = parsed.hostname
    if not host:
        raise ValueError(translate_text("Broker endpoint must include a host name."))

    netloc_host = host
    if ":" in netloc_host and not netloc_host.startswith("["):
        netloc_host = f"[{netloc_host}]"

    normalized_path = parsed.path.rstrip("/")
    return urlunsplit((parsed.scheme, netloc_host, normalized_path, "", ""))


def _normalize_broker_port(value: Any, *, default: int, field_label: str) -> int:
    text = ("" if value is None else str(value)).strip()
    if text == "":
        return default
    try:
        port = int(text)
    except (TypeError, ValueError):
        raise ValueError(
            translate_text("%(field)s must be a whole number.", field=field_label)
        )
    if port <= 0 or port > 65535:
        raise ValueError(
            translate_text("%(field)s must be between 1 and 65535.", field=field_label)
        )
    return port


def _set_default_broker_config(base_url: str, ocpp_port: int, api_port: int) -> None:
    set_config_value(BROKER_INSTANCE_DEFAULT_KEY, base_url)
    set_config_value(BROKER_INSTANCE_DEFAULT_OCPP_PORT_KEY, str(ocpp_port))
    set_config_value(BROKER_INSTANCE_DEFAULT_API_PORT_KEY, str(api_port))


def get_default_broker_settings() -> dict[str, Any]:
    configured_default = (get_config_value(BROKER_INSTANCE_DEFAULT_KEY) or "").strip()
    ocpp_port_raw = get_config_value(BROKER_INSTANCE_DEFAULT_OCPP_PORT_KEY)
    api_port_raw = get_config_value(BROKER_INSTANCE_DEFAULT_API_PORT_KEY)
    try:
        normalized = _normalize_broker_base_url(configured_default) if configured_default else ""
    except ValueError:
        normalized = ""

    try:
        ocpp_port = _normalize_broker_port(
            ocpp_port_raw,
            default=_proxy_port or _DEFAULT_PROXY_PORT,
            field_label=translate_text("OCPP port"),
        )
    except ValueError:
        ocpp_port = _proxy_port or _DEFAULT_PROXY_PORT
    try:
        api_port = _normalize_broker_port(
            api_port_raw,
            default=_proxy_api_port or _DEFAULT_PROXY_API_PORT,
            field_label=translate_text("API port"),
        )
    except ValueError:
        api_port = _proxy_api_port or _DEFAULT_PROXY_API_PORT

    base_url = normalized or PROXY_DISPLAY_BASE_URL
    return {
        "base_url": base_url,
        "ocpp_port": ocpp_port,
        "api_port": api_port,
    }


def get_default_broker_base_url() -> str:
    return get_default_broker_settings()["base_url"]


def _load_broker_instances(conn) -> list[dict[str, Any]]:
    ensure_broker_instances_table(conn)

    default_settings = get_default_broker_settings()
    default_base_url = default_settings["base_url"]
    default_ocpp_port = default_settings["ocpp_port"]
    default_api_port = default_settings["api_port"]
    instances: list[dict[str, Any]] = []

    config_entry = {
        "id": "config",
        "name": "config.json",
        "base_url": PROXY_DISPLAY_BASE_URL,
        "ocpp_port": _proxy_port,
        "api_port": _proxy_api_port,
        "editable": False,
        "is_default": PROXY_DISPLAY_BASE_URL == default_base_url
        and _proxy_port == default_ocpp_port
        and _proxy_api_port == default_api_port,
        "source": "config",
    }
    instances.append(config_entry)

    with conn.cursor() as cur:
        cur.execute(
            "SELECT id, name, base_url, ocpp_port, api_port FROM op_broker_instances ORDER BY name, id"
        )
        rows = cur.fetchall()

    for row in rows:
        try:
            normalized_url = _normalize_broker_base_url(row.get("base_url"))
        except ValueError:
            LOGGER.warning("Ignoring invalid broker base URL: %s", row.get("base_url"))
            continue
        try:
            ocpp_port = _normalize_broker_port(
                row.get("ocpp_port"),
                default=_proxy_port or _DEFAULT_PROXY_PORT,
                field_label=translate_text("OCPP port"),
            )
        except ValueError as exc:
            LOGGER.warning("Invalid OCPP port for broker %s: %s", normalized_url, exc)
            ocpp_port = _proxy_port or _DEFAULT_PROXY_PORT
        try:
            api_port = _normalize_broker_port(
                row.get("api_port"),
                default=_proxy_api_port or _DEFAULT_PROXY_API_PORT,
                field_label=translate_text("API port"),
            )
        except ValueError as exc:
            LOGGER.warning("Invalid API port for broker %s: %s", normalized_url, exc)
            api_port = _proxy_api_port or _DEFAULT_PROXY_API_PORT

        name = (row.get("name") or "").strip() or normalized_url
        instances.append(
            {
                "id": row.get("id"),
                "name": name,
                "base_url": normalized_url,
                "ocpp_port": ocpp_port,
                "api_port": api_port,
                "editable": True,
                "is_default": normalized_url == default_base_url
                and ocpp_port == default_ocpp_port
                and api_port == default_api_port,
                "source": "custom",
            }
        )

    available_urls = {
        (instance["base_url"], instance["ocpp_port"], instance["api_port"])
        for instance in instances
    }
    default_tuple = (default_base_url, default_ocpp_port, default_api_port)
    if default_tuple not in available_urls:
        _set_default_broker_config(PROXY_DISPLAY_BASE_URL, _proxy_port, _proxy_api_port)
        for instance in instances:
            instance["is_default"] = (
                instance["base_url"] == PROXY_DISPLAY_BASE_URL
                and instance.get("ocpp_port") == _proxy_port
                and instance.get("api_port") == _proxy_api_port
            )

    return instances


def _build_broker_endpoint_bundle(
    base_url: str, *, ocpp_port: Any = None, api_port: Any = None
) -> dict[str, Any]:
    normalized_base = _normalize_broker_base_url(base_url).rstrip("/")
    resolved_ocpp_port = _normalize_broker_port(
        ocpp_port,
        default=_proxy_port or _DEFAULT_PROXY_PORT,
        field_label=translate_text("OCPP port"),
    )
    resolved_api_port = _normalize_broker_port(
        api_port,
        default=_proxy_api_port or _DEFAULT_PROXY_API_PORT,
        field_label=translate_text("API port"),
    )

    parsed = urlsplit(normalized_base)
    host = parsed.hostname or ""
    netloc_host = host
    if ":" in netloc_host and not netloc_host.startswith("["):
        netloc_host = f"[{netloc_host}]"
    path = parsed.path

    api_netloc = f"{netloc_host}:{resolved_api_port}"
    ocpp_netloc = f"{netloc_host}:{resolved_ocpp_port}"
    api_base_url = urlunsplit((parsed.scheme, api_netloc, path, "", ""))
    ws_scheme = "wss" if parsed.scheme == "https" else "ws"
    ws_url = urlunsplit((ws_scheme, ocpp_netloc, path, "", ""))
    return {
        "base_url": api_base_url,
        "display_base_url": normalized_base,
        "ws_url": ws_url,
        "ocpp_port": resolved_ocpp_port,
        "api_port": resolved_api_port,
        "connected_endpoint": api_base_url.rstrip("/") + "/getConnecteEVSE",
        "stats_endpoint": api_base_url.rstrip("/") + "/connectedWallboxes",
        "broker_status_endpoint": api_base_url.rstrip("/") + "/brokerStatus",
        "active_sessions_endpoint": api_base_url.rstrip("/") + "/activeSessions",
        "connection_stats_endpoint": api_base_url.rstrip("/") + "/connectionStats",
        "db_pool_metrics_endpoint": api_base_url.rstrip("/") + "/api/db_pool_metrics",
        "reconnect_metrics_endpoint": api_base_url.rstrip("/") + "/api/reconnectStats",
    }

_ocpp_server_ip = _runtime_cfg.get("ocpp_server_ip", _proxy_ip)
try:
    _ocpp_api_port = int(
        _runtime_cfg.get(
            "ocpp_api_port",
            _runtime_cfg.get("ocpp_server_api_port", 9751),
        )
    )
except (TypeError, ValueError):
    _ocpp_api_port = 9751
OCPP_SERVER_API_BASE_URL = f"http://{_ocpp_server_ip}:{_ocpp_api_port}"

_fw_prefix = (_runtime_cfg.get("ocpp_server_fw_url_prefix") or "").strip()
OCPP_SERVER_FW_URL_PREFIX = _fw_prefix.rstrip("/") if _fw_prefix else None

_LIVENESS_WARNING_DEFAULT = 60
_station_liveness_threshold = _runtime_cfg.get("station_liveness_warning_seconds")
if _station_liveness_threshold is None:
    _station_liveness_threshold = _config.get("station_liveness_warning_seconds")
STATION_LIVENESS_WARNING_SECONDS = _parse_int(
    _station_liveness_threshold, _LIVENESS_WARNING_DEFAULT
)
OCPP_LOG_QUEUE_WARNING_RATIO = 0.8
_ocpp_log_drop_warning_raw = (
    _runtime_cfg.get("ocpp_log_drop_warning_minutes")
    if isinstance(_runtime_cfg, Mapping)
    else None
)
if _ocpp_log_drop_warning_raw is None and isinstance(_config, Mapping):
    _ocpp_log_drop_warning_raw = _config.get("ocpp_log_drop_warning_minutes")
OCPP_LOG_DROP_WARNING_MINUTES = _parse_int(_ocpp_log_drop_warning_raw, 15)


def _build_broker_monitor_context() -> dict[str, Any]:
    timeout_seconds = 4
    api_base = PROXY_BASE_URL
    api_reachable, api_error = _check_endpoint_reachability(api_base, timeout=timeout_seconds)

    endpoint_results: dict[str, dict[str, object]] = {
        key: {"error": None, "url": None, "status": None}
        for key in (
            "broker_status",
            "db_pool_metrics",
            "connection_stats",
            "reconnect_stats",
            "websocket_counts",
            "mysql_pool_status",
            "system_limits",
        )
    }

    warnings: list[dict[str, object]] = []

    def _capture_warning(message: str, *, level: str = "warning") -> None:
        warnings.append({"message": message, "level": level})

    def _fetch(key: str, endpoint: str, *, params: Optional[Mapping[str, object]] = None) -> dict[str, Any]:
        data, meta = _fetch_monitoring_json(
            api_base,
            endpoint,
            timeout=timeout_seconds,
            params=params,
        )
        endpoint_results[key] = meta
        if data is None:
            return {}
        if isinstance(data, Mapping):
            return dict(data)
        endpoint_results[key]["error"] = endpoint_results[key].get("error") or "Unerwartetes Antwortformat."
        return {}

    broker_status = _fetch("broker_status", "/brokerStatus")
    db_metrics = _fetch("db_pool_metrics", "/api/db_pool_metrics")
    connection_stats = _fetch("connection_stats", "/connectionStats", params={"total": 1})
    reconnect_stats = _fetch("reconnect_stats", "/api/reconnectStats")
    websocket_counts = _fetch("websocket_counts", "/websocketCounts")
    mysql_pool_status = _fetch("mysql_pool_status", "/mysqlPoolStatus")
    system_limits = _fetch("system_limits", "/system/limits")

    connection_totals = connection_stats.get("total") if isinstance(connection_stats, Mapping) else None
    if not isinstance(connection_totals, Mapping):
        connection_totals = {}
    reconnect_totals = reconnect_stats.get("total") if isinstance(reconnect_stats, Mapping) else None
    if not isinstance(reconnect_totals, Mapping):
        reconnect_totals = {}

    thresholds = reconnect_stats.get("thresholds") if isinstance(reconnect_stats, Mapping) else {}
    reconnect_thresholds = thresholds if isinstance(thresholds, Mapping) else {}

    derived: dict[str, Any] = {
        "estimated_fd_free": None,
        "pool_usage_ratio": None,
        "connection_success_rate": None,
        "uptime_ratio": None,
    }

    estimated_free = websocket_counts.get("estimated_free")
    if estimated_free is None and isinstance(system_limits, Mapping):
        estimated_free = system_limits.get("estimated_free")
    derived["estimated_fd_free"] = estimated_free if isinstance(estimated_free, (int, float)) else None
    if derived["estimated_fd_free"] is not None and derived["estimated_fd_free"] < 150:
        _capture_warning("FD-Puffer gering (<150).", level="danger")

    pool = mysql_pool_status.get("pool") if isinstance(mysql_pool_status, Mapping) else {}
    used = pool.get("used") if isinstance(pool, Mapping) else None
    size = pool.get("size") if isinstance(pool, Mapping) else None
    try:
        used_val = float(used)
        size_val = float(size)
        if size_val > 0:
            derived["pool_usage_ratio"] = used_val / size_val
    except (TypeError, ValueError, ZeroDivisionError):
        derived["pool_usage_ratio"] = None
    if derived["pool_usage_ratio"] is not None and derived["pool_usage_ratio"] > 0.85:
        _capture_warning("DB-Pool stark ausgelastet (>85%).", level="danger")

    success_rate = connection_totals.get("success_rate")
    if isinstance(success_rate, (int, float)):
        derived["connection_success_rate"] = float(success_rate)
        if success_rate < 0.98:
            _capture_warning("Ping-Erfolgsquote unter 98%.", level="danger")
    uptime_ratio = connection_totals.get("uptime_ratio")
    if isinstance(uptime_ratio, (int, float)):
        derived["uptime_ratio"] = float(uptime_ratio)
        if uptime_ratio < 0.99:
            _capture_warning("Uptime unter 99%.", level="warning")

    reconnect_rate = reconnect_totals.get("reconnect_rate_per_day")
    reconnect_rate_val: float | None
    try:
        reconnect_rate_val = float(reconnect_rate)
    except (TypeError, ValueError):
        reconnect_rate_val = None
    if reconnect_rate_val is not None:
        rate_threshold_raw = reconnect_thresholds.get("reconnect_rate_per_day")
        try:
            rate_threshold = float(rate_threshold_raw) if rate_threshold_raw is not None else None
        except (TypeError, ValueError):
            rate_threshold = None
        if rate_threshold is not None and reconnect_rate_val >= rate_threshold:
            _capture_warning(
                f"Reconnect-Rate hoch ({reconnect_rate_val:.2f}/Tag ≥ {rate_threshold}).",
                level="warning",
            )

    handshake_failure = reconnect_totals.get("handshake_failure")
    failure_threshold_raw = reconnect_thresholds.get("handshake_failures")
    try:
        failure_threshold = int(failure_threshold_raw) if failure_threshold_raw is not None else None
    except (TypeError, ValueError):
        failure_threshold = None
    try:
        handshake_failure_val = int(handshake_failure)
    except (TypeError, ValueError):
        handshake_failure_val = None
    if (
        failure_threshold is not None
        and handshake_failure_val is not None
        and handshake_failure_val >= failure_threshold
    ):
        _capture_warning(
            f"Handshake-Fehler im Fenster: {handshake_failure_val} (Warnschwelle {failure_threshold}).",
            level="danger",
        )

    try:
        waiting_queue = int(db_metrics.get("waiting"))
    except (TypeError, ValueError, AttributeError):
        waiting_queue = None
    if waiting_queue is not None and waiting_queue > 0:
        _capture_warning(f"DB-Warteschlange aktiv ({waiting_queue}).", level="warning")

    return {
        "api_base": api_base,
        "api_error": api_error,
        "api_unreachable": api_base and not api_reachable,
        "timeout_seconds": timeout_seconds,
        "endpoint_results": endpoint_results,
        "websocket_counts": websocket_counts,
        "system_limits": system_limits,
        "mysql_pool_status": mysql_pool_status,
        "db_metrics": db_metrics,
        "connection_stats": connection_stats,
        "connection_totals": connection_totals,
        "reconnect_stats": reconnect_stats,
        "reconnect_totals": reconnect_totals,
        "reconnect_thresholds": reconnect_thresholds,
        "recent_handshakes": (reconnect_stats.get("recent_handshake_failures") if isinstance(reconnect_stats, Mapping) else {}) or {},
        "warnings": warnings,
        "derived": derived,
    }


def _build_ocpp_server_monitor_context() -> dict[str, Any]:
    timeout_seconds = 4
    api_base = OCPP_SERVER_API_BASE_URL
    api_reachable, api_error = _check_endpoint_reachability(api_base, timeout=timeout_seconds)

    endpoint_results: dict[str, dict[str, object]] = {
        key: {"error": None, "url": None, "status": None}
        for key in (
            "websocket_counts",
            "db_pool_status",
            "log_queue_metrics",
            "action_metrics",
            "station_liveness",
            "connected_stations",
        )
    }

    def _fetch(key: str, endpoint: str, *, params: Optional[Mapping[str, object]] = None) -> dict[str, Any]:
        data, meta = _fetch_monitoring_json(
            api_base,
            endpoint,
            timeout=timeout_seconds,
            params=params,
        )
        endpoint_results[key] = meta
        if data is None:
            return {}
        if isinstance(data, Mapping):
            return dict(data)
        endpoint_results[key]["error"] = endpoint_results[key].get("error") or "Unerwartetes Antwortformat."
        return {}

    websocket_counts = _fetch("websocket_counts", "/api/websocketCounts")
    db_pool_status = _fetch("db_pool_status", "/api/db_pool_status")
    log_queue_metrics = _fetch(
        "log_queue_metrics",
        "/api/ocpp_log_queue_metrics",
        params={"window_minutes": OCPP_LOG_DROP_WARNING_MINUTES} if OCPP_LOG_DROP_WARNING_MINUTES is not None else None,
    )
    action_metrics_payload = _fetch("action_metrics", "/api/ocpp_action_metrics")
    liveness_params = {"sort": "at_risk"}
    if STATION_LIVENESS_WARNING_SECONDS is not None:
        liveness_params["threshold"] = STATION_LIVENESS_WARNING_SECONDS
    station_liveness = _fetch("station_liveness", "/api/station_liveness", params=liveness_params)
    connected_snapshot = _fetch("connected_stations", "/api/connected_stations")

    connection_metrics = websocket_counts.get("connections") if isinstance(websocket_counts, Mapping) else {}
    fd_metrics = websocket_counts.get("fd_metrics") if isinstance(websocket_counts, Mapping) else {}

    derived: dict[str, Any] = {
        "fd_usage_ratio": None,
        "db_max_slots": None,
        "db_used": None,
        "db_free_slots": None,
        "db_usage_ratio": None,
        "log_fill_ratio": None,
        "log_window": None,
        "log_drop_recent": None,
        "risk_entries": [],
    }

    open_fds = fd_metrics.get("open_fds") if isinstance(fd_metrics, Mapping) else None
    soft_limit = fd_metrics.get("fd_soft_limit") if isinstance(fd_metrics, Mapping) else None
    try:
        open_val = float(open_fds)
        soft_val = float(soft_limit)
        if soft_val > 0:
            derived["fd_usage_ratio"] = open_val / soft_val
    except (TypeError, ValueError, ZeroDivisionError):
        derived["fd_usage_ratio"] = fd_metrics.get("fd_usage_ratio") if isinstance(fd_metrics, Mapping) else None

    if isinstance(db_pool_status, Mapping):
        maxsize = db_pool_status.get("maxsize") or db_pool_status.get("mysql_pool_size")
        freesize = db_pool_status.get("freesize") or db_pool_status.get("free_slots")
        size = db_pool_status.get("size") or db_pool_status.get("created")
        try:
            max_val = int(maxsize) if maxsize is not None else None
        except (TypeError, ValueError):
            max_val = None
        try:
            free_val = int(freesize) if freesize is not None else None
        except (TypeError, ValueError):
            free_val = None
        try:
            size_val = int(size) if size is not None else None
        except (TypeError, ValueError):
            size_val = None

        derived["db_max_slots"] = max_val
        derived["db_free_slots"] = free_val
        if size_val is not None and free_val is not None:
            derived["db_used"] = max(size_val - free_val, 0)
            if size_val > 0:
                derived["db_usage_ratio"] = derived["db_used"] / size_val

    try:
        fill_ratio = float(log_queue_metrics.get("fill_ratio"))
    except (TypeError, ValueError, AttributeError):
        fill_ratio = None
    if fill_ratio is None:
        try:
            size = float(log_queue_metrics.get("size"))
            maxsize = float(log_queue_metrics.get("maxsize"))
            if maxsize > 0:
                fill_ratio = size / maxsize
        except (TypeError, ValueError, ZeroDivisionError, AttributeError):
            fill_ratio = None
    derived["log_fill_ratio"] = fill_ratio
    derived["log_window"] = log_queue_metrics.get("recent_window_minutes")
    try:
        derived["log_drop_recent"] = int(log_queue_metrics.get("recent_drop_count"))
    except (TypeError, ValueError, AttributeError):
        derived["log_drop_recent"] = None

    liveness_entries = station_liveness.get("stations") if isinstance(station_liveness, Mapping) else []
    if not isinstance(liveness_entries, list):
        liveness_entries = []
    risk_entries: list[dict[str, Any]] = []
    threshold = STATION_LIVENESS_WARNING_SECONDS
    for entry in liveness_entries:
        if not isinstance(entry, Mapping):
            continue
        seconds_left = entry.get("seconds_until_timeout")
        at_risk = bool(entry.get("at_risk"))
        try:
            seconds_val = int(seconds_left) if seconds_left is not None else None
        except (TypeError, ValueError):
            seconds_val = None
        if threshold is not None and seconds_val is not None and seconds_val <= threshold:
            at_risk = True
        if at_risk:
            risk_entries.append(dict(entry))
    derived["risk_entries"] = risk_entries

    action_metrics = dict(action_metrics_payload)
    action_warnings = []
    warnings: list[dict[str, object]] = []
    if "_warnings" in action_metrics:
        raw_warnings = action_metrics.pop("_warnings")
        if isinstance(raw_warnings, list):
            action_warnings = [entry for entry in raw_warnings if isinstance(entry, Mapping)]

    if derived["fd_usage_ratio"] is not None and derived["fd_usage_ratio"] > 0.85:
        warnings.append({"message": "FD-Auslastung über 85%.", "level": "danger"})
    if derived["db_usage_ratio"] is not None and derived["db_usage_ratio"] > 0.85:
        warnings.append({"message": "DB-Pool stark ausgelastet (>85%).", "level": "danger"})
    if derived["log_fill_ratio"] is not None and derived["log_fill_ratio"] > 0.7:
        warnings.append({"message": "Log-Queue fast voll (>70%).", "level": "danger"})
    if derived["log_drop_recent"]:
        warnings.append({"message": f"Log-Drops im Fenster: {derived['log_drop_recent']}", "level": "warning"})
    if derived["risk_entries"]:
        warnings.append({"message": "Stationen mit Timeout-Risiko erkannt.", "level": "warning"})

    return {
        "api_base": api_base,
        "api_error": api_error,
        "api_unreachable": api_base and not api_reachable,
        "timeout_seconds": timeout_seconds,
        "endpoint_results": endpoint_results,
        "connection_metrics": connection_metrics if isinstance(connection_metrics, Mapping) else {},
        "fd_metrics": fd_metrics if isinstance(fd_metrics, Mapping) else {},
        "db_pool_status": db_pool_status,
        "log_queue_metrics": log_queue_metrics,
        "action_metrics": action_metrics,
        "action_warnings": action_warnings,
        "station_liveness": station_liveness,
        "liveness_entries": liveness_entries,
        "connected_snapshot": connected_snapshot,
        "window_minutes": derived["log_window"],
        "liveness_threshold": STATION_LIVENESS_WARNING_SECONDS,
        "warnings": warnings,
        "derived": derived,
    }


def refresh_station_list_safely():
    """Trigger the broker to reload its station list.

    Any exceptions are logged but ignored so API consumers still receive a
    response even if the broker cannot be reached.
    """

    try:
        requests.get(f"{PROXY_BASE_URL}/refreshStationList", params={}, timeout=5)
    except Exception:
        app.logger.warning("Failed to refresh station list", exc_info=True)

# http://82.165.103.236/rebootChargePoint/ocpp16/TACW1144322G1531
# ws://ocpp.neleso.com:8080/steve/websocket/CentralSystemService/
# Reset Call

# Determine absolute paths so the dashboard can locate static assets
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app = Flask(
    __name__,
    static_url_path="/static",
    static_folder=os.path.join(BASE_DIR, "html", "static"),
    template_folder=os.path.join(BASE_DIR, "templates"),
)

LOGO_UPLOAD_SUBDIR = "branding"
LOGO_UPLOAD_FOLDER = os.path.join(app.static_folder, LOGO_UPLOAD_SUBDIR)
ALLOWED_LOGO_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
MAX_LOGO_FILE_SIZE = 2 * 1024 * 1024  # 2 MiB

os.makedirs(LOGO_UPLOAD_FOLDER, exist_ok=True)

FIRMWARE_DIRECTORY = os.path.join(app.static_folder, "firmware")

FIRMWARE_DELIVERY_OPTIONS = [
    {"value": "broker", "label": "OCPP Broker"},
    {"value": "server", "label": "OCPP Server"},
]
DEFAULT_FIRMWARE_TARGET = "broker"
FIRMWARE_PROTOCOL_OPTIONS = [
    {"value": "https", "label": "HTTPS"},
    {"value": "ftp", "label": "FTP"},
]
DEFAULT_FIRMWARE_PROTOCOL = "https"
FIRMWARE_FTP_BASE_URL = os.getenv(
    "FIRMWARE_FTP_BASE_URL",
    "ftp://diag:rem0tec0nnect2026x@217.160.79.201",
)

# ---------------------------------------------------------------------------
# Language and translations
# ---------------------------------------------------------------------------

DEFAULT_LANGUAGE = "en"
SUPPORTED_LANGUAGES: dict[str, str] = {"en": "English", "de": "Deutsch"}
LANGUAGE_COOKIE_NAME = "pipelet_dashboard_lang"

TRANSLATIONS: dict[str, dict[str, str]] = {
    "de": {
        "API Docs (Swagger)": "API-Dokumentation (Swagger)",
        "Active Sessions": "Aktive Sitzungen",
        "AI Diagnostics": "KI-Diagnose",
        "Alert cooldown (seconds)": "Alarm-Cooldown (Sekunden)",
        "Alert when webhook/slack endpoint stays unreachable.": "Benachrichtigen, wenn Webhook/Slack nicht erreichbar bleibt.",
        "Alerts & thresholds": "Alarme & Schwellenwerte",
        "Add backend": "Backend hinzufügen",
        "Add Users": "Benutzer hinzufügen",
        "Add": "Hinzufügen",
        "Activate the desired backends and optionally assign priorities.": "Aktivieren Sie die gewünschten Backends und vergeben Sie bei Bedarf Prioritäten.",
        "Activity": "Aktivität",
        "All Charging Sessions": "Alle Ladevorgänge",
        "Allow": "Zulassen",
        "Auto (1.6 or 2.0.1)": "Auto (1.6 oder 2.0.1)",
        "Bootstrap token ending in": "Bootstrap-Token endet auf",
        "Bootstrap token missing": "Bootstrap-Token fehlt",
        "Backend authentication": "Backend-Authentifizierung",
        "Backend basic password": "Backend-Basic-Passwort",
        "Backend basic username": "Backend-Basic-Benutzername",
        "Backend": "Backend",
        "Backend ID:": "Backend-ID:",
        "Assignment": "Zuordnung",
        "Authorization": "Autorisierung",
        "Authorization log description": "Die Tabelle zeigt die neuesten 200 Einträge aus dem Autorisierungsprotokoll der Ladepunkte.",
        "Backends": "Backends",
        "Backend added": "Backend hinzugefügt",
        "Backend deleted": "Backend gelöscht",
        "Backend updated": "Backend aktualisiert",
        "Bearer Token:": "Bearer Token:",
        "Basic data": "Basisdaten",
        "Battery Degradation": "Batteriedegradation",
        "Battery Health Dashboard": "Batterie-Gesundheitsdashboard",
        "Block": "Blockieren",
        "Base URL": "Basis-URL",
        "Broker": "Broker",
        "Broker endpoints": "Broker-Endpunkte",
        "Broker Instances": "Broker-Instanzen",
        "Broker to display": "Angezeigter Broker",
        "Broker endpoint added.": "Broker-Endpoint hinzugefügt.",
        "Broker endpoint must be a valid http(s) target.": "Broker-Endpoint muss ein gültiges http(s)-Ziel sein.",
        "Broker endpoint must include a host name.": "Broker-Endpoint muss einen Host-Namen enthalten.",
        "Broker endpoint must not be empty.": "Broker-Endpoint darf nicht leer sein.",
        "Broker endpoint removed.": "Broker-Endpoint entfernt.",
        "Broker endpoint updated.": "Broker-Endpoint aktualisiert.",
        "Current default:": "Aktueller Standard:",
        "Default": "Standard",
        "Default broker updated.": "Standard-Broker aktualisiert.",
        "Default selection": "Standardauswahl",
        "Display name": "Anzeigename",
        "If no default is chosen, the value from config.json remains active.": "Wenn kein Standard gewählt wird, bleibt der Wert aus der config.json aktiv.",
        "Manage reachable broker endpoints and choose which one is used for the dashboard view.": "Verwalte erreichbare Broker-Endpunkte und wähle, welcher für die Dashboard-Anzeige verwendet wird.",
        "Open dashboard": "Dashboard öffnen",
        "OCPP Port": "OCPP-Port",
        "OCPP port": "OCPP-Port",
        "API Port": "API-Port",
        "API port": "API-Port",
        "Port values belong in the port fields, not in the base URL.": "Portangaben gehören in die Port-Felder, nicht in die Base URL.",
        "%(field)s must be a whole number.": "%(field)s muss eine ganze Zahl sein.",
        "%(field)s must be between 1 and 65535.": "%(field)s muss zwischen 1 und 65535 liegen.",
        "Set as default": "Als Standard setzen",
        "Invalid broker ID.": "Ungültige Broker-ID.",
        "Branding": "Branding",
        "Cancel": "Abbrechen",
        "Color theme '{theme}' activated.": "Farbwelt '{theme}' wurde aktiviert.",
        "Color Themes": "Farbwelten",
        "Credentials / Handshake": "Credentials / Handshake",
        "Credentials token": "Credentials-Token",
        "Configuration saved.": "Konfiguration gespeichert.",
        "Choose between the classic color theme and the new enterprise variant.": "Wählen Sie zwischen der bisherigen Classic-Farbwelt und der neuen Enterprise-Variante.",
        "Comment": "Kommentar",
        "Configure routing, authentication, and optional monitoring in the sections below.": "Konfigurieren Sie Routing, Authentifizierung und optionale Überwachungen in den folgenden Abschnitten.",
        "Control behavior and optional forwarding.": "Steuern Sie Verhalten und optionale Weiterleitungen.",
        "CP Config (OICP)": "CP-Konfiguration (OICP)",
        "Change Password": "Passwort ändern",
        "Current password": "Aktuelles Passwort",
        "Chargepoint ID": "Chargepoint-ID",
        "Chargepoint Status": "Ladepunktstatus",
        "Charging Infrastructure Monitor": "Ladeinfrastruktur-Monitor",
        "Current connection": "Aktuelle Verbindung",
        "Charging Sessions": "Ladevorgänge",
        "Charging Points": "Ladepunkte",
        "Analysis": "Analyse",
        "Show starred only": "Nur markierte anzeigen",
        "Show": "Anzeigen",
        "Month": "Monat",
        "Session ID": "Sitzungs-ID",
        "Start": "Start",
        "End": "Ende",
        "Energy (kWh)": "Energie (kWh)",
        "OCMF Energy (kWh)": "OCMF-Energie (kWh)",
        "SQL Import": "SQL-Import",
        "Import SQL": "SQL importieren",
        "Mark charging session": "Ladevorgang markieren",
        "View details": "Details anzeigen",
        "Download SQL export": "SQL-Export herunterladen",
        "Could not save star state.": "Markierung konnte nicht gespeichert werden.",
        "Ongoing": "Laufend",
        "No SQL file selected.": "Keine SQL-Datei ausgewählt.",
        "The file could not be read (UTF-8 expected).": "Die Datei konnte nicht gelesen werden (UTF-8 erwartet).",
        "The file does not contain executable SQL statements.": "Die Datei enthält keine ausführbaren SQL-Befehle.",
        "Import failed: SQL error.": "Import fehlgeschlagen: SQL-Fehler.",
        "SQL import completed successfully.": "SQL-Import erfolgreich abgeschlossen.",
        "Check status": "Status abfragen",
        "Check wallbox connection": "Wallbox-Verbindung prüfen",
        "Classic": "Classic",
        "Configuration": "Konfiguration",
        "Connected Devices": "Verbundene Geräte",
        "Connection Monitoring": "Verbindungsmonitoring",
        "Connected since {since_display} (duration {duration_display}).": "Verbunden seit {since_display} (Dauer {duration_display}).",
        "Connected since": "Verbunden seit",
        "Core CPMS – Connected Devices": "Core CPMS – Verbundene Geräte",
        "Overview of all charge points currently connected to the Core CPMS.": "Überblick über alle derzeit am Core CPMS verbundenen Ladepunkte.",
        "Response from the OCPP server": "Antwort des OCPP-Servers",
        "Notice:": "Hinweis:",
        "Connection data could not be fully loaded. Details: {details}": "Die Verbindungsdaten konnten nicht vollständig geladen werden. Details: {details}",
        "Seconds": "Sekunden",
        "Availability": "Verfügbarkeit",
        "OICP": "OICP",
        "Inoperative": "Inoperative",
        "Operative": "Operativ",
        "No charge points are currently connected.": "Aktuell sind keine Ladestationen verbunden.",
        "Could not change OICP status.": "OICP-Status konnte nicht geändert werden.",
        "OICP toggle failed: {error}": "OICP-Umschaltung fehlgeschlagen: {error}",
        "Controls which automatic configuration commands the OCPP server runs when a connection starts.": "Steuert, welche automatischen Konfigurationsbefehle der OCPP-Server beim Start einer Verbindung ausführt.",
        "Set LocalAuthorizeOffline": "LocalAuthorizeOffline setzen",
        "Enables or disables automatically setting LocalAuthorizeOffline via ChangeConfiguration.": "Aktiviert oder deaktiviert das automatische Setzen von LocalAuthorizeOffline über ChangeConfiguration.",
        "Set AuthorizeRemoteTxRequests": "AuthorizeRemoteTxRequests setzen",
        "Controls whether the server enforces the authorization flow for remote starts.": "Steuert, ob der Server den Autorisierungs-Flow für entfernte Starts erzwingt.",
        "Set LocalAuthListEnabled": "LocalAuthListEnabled setzen",
        "Automatically sends LocalAuthListEnabled when enabled.": "Sendet bei Aktivierung automatisch die Einstellung LocalAuthListEnabled.",
        "Set AuthorizationCacheEnabled": "AuthorizationCacheEnabled setzen",
        "Determines whether the charge point should use its authorization cache.": "Bestimmt, ob die Ladestation ihren Autorisierungscache verwenden soll.",
        "Send ChangeAvailability to Operative": "ChangeAvailability auf Operative senden",
        "Defines whether the server issues ChangeAvailability to Operative directly after the BootNotification.": "Legt fest, ob der Server direkt nach der BootNotification ein ChangeAvailability auf Operative ausführt.",
        "Enforce WebSocketPingInterval to 60 seconds": "WebSocketPingInterval auf 60 Sekunden erzwingen",
        "Controls whether the server automatically uses GetConfiguration/ChangeConfiguration after connecting to set WebSocketPingInterval to 60 seconds.": "Steuert, ob der Server nach dem Verbindungsaufbau automatisch GetConfiguration/ChangeConfiguration nutzt, um WebSocketPingInterval auf 60 Sekunden festzulegen.",
        "Send TriggerMessage for MeterValues on start": "TriggerMessage für MeterValues beim Start senden",
        "Controls whether a TriggerMessage to fetch MeterValues is sent automatically after connecting.": "Steuert, ob nach dem Verbindungsaufbau automatisch eine TriggerMessage zum Abholen von MeterValues gesendet wird.",
        "Send TriggerMessage for StatusNotification on start": "TriggerMessage für StatusNotification beim Start senden",
        "When enabled, the server requests a StatusNotification immediately after connecting.": "Wenn aktiviert, fordert der Server direkt nach dem Verbindungsaufbau eine StatusNotification an.",
        "TriggerMessage for BootNotification before first boot": "TriggerMessage für BootNotification vor erstem Boot",
        "Disable if the charge point reacts sensitively to TriggerMessage(BootNotification) when other messages arrive before the first BootNotification.": "Deaktivieren, falls die Wallbox auf TriggerMessage(BootNotification) empfindlich reagiert, wenn andere Nachrichten vor der ersten BootNotification eintreffen.",
        "Heartbeat interval (seconds)": "Heartbeat-Intervall (Sekunden)",
        "Default value for HeartbeatInterval and the interval field of the BootNotification.": "Vorgabewert für das HeartbeatInterval sowie den Interval-Wert der BootNotification.",
        "Additional ChangeConfiguration commands": "Zusätzliche ChangeConfiguration-Befehle",
        "Define up to five additional ChangeConfiguration commands that are sent automatically to each charge point during connection. Enable only the entries that should be used.": "Bis zu fünf zusätzliche ChangeConfiguration-Kommandos definieren, die beim Verbindungsaufbau automatisch an jede Wallbox gesendet werden. Nur aktivieren, was genutzt werden soll.",
        "Enable ChangeConfiguration entry {index}": "ChangeConfiguration-Eintrag {index} aktivieren",
        "When enabled, the key and value below are set automatically via ChangeConfiguration.": "Bei Aktivierung werden Schlüssel und Wert automatisch über ChangeConfiguration gesetzt.",
        "Configuration key": "Konfigurationsschlüssel",
        "e.g. AllowOfflineTxForUnknownId": "z. B. AllowOfflineTxForUnknownId",
        "Value": "Wert",
        "e.g. true": "z. B. true",
        "Latest status updates for all charge points based on the most recent StatusNotifications.": "Aktuelle Statusmeldungen aller Ladepunkte basierend auf den letzten StatusNotifications.",
        "Start status round call?": "Status-Rundruf wirklich starten?",
        "Status Call": "Status Call",
        "Status data could not be loaded from the database. Details: {details}": "Die Statusdaten konnten nicht aus der Datenbank geladen werden. Details: {details}",
        "Status message": "Statusmeldung",
        "Reported": "Gemeldet",
        "No status messages are available yet.": "Es liegen noch keine Statusmeldungen vor.",
        "Chargepoint Monitoring Dashboard": "Ladepunkt-Monitoring-Dashboard",
        "Show only connected charge points": "Nur verbundene Ladepunkte anzeigen",
        "Filter connected, {count} connected charge points": "Verbunden filtern, {count} verbundene Ladepunkte",
        "Show all charge points": "Alle Ladepunkte anzeigen",
        "Show all charge points, {count} total": "Alle Ladepunkte anzeigen, insgesamt {count}",
        "Total": "Gesamt",
        "Show only flagged charge points": "Nur markierte Ladepunkte anzeigen",
        "Filter flagged charge points, {count} flagged": "Markierte Ladepunkte filtern, {count} markiert",
        "Flagged": "Auffällig",
        "Longest connection duration": "Längste Verbindungsdauer",
        "Shortest connection duration": "Kürzeste Verbindungsdauer",
        "OCPP Broker Endpoint:": "OCPP-Broker-Endpunkt:",
        "Broker status:": "Broker-Status:",
        "Reachable": "Erreichbar",
        "Not reachable": "Nicht erreichbar",
        "Marked Wallboxes": "Markierte Wallboxen",
        "Entries from manual checks and automatic fault detection.": "Einträge aus manuellen Prüfungen und automatischer Fehlererkennung.",
        "Clear error list": "Fehlerliste leeren",
        "Reason": "Grund",
        "Error history": "Fehlerhistorie",
        "View config": "Konfiguration ansehen",
        "Service": "Service",
        "Fault detection": "Fehlererkennung",
        "Manual": "Manuell",
        "Request service": "Service anfordern",
        "Service request sent": "Service-Anfrage gesendet",
        "Service request sent on {timestamp}": "Service-Anfrage gesendet am {timestamp}",
        "Remove mark?": "Markierung entfernen?",
        "Remove mark": "Markierung entfernen",
        "Toggle highlight": "Highlight umschalten",
        "Open location": "Standort öffnen",
        "WebUI Remote": "WebUI Remote",
        "Open WebUI remote access": "WebUI Remote Access öffnen",
        "Open Load Management remote access": "Load Management Remote Access öffnen",
        "Load Management": "Load Management",
        "Quality": "Qualität",
        "Reconnect counter": "Reconnect-Zähler",
        "Last connected": "Zuletzt verbunden",
        "View configuration": "Konfiguration anzeigen",
        "Open OCPP logs": "OCPP-Logs anzeigen",
        "Restart charge point": "Wallbox neu starten",
        "Download diagnostics": "Diagnose herunterladen",
        "Delete connection": "Verbindung löschen",
        "Mark charge point": "Wallbox markieren",
        "Open settings": "Einstellungen öffnen",
        "Mark as highlight": "Als Highlight markieren",
        "Remove highlight": "Highlight entfernen",
        "Comparison and server actions": "Vergleich und Serveraktionen",
        "No charge point selected (max. {limit})": "Keine Wallbox ausgewählt (max. {limit})",
        "Compare charge points, open selection": "Wallboxen vergleichen, Auswahl öffnen",
        "Compare": "Vergleichen",
        "Reset reconnect counter": "Reconnect-Zähler zurücksetzen",
        "Reload server-side database": "Serverseitige Datenbank neu laden",
        "Service request": "Service-Anfrage",
        "Close": "Schließen",
        "Please select": "Bitte auswählen",
        "Replace device": "Gerät tauschen",
        "Technician on site required": "Techniker vor Ort erforderlich",
        "Additional information": "Zusätzliche Informationen",
        "Please choose an action.": "Bitte eine Maßnahme auswählen.",
        "Service request sent.": "Service-Anfrage gesendet.",
        "Service request already sent.": "Service-Anfrage wurde bereits versendet.",
        "The service request could not be sent.": "Die Service-Anfrage konnte nicht gesendet werden.",
        "Network error: {error}": "Netzwerkfehler: {error}",
        "Download started for {id}": "Download gestartet für {id}",
        "Reboot command sent to {id}": "Reboot-Befehl gesendet an {id}",
        "Reboot failed: {error}": "Fehler beim Reboot: {error}",
        "Network error during reboot: {error}": "Netzwerkfehler beim Reboot: {error}",
        "Delete entry: {id}?": "Eintrag löschen: {id}?",
        "Entry deleted: {id}": "Eintrag gelöscht: {id}",
        "Delete failed: {error}": "Löschen fehlgeschlagen: {error}",
        "Network error while deleting": "Netzwerkfehler beim Löschen",
        "Select {id} for comparison": "{id} für Vergleich auswählen",
        "{count} charge point selected (max. {limit})": "{count} Wallbox ausgewählt (max. {limit})",
        "{count} charge points selected (max. {limit})": "{count} Wallboxen ausgewählt (max. {limit})",
        "You can compare up to {limit} charge points.": "Es können maximal {limit} Wallboxen verglichen werden.",
        "1 entry will be deleted. Continue?": "1 Eintrag wird gelöscht. Fortfahren?",
        "{count} entries will be deleted. Continue?": "{count} Einträge werden gelöscht. Fortfahren?",
        "Charge point {id} marked as highlight.": "Wallbox {id} als Highlight markiert.",
        "Highlight removed for {id}.": "Highlight für {id} entfernt.",
        "Could not update highlight: {error}": "Highlight konnte nicht aktualisiert werden: {error}",
        "Diagnostic Files": "Diagnosedateien",
        "Diagnostic file": "Diagnosepaket",
        "Diagnostic reports": "Diagnoseberichte",
        "All found {extension} archives in the folder {directory}.": "Alle gefundenen {extension}-Archive im Ordner {directory}.",
        "Delete diagnostic package {filename}?": "Soll das Diagnosepaket {filename} gelöscht werden?",
        "Delete files": "Dateien löschen",
        "Medium": "Mittel",
        "No diagnostic archives were found.": "Es wurden keine Diagnosearchive gefunden.",
        "Open action summary": "Maßnahmen öffnen",
        "Open diagnostic report": "Diagnosebericht öffnen",
        "Overview of downloaded diagnostic packages. The analysis was generated automatically by AI and may contain errors.": "Übersicht der heruntergeladenen Diagnosepakete. Die Analyse wurde automatisch von KI erstellt und kann Fehler enthalten.",
        "Report": "Bericht",
        "Severity": "Schweregrad",
        "Device List": "Geräteliste",
        "Closed handshakes": "Abgeschlossene Handshakes",
        "Digital Charger Twins": "Digitale Charger Twins",
        "English": "Englisch",
        "Edit wallbox": "Wallbox bearbeiten",
        "Enterprise": "Enterprise",
        "Enable charging analytics": "Ladeanalyse aktivieren",
        "Enable MQTT forwarding": "MQTT-Weiterleitung aktivieren",
        "Enable OCPI backend": "OCPI-Backend aktivieren",
        "Load management remote access URL": "Load Management Remote Access URL",
        "External API": "Externe API",
        "External API Log description": "Die Tabelle zeigt die neuesten 200 Einträge aus dem externen API-Protokoll.",
        "External API Logs": "Externe API-Logs",
        "Failed or pending exchanges that may need attention.": "Fehlgeschlagene oder offene Handshakes, die Aufmerksamkeit benötigen.",
        "Define the source address and optionally choose a predefined forwarding target.": "Quelladresse festlegen und optional eine vordefinierte Weiterleitung wählen.",
        "Define which OCPP version the wallbox should use.": "Festlegen, welche OCPP-Version die Wallbox verwenden soll.",
        "Fault Detection": "Fehlererkennung",
        "Fleet Vehicles": "Flottenfahrzeuge",
        "Firmware updates": "Firmware-Updates",
        "Global RFIDs": "Globale RFIDs",
        "ID Tag Management": "RFID-Tag-Verwaltung",
        "Incoming Header": "Incoming Header",
        "Incorrect password": "Falsches Passwort",
        "Inspection Methodology": "Inspektionsmethodik",
        "Last update": "Letztes Update",
        "Monitor OCPI handshake attempts, tokens, and mTLS readiness per backend.": "Überwachen Sie OCPI-Handshakes, Tokens und mTLS-Bereitschaft pro Backend.",
        "Language": "Sprache",
        "New password": "Neues Passwort",
        "Light": "Hell",
        "Light mode": "Hell-Modus",
        "Location & remote access": "Standort & Remote-Zugriff",
        "Location link": "Standort-Link",
        "Location": "Standort",
        "Locations": "Standorte",
        "Last disconnect at {timestamp}.": "Zuletzt getrennt am {timestamp}.",
        "Latest heartbeat at {timestamp}.": "Letzter Heartbeat um {timestamp}.",
        "Latest entries": "Neueste Einträge",
        "Links": "Links",
        "Dashboard": "Dashboard",
        "Logo for the {mode}": "Logo für den {mode}",
        "Logo for {mode} removed.": "Logo für den {mode} wurde entfernt.",
        "Logo for {mode} updated.": "Logo für den {mode} wurde aktualisiert.",
        "Local Lists": "Lokale Listen",
        "Local RFID Entries": "Lokale RFID-Einträge",
        "Login": "Anmeldung",
        "Logout": "Abmelden",
        "Maintenance": "Wartung",
        "Missing backend id for credentials exchange.": "Fehlende Backend-ID für Credentials-Austausch.",
        "Manage Tenants": "Mandanten verwalten",
        "Manual Odometer Entry": "Manuelle Kilometerstand-Eingabe",
        "Manual SOH Entry": "Manuelle SOH-Eingabe",
        "Monitoring": "Monitoring",
        "Monitoring and Errors": "Monitoring und Fehler",
        "MQTT": "MQTT",
        "Manage local list": "Lokale Liste verwalten",
        "Modules": "Module",
        "OCPI": "OCPI",
        "Open handshakes": "Offene Handshakes",
        "OCPP connection": "OCPP-Verbindung",
        "OCPI Backend URL:": "OCPI-Backend-URL:",
        "OCPI Backends": "OCPI-Backends",
        "OCPI backend assignment": "OCPI-Backend-Zuordnung",
        "OCPI backend assignments": "OCPI-Backend-Zuordnungen",
        "OCPI assignments": "OCPI-Zuordnungen",
        "Optional Basic Auth credentials for backend requests.": "Optionaler Basic-Auth-Zugang für Backend-Anfragen.",
        "No authorization events have been recorded yet.": "Es wurden noch keine Autorisierungsereignisse aufgezeichnet.",
        "No OCPI backends configured. Please add them under \"{menu}\" first.": "Keine OCPI-Backends konfiguriert. Bitte zunächst unter \"{menu}\" anlegen.",
        "No OCPI assignments recorded yet.": "Noch keine OCPI-Zuordnungen vorhanden.",
        "Not Connected": "Nicht verbunden",
        "OCPP Broker": "OCPP-Broker",
        "OCPP log": "OCPP-Log",
        "OCPP version": "OCPP-Version",
        "OCPP Commands": "OCPP-Befehle",
        "OCPI Wallboxes": "OCPI-Wallboxen",
        "Optional": "Optional",
        "OP Redirects": "OP-Weiterleitungen",
        "Open load management remote access": "Load Management Remote Access öffnen",
        "Open WebUI remote access": "WebUI Remote Access öffnen",
        "Optional information about the station and its remote access.": "Optionale Informationen zur Station und deren Fernzugriff.",
        "Optionally assign the wallbox to a tenant. Without selection it remains with the admin tenant.": "Ordnen Sie die Wallbox optional einem Tenant zu. Ohne Auswahl verbleibt sie beim Admin-Tenant.",
        "Ping monitoring (24h)": "Ping-Monitoring (24h)",
        "Assign OCPI backends per station and control activation/priority for EMSP routing.": "OCPI-Backends pro Station zuweisen und Aktivierung/Priorität für das EMSP-Routing steuern.",
        "Password": "Passwort",
        "Peer token": "Peer-Token",
        "Peer token ending in": "Peer-Token endet auf",
        "Peer token missing": "Peer-Token fehlt",
        "Set a new password": "Neues Passwort festlegen",
        "PnC enabled": "PnC aktiviert",
        "Processed Charging Sessions": "Verarbeitete Ladevorgänge",
        "Admin": "Admin",
        "Predefined route": "Vordefinierte Route",
        "WebUI remote access URL": "WebUI Remote Access URL",
        "— manual entry —": "— manuelle Eingabe —",
        "Priority": "Priorität",
        "Recent authorization events": "Aktuelle Autorisierungsereignisse",
        "Received invalid response from broker.": "Ungültige Antwort vom Broker erhalten.",
        "Routing & monitoring": "Routing & Monitoring",
        "Update your personal access password for the operator area.": "Aktualisieren Sie Ihr persönliches Zugangspasswort für den Operator-Bereich.",
        "Please choose a file.": "Bitte wählen Sie eine Datei aus.",
        "Pending or failed exchanges that may need attention.": "Ausstehende oder fehlgeschlagene Handshakes, die Aufmerksamkeit benötigen.",
        "Please choose a secure password with at least 10 characters.": "Bitte vergeben Sie ein sicheres Passwort mit mindestens 10 Zeichen.",
        "Ready to activate": "Bereit zur Aktivierung",
        "RFID Mapping": "RFID-Zuordnung",
        "Select theme": "Farbwelt wählen",
        "Source URL": "Source URL",
        "Strict availability": "Strikte Verfügbarkeit",
        "Manage vendor- and model-specific configuration checks.": "Verwalte Hersteller- und Modell-spezifische Konfigurationsprüfungen.",
        "Manufacturer": "Hersteller",
        "Model": "Modell",
        "ID": "ID",
        "Edit rules": "Regeln bearbeiten",
        "New rule set": "Neue Regel-Liste",
        "Create an additional manufacturer/model combination.": "Erstelle eine weitere Hersteller-/Modellkombination.",
        "e.g. BMW": "z. B. BMW",
        "e.g. i Wallbox": "z. B. i Wallbox",
        "Really delete this list?": "Liste wirklich löschen?",
        "Create": "Anlegen",
        "Rules": "Regeln",
        "Rule Sets": "Regel-Listen",
        "Server Startup Configuration": "Server-Startkonfiguration",
        "Target WS URL": "Target WS URL",
        "Back": "Zurück",
        "Partner": "Partner",
        "Module": "Modul",
        "HTTP status": "HTTP-Status",
        "Page size": "Seitengröße",
        "Filter": "Filtern",
        "Reset": "Zurücksetzen",
        "Chargepoint": "Ladepunkt",
        "Response Code:": "Antwortcode:",
        "No log entries found.": "Keine Logeinträge gefunden.",
        "Page": "Seite",
        "Settings": "Einstellungen",
        "Source": "Quelle",
        "Tenant assignment": "Tenant-Zuordnung",
        "The selection controls which tenant the wallbox belongs to.": "Die Auswahl steuert, welchem Tenant die Wallbox zugeordnet wird.",
        "The WebSocket address is completed automatically when a route is selected.": "Die WebSocket-Adresse wird bei Auswahl einer Route automatisch ergänzt.",
        "Tenant": "Mandant",
        "Dark": "Dunkel",
        "Dark mode": "Dunkel-Modus",
        "Active": "Aktiv",
        "Active theme": "Aktive Farbwelt",
        "Upload logo": "Logo hochladen",
        "Delete logo": "Logo löschen",
        "Unsupported file format. Allowed: {allowed}.": "Nicht unterstütztes Dateiformat. Erlaubt sind: {allowed}.",
        "The file is empty.": "Die Datei ist leer.",
        "The file is too large (maximum {max_mb} MB).": "Die Datei ist zu groß (maximal {max_mb} MB).",
        "The file could not be saved.": "Die Datei konnte nicht gespeichert werden.",
        "Unknown color theme selected.": "Unbekannte Farbwelt ausgewählt.",
        "No logo stored for {mode}.": "Für den {mode} ist kein Logo hinterlegt.",
        "Upload separate logos for light and dark modes. Supported formats: PNG, JPG, GIF, SVG or WEBP. Maximum file size: {size_kb} KB.": "Laden Sie getrennte Logos für den hellen und dunklen Darstellungsmodus hoch. Unterstützte Formate: PNG, JPG, GIF, SVG oder WEBP. Maximale Dateigröße: {size_kb} KB.",
        "This logo is used when the light display mode is active.": "Dieses Logo wird verwendet, wenn der helle Darstellungsmodus aktiv ist.",
        "This logo is displayed when the dark display mode is selected.": "Dieses Logo wird angezeigt, wenn der dunkle Darstellungsmodus gewählt ist.",
        "If no logo is set here, the {mode} logo is used automatically.": "Ist hier kein Logo hinterlegt, wird automatisch das Logo des {mode} verwendet.",
        "Current logo ({mode}):": "Aktuelles Logo ({mode}):",
        "Current logo for the {mode}": "Aktuelles Logo für den {mode}",
        "Currently using the {mode} logo.": "Aktuell wird das Logo des {mode} verwendet.",
        "Choose logo file": "Logo-Datei auswählen",
        "Dashboard logos": "Logos für das Dashboard",
        "Do you really want to delete the {mode} logo?": "Möchten Sie das Logo für den {mode} wirklich löschen?",
        "Standard colors of the classic Pipelet interface.": "Standardfarben der bisherigen Pipelet-Oberfläche.",
        "New color theme featuring Glacial Blue and Deep Teal accents.": "Neue Farbwelt mit Glacial Blue und Deep Teal Akzenten.",
        "System Configuration": "Systemkonfiguration",
        "Token Login": "Token-Login",
        "Tenants": "Mandanten",
        "Simulator status": "Simulatorstatus",
        "running": "läuft",
        "stopped": "gestoppt",
        "Backend URL": "Backend-URL",
        "Charge Point ID": "Chargepoint-ID",
        "ID Tag {number}": "ID-Tag {number}",
        "Meter reading": "Zählerstand",
        "MQTT Overview": "MQTT-Übersicht",
        "Overview of the MQTT configuration and enabled forwards.": "Überblick über die MQTT-Konfiguration und aktivierte Weiterleitungen.",
        "MQTT is configured": "MQTT ist konfiguriert",
        "MQTT is not configured": "MQTT ist nicht konfiguriert",
        "The MQTT server {host}:{port} is reachable.": "Der MQTT-Server {host}:{port} ist erreichbar.",
        "The MQTT server {host}:{port} is not reachable.": "Der MQTT-Server {host}:{port} ist nicht erreichbar.",
        "MQTT Broker": "MQTT-Broker",
        "Port": "Port",
        "Topic prefix": "Topic-Präfix",
        "Example: View all messages with {command}": "Beispiel: Alle Nachrichten mit {command} ansehen",
        "Run the following command on a machine with mosquitto-clients installed to view all messages on the configured topic.": "Führen Sie den folgenden Befehl auf einem Rechner mit installiertem mosquitto-clients aus, um alle Nachrichten auf dem konfigurierten Topic zu sehen.",
        "Wallboxes with MQTT forwarding": "Wallboxen mit MQTT-Weiterleitung",
        "Error loading wallboxes: {error}": "Fehler beim Laden der Wallboxen: {error}",
        "Source URL": "Source-URL",
        "Backend target": "Backend-Ziel",
        "No wallboxes with MQTT forwarding found.": "Keine Wallboxen mit aktivierter MQTT-Weiterleitung gefunden.",
        "Change configuration": "Konfiguration ändern",
        "FTP Diagnostic Files": "FTP-Diagnosedateien",
        "Download or delete diagnostic files from the FTP staging area.": "Laden Sie Diagnosedateien aus dem FTP-Puffer herunter oder löschen Sie sie.",
        "Back to dashboard": "Zurück zum Dashboard",
        "Available files": "Verfügbare Dateien",
        "All files are loaded from the configured FTP directory.": "Alle Dateien werden aus dem konfigurierten FTP-Verzeichnis geladen.",
        "file": "Datei",
        "files": "Dateien",
        "Filename": "Dateiname",
        "Download": "Herunterladen",
        "No diagnostic files are currently available.": "Es sind derzeit keine Diagnosedateien vorhanden.",
        "Firmware Management": "Firmwareverwaltung",
        "Manage available firmware files and distribute updates to your wallboxes.": "Verwalten Sie verfügbare Firmware-Dateien und verteilen Sie Updates an Ihre Wallboxen.",
        "Available firmware": "Verfügbare Firmware",
        "Firmware files are loaded from {path}.": "Firmware-Dateien werden aus {path} geladen.",
        "Size": "Größe",
        "Last modified": "Zuletzt geändert",
        "No firmware files (≥100 KB) available.": "Keine Firmware-Dateien (≥100 KB) vorhanden.",
        "Upload new firmware": "Neue Firmware hochladen",
        "Provide new firmware packages for your fleet.": "Stellen Sie neue Firmware-Pakete für Ihre Flotte bereit.",
        "Only files of at least 100 KB appear in the list above.": "Nur Dateien mit mindestens 100 KB erscheinen in der obigen Liste.",
        "Upload firmware": "Firmware hochladen",
        "Send firmware to a wallbox": "Firmware an eine Wallbox senden",
        "Choose firmware, target wallbox, and delivery method.": "Wählen Sie Firmware, Ziel-Wallbox und Übertragungsweg.",
        "Download protocol": "Download-Protokoll",
        "Send via": "Senden über",
        "Send firmware": "Firmware senden",
        "Delete firmware \"{filename}\"?": "Firmware \"{filename}\" löschen?",
        "Deleted firmware {filename}": "Firmware {filename} gelöscht",
        "Deleting firmware failed: {error}": "Firmware-Löschung fehlgeschlagen: {error}",
        "Network error while deleting firmware.": "Netzwerkfehler beim Löschen der Firmware.",
        "Please choose a firmware file.": "Bitte wählen Sie eine Firmware-Datei.",
        "Please choose a wallbox.": "Bitte wählen Sie eine Wallbox.",
        "Firmware {filename} sent to {station}": "Firmware {filename} an {station} gesendet",
        "Sending firmware failed: {error}": "Senden der Firmware fehlgeschlagen: {error}",
        "Network error while sending firmware.": "Netzwerkfehler beim Senden der Firmware.",
        "The broker could not be reached.": "Der Broker konnte nicht erreicht werden.",
        "The wallbox \"{display_id}\" is currently connected.": "Die Wallbox \"{display_id}\" ist verbunden.",
        "The wallbox \"{display_id}\" is currently not connected.": "Die Wallbox \"{display_id}\" ist derzeit nicht verbunden.",
        "The wallbox \"{display_id}\" is not registered with the broker.": "Die Wallbox \"{display_id}\" ist nicht beim Broker registriert.",
        "Timestamp": "Zeitpunkt",
        "Toggle theme": "Darstellung umschalten",
        "Unauthorized": "Nicht autorisiert",
        "Used Header": "Verwendeter Header",
        "User": "Benutzer",
        "Username": "Benutzername",
        "Username or password incorrect": "Benutzername oder Passwort ist falsch",
        "Vehicle Database": "Fahrzeugdatenbank",
        "Virtual Charging Station": "Virtuelle Ladestation",
        "Wallbox": "Wallbox",
        "Wallbox \"{display_id}\" connection check": "Wallbox \"{display_id}\" Verbindungsprüfung",
        "connected": "verbunden",
        "Closed": "Geschlossen",
        "Open": "Offen",
        "de": "Deutsch",
        "e.g. TACW123456": "z. B. TACW123456",
        "en": "Englisch",
        "Action": "Aktion",
        "Actions": "Aktionen",
        "Actions summary": "Maßnahmen",
        "Audit note": "Audit-Notiz",
        "Authorization header": "Authorization Header",
        "Base URL*": "Basis-URL*",
        "Current direct link:": "Aktueller Direktlink:",
        "Enable token login": "Token-Login aktivieren",
        "Enables direct dashboard access via a token link. The default token is {token} and can be changed here.": "Ermöglicht den Direktzugang zum Dashboard über einen Token-Link. Der Standardtoken lautet {token} und kann hier angepasst werden.",
        "Hint": "Hinweis",
        "Key": "Key",
        "Value": "Wert",
        "CA bundle": "CA-Bundle",
        "Certificate overview": "Zertifikatsübersicht",
        "Certificate type": "Zertifikatstyp",
        "Check the connection before triggering a PnC command.": "Verbindung prüfen, bevor ein PnC-Befehl ausgelöst wird.",
        "Calculate the checksum for a SECC ID without the trailing digits.": "Berechnen Sie die Prüfziffer für eine SECC-ID ohne abschließende Stellen.",
        "Calculated SECC ID": "Berechnete SECC-ID",
        "Client certificate": "Client-Zertifikat",
        "Connected stations": "Verbundene Stationen",
        "Connected stations list could not be loaded: {error}": "Verbundene Stationsliste konnte nicht geladen werden: {error}",
        "Connector ID": "Connector-ID",
        "Connectors": "Connectoren",
        "Contract-cert reference": "Contract-Cert Reference",
        "Current storage paths, expiration dates, and validity.": "Aktuelle Ablagepfade, Ablaufdaten und Gültigkeit.",
        "Default (Hard)": "Standard (Hard)",
        "Diagnostics URL": "Diagnostics-URL",
        "Disable": "Deaktivieren",
        "Enable": "Aktivieren",
        "Enable or disable Plug & Charge per charging station.": "Aktivieren oder deaktivieren Sie Plug & Charge pro Ladestation.",
        "Error reading file": "Fehler beim Lesen",
        "Expiration": "Ablauf",
        "File": "Datei",
        "Found": "Gefunden",
        "Hubject API settings": "Hubject API Einstellungen",
        "Hubject session/partner ID": "Hubject Session/Partner ID",
        "IdTag": "IdTag",
        "Initiator": "Initiator",
        "Keep track of the status of your Plug & Charge integration.": "Behalten Sie den Status Ihrer Plug & Charge-Integration im Blick.",
        "Key figures and status messages for contracts, certificates, and authorizations will appear here in the future.": "Hier werden künftig Kennzahlen und Statusmeldungen zu Ladeverträgen, Zertifikaten und Autorisierungen angezeigt.",
        "Leave empty if no additional header is required.": "Leer lassen, wenn kein zusätzlicher Header benötigt wird.",
        "Manage authorizations for Plug & Charge contracts.": "Verwalten Sie die Autorisierungen für Plug & Charge-Verträge.",
        "Manage base URL, authorization, and timeout for Hubject.": "Verwalten Sie Basis-URL, Autorisierung und Timeout für Hubject.",
        "Manage Hubject mTLS artifacts for Plug & Charge.": "Pflegen Sie die Hubject-mTLS-Artefakte für Plug & Charge.",
        "Missing": "Fehlt",
        "No action executed yet.": "Noch keine Aktion ausgeführt.",
        "No handshakes recorded yet.": "Noch keine Handshakes erfasst.",
        "No details": "Keine Details",
        "Notes": "Notizen",
        "Optional correlation value (e.g. SessionID or EMP/CPO PartnerSessionID).": "Optionaler Korrelationswert (z. B. SessionID oder EMP/CPO PartnerSessionID).",
        "Optional for CDR/Push calls": "Optional für CDR/Push Calls",
        "Optional for RemoteStop": "Optional für RemoteStop",
        "Optional, otherwise default": "Optional, sonst Standard",
        "Path": "Pfad",
        "Please choose": "Bitte wählen",
        "PnC": "PnC",
        "PnC contract certificate": "PnC Vertragszertifikat",
        "Plug & Charge – Authorizations": "Plug & Charge – Authorisierungen",
        "Plug & Charge – Certificates": "Plug & Charge – Zertifikate",
        "Plug & Charge – Hubject API": "Plug & Charge – Hubject API",
        "Plug & Charge – SECC check digit": "Plug & Charge – SECC-Prüfziffer",
        "Plug & Charge – Overview": "Plug & Charge – Übersicht",
        "Plug & Charge – OCPP commands": "Plug & Charge – OCPP-Befehle",
        "Plug & Charge – Stations": "Plug & Charge – Stationen",
        "Please choose a file.": "Bitte wählen Sie eine Datei aus.",
        "Private key": "Private Key",
        "Please verify the target URL before enabling OCPI forwarding.": "Bitte die Ziel-URL prüfen, bevor der OCPI-Versand aktiviert wird.",
        "Response / audit": "Antwort / Audit",
        "Re-run handshake": "Handshake erneut ausführen",
        "Reset type": "Reset-Typ",
        "Save": "Speichern",
        "Retry interval (s)": "Retry-Interval (s)",
        "Default token expiry date (YYYY-MM-DD)": "Standard-Ablaufdatum für Token (JJJJ-MM-TT)",
        "Token expiry warning (days)": "Warnung Token-Ablauf (Tage)",
        "Token expiry map (JSON)": "Token-Ablaufkarte (JSON)",
        "Provide per-partner expiry dates as a JSON object.": "Partner-spezifische Ablaufdaten als JSON-Objekt angeben.",
        "Optional default expiry for tokens without per-partner overrides.": "Optionales Ablaufdatum für Token ohne partner-spezifische Angabe.",
        "Send alerts when a token expires within this many days.": "Alarme senden, wenn ein Token in so vielen Tagen abläuft.",
        "Token {name} expires soon": "Token {name} läuft bald ab",
        "≈ {days} days": "≈ {days} Tage",
        "Module error threshold (%)": "Modul-Fehlerschwelle (%)",
        "Triggers alerts when a module exceeds this error rate.": "Löst Alarme aus, wenn ein Modul diese Fehlerrate überschreitet.",
        "Partner error threshold (%)": "Partner-Fehlerschwelle (%)",
        "Threshold for partner/backend failure rate alerts.": "Schwelle für Fehlerraten bei Partnern/Backends.",
        "Webhook down (minutes)": "Webhook ausgefallen (Minuten)",
        "Alert when webhook/slack endpoint stays unreachable.": "Benachrichtigen, wenn Webhook/Slack nicht erreichbar bleibt.",
        "Alerts & thresholds": "Alarme & Schwellenwerte",
        "Quiet hours start (HH:MM)": "Ruhezeit Beginn (HH:MM)",
        "Quiet hours end (HH:MM)": "Ruhezeit Ende (HH:MM)",
        "Quiet hours timezone": "Zeitzone für Ruhezeiten",
        "Silence notifications during the configured window.": "Benachrichtigungen im angegebenen Zeitfenster unterdrücken.",
        "Alert cooldown (seconds)": "Alarm-Cooldown (Sekunden)",
        "Minimum interval between repeated alerts of the same type.": "Mindestabstand zwischen identischen Alarmen.",
        "Webhook unreachable": "Webhook nicht erreichbar",
        "Down > {minutes} min": "Ausfall > {minutes} Min",
        "Sending OCPI CDRs is disabled by default and must be explicitly enabled.": "Der Versand von OCPI-CDRs ist standardmäßig deaktiviert und muss explizit eingeschaltet werden.",
        "Send RemoteStart/Stop, resets, or diagnostic requests to connected stations with Plug & Charge metadata.": "Senden Sie RemoteStart/Stop, Resets oder Diagnostikanfragen an verbundene Stationen mit Plug & Charge-Metadaten.",
        "Send command": "Befehl senden",
        "SECC check digit": "SECC-Prüfziffer",
        "SECC ID without check digit": "SECC-ID ohne Prüfziffer",
        "Shows the structured results of the last action.": "Zeigt die strukturierten Ergebnisse der letzten Aktion.",
        "Since": "Seit",
        "Station": "Station",
        "Station ID": "Station-ID",
        "Multiple targets can be maintained in parallel. Active backends are used per station according to the assignment list.": "Mehrere Ziele können parallel gepflegt werden. Aktivierte Backends werden pro Station gemäß der Zuordnungsliste genutzt.",
        "Station overview": "Stationsübersicht",
        "Status": "Status",
        "Successful or recently completed exchanges.": "Erfolgreiche oder kürzlich abgeschlossene Handshakes.",
        "Enter the SECC ID without the trailing two-digit checksum.": "Geben Sie die SECC-ID ohne die abschließende zweistellige Prüfsumme ein.",
        "The token is only used when OCPI forwarding is explicitly enabled.": "Das Token wird nur genutzt, wenn die OCPI-Weiterleitung explizit aktiviert ist.",
        "The check digit updates automatically while you type.": "Die Prüfziffer wird während der Eingabe automatisch aktualisiert.",
        "Timeout (seconds)": "Timeout (Sekunden)",
        "Token": "Token",
        "Tokens / mTLS": "Tokens / mTLS",
        "mTLS ready": "mTLS bereit",
        "Toggle PnC with one click and jump to OCPP actions.": "Schalten Sie PnC mit einem Klick um und springen Sie zu OCPP-Aktionen.",
        "Transaction ID": "Transaction-ID",
        "Type": "Typ",
        "URL": "URL",
        "Upload": "Hochladen",
        "Upload or replace certificate": "Zertifikat hochladen oder ersetzen",
        "Validates the connection before sending and records the action with PnC metadata.": "Validiert die Verbindung vor dem Versand und dokumentiert die Aktion mit PnC-Metadaten.",
        "max. 2 MB; PEM, CRT, CER or DER. Keys accept PEM/KEY.": "max. 2 MB; PEM, CRT, CER oder DER. Schlüssel akzeptieren PEM/KEY.",
        "overrides contract reference": "überschreibt contract reference",
        "who is sending the command": "wer den Befehl sendet",
        "e.g. 1": "z. B. 1",
        "e.g. Basic ... or Bearer ...": "z. B. Basic ... oder Bearer ...",
        "OCPI Settings": "OCPI-Einstellungen",
        "Configure base URLs, tokens, webhooks, and retry behaviour for the OCPI API.": "Basis-URLs, Tokens, Webhooks und Retry-Verhalten der OCPI-API konfigurieren.",
        "Environment & Base URL": "Umgebung & Basis-URL",
        "Environment": "Umgebung",
        "Production": "Produktion",
        "Sandbox": "Sandbox",
        "Active base URL:": "Aktive Basis-URL:",
        "Base URL (Production)": "Basis-URL (Produktion)",
        "Base URL (Sandbox)": "Basis-URL (Sandbox)",
        "Used when the production environment is active or as fallback for existing peers.": "Wird verwendet, wenn die Produktionsumgebung aktiv ist oder als Fallback für bestehende Partner.",
        "Switch environments without losing the alternative base URL.": "Wechseln Sie die Umgebung, ohne die alternative Basis-URL zu verlieren.",
        "Tokens & Webhooks": "Tokens & Webhooks",
        "Token (Production)": "Token (Produktion)",
        "API token for production traffic": "API-Token für Produktionsverkehr",
        "Generate token": "Token erzeugen",
        "Bearer-style token protecting the production OCPI endpoints.": "Bearer-Token zum Schutz der produktiven OCPI-Endpunkte.",
        "Token (Sandbox)": "Token (Sandbox)",
        "API token for sandbox traffic": "API-Token für Sandbox-Verkehr",
        "Use a separate token for sandbox partners if required.": "Bei Bedarf separates Token für Sandbox-Partner verwenden.",
        "Webhook URL": "Webhook-URL",
        "Optional webhook for OCPI alerts and retries.": "Optionaler Webhook für OCPI-Warnungen und Retries.",
        "Slack webhook URL": "Slack-Webhook-URL",
        "Send OCPI notifications to Slack or compatible channels.": "OCPI-Benachrichtigungen an Slack oder kompatible Kanäle senden.",
        "Email recipients (comma-separated)": "E-Mail-Empfänger (kommagetrennt)",
        "Retry policy": "Retry-Strategie",
        "Retry batch size": "Retry-Batch-Größe",
        "Retry max attempts": "Maximale Retry-Versuche",
        "Controls how often failed OCPI exports are retried and how many items are processed per run.": "Steuert, wie oft fehlgeschlagene OCPI-Exporte erneut gesendet werden und wie viele Einträge pro Lauf verarbeitet werden.",
        "Partners / EMSPs": "Partner / EMSPs",
        "Manage EMSP tokens that are exposed via the OCPI tokens module.": "Verwalten Sie EMSP-Tokens, die über das OCPI-Tokens-Modul bereitgestellt werden.",
        "UID": "UID",
        "Auth ID": "Auth-ID",
        "Issuer": "Aussteller",
        "Whitelist status": "Whitelist-Status",
        "Local RFID": "Lokale RFID",
        "Valid": "Gültig",
        "Expired": "Abgelaufen",
        "Blocked": "Blockiert",
        "Unblock": "Freigeben",
        "Last updated": "Zuletzt aktualisiert",
        "No tokens available yet.": "Noch keine Tokens vorhanden.",
        "Token saved": "Token gespeichert",
        "Token deleted": "Token gelöscht",
        "Token blocked": "Token blockiert",
        "Token unblocked": "Token freigegeben",
        "Cache hits": "Cache-Treffer",
        "Cache misses": "Cache-Verfehlungen",
        "Cache entries": "Cache-Einträge",
        "Cache hit rate": "Cache-Trefferquote",
        "EMSP": "EMSP",
        "CPO": "CPO",
        "UID is required.": "UID ist erforderlich.",
        "Inactive": "Inaktiv",
        "Name": "Name",
        "Delete": "Löschen",
        "Capabilities": "Fähigkeiten",
        "Tariffs": "Tarife",
        "Tariff saved.": "Tarif gespeichert.",
        "Tariff deleted.": "Tarif gelöscht.",
        "Tariff not found.": "Tarif nicht gefunden.",
        "Tariff assignment saved.": "Tarifzuordnung gespeichert.",
        "Tariff assignment removed.": "Tarifzuordnung entfernt.",
        "Tariff details": "Tarifdetails",
        "Tariff overview": "Tarifübersicht",
        "Tariff ID": "Tarif-ID",
        "Tariff title (EN)": "Tarifname (EN)",
        "Tariff title (DE)": "Tarifname (DE)",
        "Tax included": "Steuer inklusive",
        "Valid from": "Gültig ab",
        "Valid until": "Gültig bis",
        "Price components (JSON)": "Preisbestandteile (JSON)",
        "Include VAT in price components or toggle the tax flag above.": "MwSt. in den Preisbestandteilen angeben oder die Steuer-Option oben nutzen.",
        "EMSP surcharges": "EMSP-Aufschläge",
        "Percent markup": "Aufschlag in Prozent",
        "Fixed markup": "Aufschlag als Betrag",
        "Percent values are applied per EMSP; leave backend empty for defaults.": "Prozentwerte werden je EMSP angewendet; Backend leer lassen für Standardaufschläge.",
        "Assignments": "Zuordnungen",
        "Assignment saved.": "Zuordnung gespeichert.",
        "Assignment updated.": "Zuordnung aktualisiert.",
        "Assignment deleted.": "Zuordnung gelöscht.",
        "EVSE (optional)": "EVSE (optional)",
        "Attach to a specific EVSE or leave empty for the location": "Einer bestimmten EVSE zuordnen oder leer lassen für den Standort.",
        "Add assignment": "Zuordnung hinzufügen",
        "Select a tariff to manage assignments.": "Bitte einen Tarif auswählen, um Zuordnungen zu verwalten.",
        "Create or update tariffs including validity, EMSP surcharges, and tax handling.": "Tarife mit Gültigkeiten, EMSP-Aufschlägen und Steuerhandling erstellen oder bearbeiten.",
        "Select": "Auswählen",
        "Yes": "Ja",
        "No": "Nein",
        "None": "Keine",
        "All": "Alle",
        "Location level": "Standort-Ebene",
        "Tariff storage is unavailable.": "Tarifspeicher ist nicht verfügbar.",
        "Tariff ID is required.": "Tarif-ID ist erforderlich.",
        "Tariff ID must be 255 characters or fewer.": "Die Tarif-ID darf höchstens 255 Zeichen lang sein.",
        "Currency must be a 3-letter code.": "Währung muss einen 3-stelligen Code haben.",
        "Valid from must be a valid date/time.": "Gültig ab muss ein gültiges Datum/Zeit sein.",
        "Valid until must be a valid date/time.": "Gültig bis muss ein gültiges Datum/Zeit sein.",
        "Valid until must be after valid from.": "Gültig bis muss nach Gültig ab liegen.",
        "Price elements must be a list or JSON array.": "Preisbestandteile müssen eine Liste oder ein JSON-Array sein.",
        "Surcharges must be a list or JSON array.": "Aufschläge müssen eine Liste oder ein JSON-Array sein.",
        "Backend IDs must be integers.": "Backend-IDs müssen Ganzzahlen sein.",
        "Backend selection is required.": "Backend-Auswahl ist erforderlich.",
        "Percent markup must be numeric.": "Prozentualer Aufschlag muss numerisch sein.",
        "Fixed markup must be numeric.": "Fixer Aufschlag muss numerisch sein.",
        "At least one surcharge value is required.": "Mindestens ein Aufschlagswert ist erforderlich.",
        "Tariff does not exist.": "Tarif existiert nicht.",
        "Tariff assignment target is required.": "Ziel für die Tarifzuordnung ist erforderlich.",
        "Location ID is required.": "Standort-ID ist erforderlich.",
        "Station ID is required.": "Station-ID ist erforderlich.",
        "Synchronization": "Synchronisation",
        "All statuses": "Alle Stati",
        "All capabilities": "Alle Fähigkeiten",
        "All tariffs": "Alle Tarife",
        "Apply filters": "Filter anwenden",
        "Reset": "Zurücksetzen",
        "Bulk edit (JSON)": "Sammelbearbeitung (JSON)",
        "Upload CSV/JSON": "CSV/JSON hochladen",
        "No sync attempts yet": "Noch keine Synchronisationsversuche",
        "No sync attempts yet.": "Noch keine Synchronisationsversuche.",
        "No data available yet.": "Noch keine Daten vorhanden.",
        "Select a CSV or JSON file": "Bitte CSV- oder JSON-Datei auswählen.",
        "Import finished with warnings": "Import mit Warnungen abgeschlossen",
        "Import completed": "Import abgeschlossen",
        "Upload failed": "Upload fehlgeschlagen",
        "Manage OCPI locations, filter by status, capabilities, or tariffs, and review synchronization status with EMSPs.": "OCPI-Standorte verwalten, nach Status, Capabilities oder Tarifen filtern und den Synchronisationsstatus mit EMSPs prüfen.",
        "Filter EVSEs by status, capabilities, or tariffs and check their synchronization status with connected EMSPs.": "EVSEs nach Status, Capabilities oder Tarifen filtern und den Synchronisationsstatus mit angebundenen EMSPs prüfen.",
        "EVSE": "EVSE",
        "Sessions / CDRs": "Sitzungen / CDRs",
        "Sessions (live)": "Sessions (Live)",
        "Active sessions": "Aktive Sessions",
        "Completed sessions": "Abgeschlossene Sessions",
        "CDR queue": "CDR-Warteschlange",
        "Pending CDRs": "Ausstehende CDRs",
        "Failed CDRs": "Fehlgeschlagene CDRs",
        "Live overview of broker sessions and OCPI CDR exports with manual retry controls.": "Live-Übersicht der Broker-Sessions und OCPI-CDRs mit manuellen Retry-Optionen.",
        "Refresh data": "Daten aktualisieren",
        "Session UID": "Session-UID",
        "Transaction": "Transaktion",
        "Session match": "Session gefunden",
        "No matching session found": "Keine passende Session gefunden",
        "Replay failed or pending CDRs directly from this list.": "Fehlgeschlagene oder ausstehende CDRs direkt aus dieser Liste erneut senden.",
        "No sessions recorded yet.": "Noch keine Sessions erfasst.",
        "No CDR exports recorded yet.": "Noch keine CDR-Exporte erfasst.",
        "Retry count": "Anzahl der Retries",
        "Created": "Angelegt",
        "Replaying…": "Wiederholung läuft …",
        "CDR replay sent.": "CDR erneut gesendet.",
        "CDR replay failed.": "CDR-Wiederholung fehlgeschlagen.",
        "Could not refresh data.": "Daten konnten nicht aktualisiert werden.",
        "CDR export table not found.": "CDR-Exporttabelle nicht gefunden.",
        "Export not found.": "Export nicht gefunden.",
        "No OCPI backend configured for this station.": "Kein OCPI-Backend für diese Station konfiguriert.",
        "CDR module is disabled for the selected backend.": "Das CDR-Modul ist für das ausgewählte Backend deaktiviert.",
        "Command Queue": "Befehlswarteschlange",
        "Pending and historical OCPP commands triggered via OCPI/Hubject with retry and cancel controls.": "Ausstehende und historische OCPP-Befehle aus OCPI/Hubject mit Wiederholungs- und Abbruchmöglichkeiten.",
        "Command": "Befehl",
        "Backend": "Backend",
        "Target": "Ziel",
        "OCPP IDs": "OCPP-IDs",
        "Status": "Status",
        "Attempts": "Versuche",
        "Last status": "Letzter Status",
        "Actions": "Aktionen",
        "OCPI ID:": "OCPI-ID:",
        "Backend ID:": "Backend-ID:",
        "Connector": "Steckplatz",
        "Transaction:": "Transaktion:",
        "Reservation:": "Reservierung:",
        "Failures:": "Fehlversuche:",
        "Updated:": "Aktualisiert:",
        "Retry": "Wiederholen",
        "No commands queued yet.": "Keine Befehle in der Warteschlange.",
        "Command ID is required.": "Command-ID wird benötigt.",
        "Unknown action.": "Unbekannte Aktion.",
        "Command retriggered.": "Befehl erneut ausgelöst.",
        "Command cancelled.": "Befehl abgebrochen.",
        "Command action failed: {detail}": "Befehlsaktion fehlgeschlagen: {detail}",
        "Unknown error": "Unbekannter Fehler",
        "Queued": "Eingereiht",
        "In progress": "In Bearbeitung",
        "Cancelled": "Abgebrochen",
        "Succeeded": "Erfolgreich",
        "Start session": "Ladevorgang starten",
        "Stop session": "Ladevorgang stoppen",
        "Reserve now": "Jetzt reservieren",
        "Unlock connector": "Steckdose entriegeln",
        "Unknown status": "Unbekannter Status",
        "Pending": "Ausstehend",
        "Failed": "Fehlgeschlagen",
        "Refresh": "Aktualisieren",
        "Response": "Antwort",
        "Replay": "Erneut senden",
        "Last update": "Letzte Aktualisierung",
        "Completed": "Abgeschlossen",
        "OCPI Sync Overview": "OCPI-Sync-Übersicht",
        "Review the last pull/push attempts per module and retry failed exports.": "Prüfen Sie die letzten Pull-/Push-Versuche pro Modul und wiederholen Sie fehlgeschlagene Exporte.",
        "Sync status": "Sync-Status",
        "Last pull": "Letzter Pull",
        "Last push": "Letzter Push",
        "Last retry": "Letzter Retry",
        "No data yet.": "Noch keine Daten.",
        "Duration": "Dauer",
        "Status code": "Statuscode",
        "Dead-letter queue": "Dead-Letter-Queue",
        "Record type": "Record-Typ",
        "Scheduler jobs": "Scheduler-Jobs",
        "Interval": "Intervall",
        "Mode": "Modus",
        "Sync logs": "Sync-Protokoll",
        "Recent scheduler results": "Aktuelle Scheduler-Ergebnisse",
        "Direction": "Richtung",
        "Job": "Job",
        "Module": "Modul",
        "No logs yet.": "Noch keine Logs.",
        "Running": "Läuft",
        "Stopped": "Gestoppt",
        "Replay sent.": "Replay gesendet.",
        "Replay failed.": "Replay fehlgeschlagen.",
        "Pull": "Pull",
        "Push": "Push",
        "Result": "Ergebnis",
        "CDRs": "CDRs",
        "Database unavailable for KPI lookup: {error}": "Datenbank für KPI-Abruf nicht verfügbar: {error}",
        "No OCPI sync data available yet.": "Keine OCPI-Sync-Daten vorhanden.",
        "Could not load OCPI sync data: {error}": "OCPI-Sync-Daten konnten nicht geladen werden: {error}",
        "Client key": "Client-Schlüssel",
        "Unknown backend": "Unbekanntes Backend",
        "No webhook configured.": "Kein Webhook konfiguriert.",
        "Scheduler status unavailable: {detail}": "Scheduler-Status nicht verfügbar: {detail}",
        "OCPI KPIs": "OCPI-KPIs",
        "Module success": "Erfolgsquote nach Modul",
        "Partner success": "Erfolgsquote nach Partner",
        "Health & alerts": "Gesundheit & Warnungen",
        "Success rate": "Erfolgsquote",
        "Timeout rate": "Timeout-Rate",
        "Average latency": "Durchschnittliche Latenz",
        "p95 latency": "p95-Latenz",
        "Latency": "Latenz",
        "Timeouts": "Timeouts",
        "Webhook reachable": "Webhook erreichbar",
        "Webhook unreachable": "Webhook nicht erreichbar",
        "Expiring soon": "Läuft bald ab",
        "Certificate missing": "Zertifikat fehlt",
        "Certificate ok": "Zertifikat OK",
        "Certificate expiry": "Zertifikatsablauf",
        "Updated": "Aktualisiert",
        "Samples": "Stichproben",
        "KPIs could not be loaded.": "KPIs konnten nicht geladen werden.",
        "Last sync": "Letzter Sync",
        "Warnings": "Warnungen",
        "Aggregated OCPI success, latency, webhook, and certificate KPIs.": "Aggregierte OCPI-Erfolgsquoten, Latenzen, Webhook- und Zertifikats-KPIs.",
        "Sessions": "Sitzungen",
        "Tokens": "Tokens",
        "Success": "Erfolgreich",
        "Export JSON": "JSON exportieren",
        "Export CSV": "CSV exportieren",
        "OCPI version": "OCPI-Version",
        "Validate": "Validieren",
        "Validation passed": "Validierung erfolgreich",
        "Validation errors": "Validierungsfehler",
        "Validation warnings": "Validierungswarnungen",
        "Sandbox samples": "Sandbox-Beispieldaten",
        "Sandbox data applied.": "Sandbox-Daten übernommen.",
        "Sandbox tokens created.": "Sandbox-Tokens erstellt.",
        "No entries found in upload.": "Keine Einträge im Upload gefunden.",
        "Unsupported OCPI module: {module}": "Nicht unterstütztes OCPI-Modul: {module}",
        "Unsupported OCPI version: {version}": "Nicht unterstützte OCPI-Version: {version}",
        "Entry {row}: Unexpected data type.": "Eintrag {row}: Unerwarteter Datentyp.",
        "Entry {row}: Missing required field \"{field}\".": "Eintrag {row}: Pflichtfeld \"{field}\" fehlt.",
        "Entry {row}: Field \"{field}\" must not be empty.": "Eintrag {row}: Feld \"{field}\" darf nicht leer sein.",
        "Entry {row}: Field \"{field}\" must be an array.": "Eintrag {row}: Feld \"{field}\" muss ein Array sein.",
        "Entry {row}: Timestamp \"{field}\" is not a valid ISO 8601 value.": "Eintrag {row}: Zeitstempel \"{field}\" ist kein gültiger ISO-8601-Wert.",
        "Entry {row}: Token type missing, defaulting to RFID.": "Eintrag {row}: Token-Typ fehlt, es wird RFID verwendet.",
        "Entry {row}: Unknown status \"{detail}\".": "Eintrag {row}: Unbekannter Status \"{detail}\".",
        "Validated {count} entries for OCPI {detail}.": "{count} Einträge für OCPI {detail} validiert.",
        "Validation errors found.": "Validierungsfehler gefunden.",
        "Database unavailable for import.": "Datenbank für Import nicht verfügbar.",
        "Import finished with warnings": "Import mit Warnungen abgeschlossen",
        "Import completed": "Import abgeschlossen",
        "Please select a charge point.": "Bitte eine Ladestation auswählen.",
        "Configuration for {chargepoint} was deleted.": "Konfiguration für {chargepoint} wurde gelöscht.",
        "Configuration for {chargepoint} was saved.": "Konfiguration für {chargepoint} wurde gespeichert.",
        "Configuration could not be saved.": "Konfiguration konnte nicht gespeichert werden.",
        "OCPP Routing – Configuration": "OCPP Routing – Konfiguration",
        "Create digital twins of charge points that connect to additional OCPP backends. The twins behave like the original charger, emit the same signals, and process the responses.": "Erzeugen Sie digitale Zwillinge von Ladepunkten, die sich mit zusätzlichen OCPP-Backends verbinden können. Die Zwillinge verhalten sich wie die Original-Ladestation, senden dieselben Signale und verarbeiten die Antworten.",
        "Manage routing rules": "Routing-Regeln verwalten",
        "OCPP backend URL": "OCPP-Backend-URL",
        "e.g. wss://ocpp.pipelet.com/CP1245": "z. B. wss://ocpp.pipelet.com/CP1245",
        "wss://...": "wss://...",
        "OCPP protocol": "OCPP-Protokoll",
        "Please select a protocol": "Bitte ein Protokoll auswählen",
        "No charge points are known yet. Once data is collected, forwarding rules can be configured here.": "Es sind noch keine Ladepunkte bekannt. Sobald Daten erfasst wurden, können hier Weiterleitungen konfiguriert werden.",
        "Active routing configurations": "Aktive Routing-Konfigurationen",
        "Active OCPP messages": "Aktive OCPP-Nachrichten",
        "No messages enabled": "Keine Nachrichten aktiviert",
        "Delete configuration": "Konfiguration löschen",
        "Edit configuration": "Konfiguration bearbeiten",
        "No routing rules have been saved yet.": "Es wurden noch keine Routing-Regeln gespeichert.",
        "All OCPP Messages": "Alle OCPP-Nachrichten",
        "OCPP Routing – Monitoring": "OCPP Routing – Monitoring",
        "Monitor the status of your OCPP routing configurations here.": "Überwachen Sie hier den Status Ihrer OCPP-Routing-Konfigurationen.",
        "Boot Notification": "Boot-Nachricht",
        "Change Availability": "Verfügbarkeit ändern",
        "GetConfiguration": "GetConfiguration",
        "MeterValues": "MeterValues",
        "RemoteStartTransaction": "RemoteStartTransaction",
        "RemoteStopTransaction": "RemoteStopTransaction",
        "StartTransaction": "StartTransaction",
        "StopTransaction": "StopTransaction",
        "UnlockConnector": "Connector entriegeln",
        "SetConfiguration": "SetConfiguration",
        "Authorize": "Autorisieren",
    }
}


def translate_text(text: str, *, lang: str | None = None, **kwargs) -> str:
    """Return the translated text for the current user language."""

    target_lang = (lang or getattr(g, "dashboard_language", None) or DEFAULT_LANGUAGE).lower()
    template = TRANSLATIONS.get(target_lang, {}).get(text)
    if template is None and target_lang != DEFAULT_LANGUAGE:
        template = TRANSLATIONS.get(DEFAULT_LANGUAGE, {}).get(text)
    if template is None:
        template = text
    try:
        return template.format(**kwargs)
    except Exception:
        return template


def build_translation_map(*keys: str, **aliases: str) -> dict[str, str]:
    """Return a translated mapping for the provided keys or alias/text pairs."""

    entries: dict[str, str] = {}
    for key in keys:
        entries[key] = translate_text(key)
    for alias, source_text in aliases.items():
        entries[alias] = translate_text(source_text)
    return entries


def resolve_language() -> str:
    preferred = request.cookies.get(LANGUAGE_COOKIE_NAME, "").lower()
    if preferred in SUPPORTED_LANGUAGES:
        return preferred

    for accepted in request.accept_languages.values():
        lang_code = accepted.split("-")[0].lower()
        if lang_code in SUPPORTED_LANGUAGES:
            return lang_code
    return DEFAULT_LANGUAGE


@app.before_request
def set_dashboard_language():
    g.dashboard_language = resolve_language()


@app.context_processor
def inject_language_tools():
    def language_switch_url(lang_code: str) -> str:
        next_target = request.full_path.rstrip("?") or request.path or url_for("dashboard")
        parsed = urlparse(next_target)
        if parsed.netloc and parsed.netloc != request.host:
            next_target = url_for("dashboard")
        return url_for("set_language", lang=lang_code, next=next_target)

    return {
        "_": translate_text,
        "available_languages": SUPPORTED_LANGUAGES,
        "current_language": getattr(g, "dashboard_language", DEFAULT_LANGUAGE),
        "language_switch_url": language_switch_url,
    }
_ALLOWED_FIRMWARE_PROTOCOLS = {option["value"] for option in FIRMWARE_PROTOCOL_OPTIONS}


def ensure_firmware_directory() -> str:
    os.makedirs(FIRMWARE_DIRECTORY, exist_ok=True)
    return FIRMWARE_DIRECTORY


def build_firmware_download_url(filename: str, protocol: str = DEFAULT_FIRMWARE_PROTOCOL) -> str:
    """Return an absolute download URL for the given firmware filename."""

    if protocol == "ftp":
        base = FIRMWARE_FTP_BASE_URL.rstrip('/')
        return f"{base}/{quote(filename)}"

    relative_path = f'firmware/{filename}'
    if OCPP_SERVER_FW_URL_PREFIX:
        base = f"{OCPP_SERVER_FW_URL_PREFIX.rstrip('/')}/"
        return urljoin(base, relative_path)
    return url_for('static', filename=relative_path, _external=True)


def _format_bytes(num: int) -> str:
    step = 1024.0
    units = ["B", "KB", "MB", "GB", "TB"]
    size = float(num)
    for unit in units:
        if size < step or unit == units[-1]:
            if unit == "B":
                return f"{int(size)} {unit}"
            return f"{size:.1f} {unit}"
        size /= step
    return f"{size:.1f} TB"


def resolve_firmware_path(filename: str) -> tuple[str, str]:
    base_dir = ensure_firmware_directory()
    safe_name = os.path.basename(filename or "").strip()
    if not safe_name:
        raise ValueError("invalid filename")
    full_path = os.path.normpath(os.path.join(base_dir, safe_name))
    base_abs = os.path.abspath(base_dir)
    full_abs = os.path.abspath(full_path)
    if os.path.commonpath([base_abs, full_abs]) != base_abs:
        raise ValueError("invalid filename path")
    return safe_name, full_path

_dashboard_frame_template = _config.get("dashboard_frame_template")
if _dashboard_frame_template:
    if not os.path.isabs(_dashboard_frame_template):
        override_path = os.path.join(BASE_DIR, _dashboard_frame_template)
    else:
        override_path = _dashboard_frame_template

    try:
        with open(override_path, "r", encoding="utf-8") as fh:
            override_source = fh.read()
    except OSError:
        app.logger.warning(
            "Failed to load dashboard_frame_template '%s'", _dashboard_frame_template,
            exc_info=True,
        )
    else:
        app.jinja_loader = ChoiceLoader(
            [
                DictLoader({"op_zz_frame.html": override_source}),
                app.jinja_loader,
            ]
        )


@app.context_processor
def inject_dashboard_settings():
    """Expose configuration flags used by the shared dashboard layout."""

    logo_light_path = get_dashboard_logo_path("light")
    logo_dark_path = get_dashboard_logo_path("dark")
    logo_light_url = url_for("static", filename=logo_light_path) if logo_light_path else None
    logo_dark_url = url_for("static", filename=logo_dark_path) if logo_dark_path else None
    logo_url = logo_light_url or logo_dark_url
    tenants = get_dashboard_tenants() or []
    current_user = get_dashboard_user()

    hidden_menus = set(_dashboard_hidden_menus)
    override_menus = getattr(g, "dashboard_hidden_menus_override", set()) or set()
    hidden_menus.update(override_menus)

    return {
        "dashboard_hidden_menus": hidden_menus,
        "dashboard_logout_url": _logout_url,
        "dashboard_current_user": current_user,
        "dashboard_logo_url": logo_url,
        "dashboard_logo_light_url": logo_light_url,
        "dashboard_logo_dark_url": logo_dark_url,
        "dashboard_tenants": tenants,
        "dashboard_default_tenant": DASHBOARD_DEFAULT_TENANT,
        "dashboard_color_scheme": get_dashboard_color_scheme(),
    }

# Ensure required tables exist
ensure_idtags_table()
ensure_rfid_tables()
ensure_voucher_tables()
ensure_fault_detection_tables()
ensure_ocpi_backend_tables()
ensure_external_api_logging_table()
ensure_op_messages_endpoint_column()
ensure_vehicle_catalog_table()
ensure_vehicle_fleet_table()
ensure_tenant_tables()
ensure_charging_sessions_vehicle_column()
ensure_evse_id_mapping_table()
get_tariff_service().ensure_schema()


def _parse_vehicle_form(form, *, apply_default_losses=False):
    """Validate vehicle form data and return a payload dictionary."""

    errors = []
    payload = {}

    name = (form.get("name") or "").strip()
    manufacturer = (form.get("manufacturer") or "").strip()
    model = (form.get("model") or "").strip()

    if not name:
        errors.append("Name is required.")
    if not manufacturer:
        errors.append("Manufacturer is required.")
    if not model:
        errors.append("Model is required.")

    payload["name"] = name
    payload["manufacturer"] = manufacturer
    payload["model"] = model

    try:
        payload["battery_size_gross_kwh"] = _parse_optional_decimal(
            form.get("battery_size_gross_kwh")
        )
    except ValueError:
        errors.append("Battery Size Gross must be a number.")

    try:
        payload["battery_size_net_kwh"] = _parse_optional_decimal(
            form.get("battery_size_net_kwh")
        )
    except ValueError:
        errors.append("Battery Size Net must be a number.")

    default_losses = DEFAULT_AC_CHARGING_LOSSES if apply_default_losses else None
    try:
        payload["ac_charging_losses_percent"] = _parse_optional_decimal(
            form.get("ac_charging_losses_percent"),
            default=default_losses,
        )
    except ValueError:
        errors.append("AC charging losses must be a number.")

    return payload, errors


def _parse_vehicle_fleet_form(form):
    """Validate fleet vehicle form data and return a payload dictionary."""

    errors = []
    payload = {}

    name = (form.get("name") or "").strip()
    vehicle_catalog_raw = (form.get("vehicle_catalog_id") or "").strip()
    build_year_raw = (form.get("build_year") or "").strip()
    vin = (form.get("vin") or "").strip()
    license_plate = (form.get("license_plate") or "").strip()

    if not name:
        errors.append("Name is required.")

    if vehicle_catalog_raw:
        try:
            vehicle_catalog_id = int(vehicle_catalog_raw)
        except (TypeError, ValueError):
            errors.append("Invalid vehicle selection.")
            vehicle_catalog_id = None
        else:
            payload["vehicle_catalog_id"] = vehicle_catalog_id
    else:
        payload["vehicle_catalog_id"] = None

    if build_year_raw:
        if not build_year_raw.isdigit() or len(build_year_raw) != 4:
            errors.append("Build year must be a four digit year (e.g. 2022).")
        else:
            payload["build_year"] = int(build_year_raw)
    else:
        payload["build_year"] = None

    if vin and len(vin) > 64:
        errors.append("VIN must be 64 characters or fewer.")
    payload["vin"] = vin if vin else None

    if license_plate and len(license_plate) > 64:
        errors.append("License plate must be 64 characters or fewer.")
    payload["license_plate"] = license_plate if license_plate else None

    payload["name"] = name

    return payload, errors


COOKIE_NAME = "auth_token"
TENANT_COOKIE_NAME = "tenant_auth"
PASSWORD_KEY = "dashboard_password"
TOKEN_KEY = "dashboard_token"
TOKEN_LOGIN_ENABLED_KEY = "dashboard_token_login_enabled"
DEFAULT_DASHBOARD_TOKEN = "ydhQLfuAPBkKfOG2ecPKfcX75jv2YYviRd97fIEVuGvEI44GKtYgxm0P1OxHal1t"
DASHBOARD_DEFAULT_TENANT = "Admin"
DASHBOARD_LOGO_KEY = "dashboard_logo_path"
DASHBOARD_LOGO_LIGHT_KEY = "dashboard_logo_light_path"
DASHBOARD_LOGO_DARK_KEY = "dashboard_logo_dark_path"
DASHBOARD_COLOR_SCHEME_KEY = "dashboard_color_scheme"
DEFAULT_DASHBOARD_COLOR_SCHEME = "classic"

COLOR_SCHEME_VARIANTS: dict[str, dict[str, Any]] = {
    "classic": {
        "label": "Classic",
        "description": "Standard colors of the classic Pipelet interface.",
        "light_palette": [
            {"label": "Primary", "value": "#EA580C"},
            {"label": "Accent", "value": "#FACC15"},
            {"label": "Ink", "value": "#0F172A"},
            {"label": "Card", "value": "#FFFFFF"},
            {"label": "Border", "value": "#E2E8F0"},
        ],
        "dark_palette": [
            {"label": "Primary", "value": "#FB923C"},
            {"label": "Accent", "value": "#FBBF24"},
            {"label": "Slate", "value": "#1F2937"},
            {"label": "Surface", "value": "#111827"},
            {"label": "Border", "value": "#374151"},
        ],
    },
    "enterprise": {
        "label": "Enterprise",
        "description": "New color theme featuring Glacial Blue and Deep Teal accents.",
        "light_palette": [
            {"label": "Glacial Blue", "value": "#5FB3CE"},
            {"label": "Deep Teal", "value": "#1E677A"},
            {"label": "Pine Green", "value": "#2F7F70"},
            {"label": "Alpine Mint", "value": "#A5D6C2"},
            {"label": "Shadow Blue", "value": "#3E5878"},
            {"label": "Text Primary", "value": "#1A1A1A"},
            {"label": "Text Secondary", "value": "#6A6A6A"},
            {"label": "Soft Sand", "value": "#EFE8D9"},
        ],
        "dark_palette": [
            {"label": "Background", "value": "#0F1418"},
            {"label": "Surface", "value": "#182024"},
            {"label": "Surface Subtle", "value": "#1F2A31"},
            {"label": "Border", "value": "#273238"},
            {"label": "Primary", "value": "#7CC8DD"},
            {"label": "Teal", "value": "#1B5564"},
            {"label": "Pine Green", "value": "#3A9A88"},
            {"label": "Mint", "value": "#B5E5D2"},
            {"label": "Shadow Blue", "value": "#8FA6C0"},
        ],
    },
    "walle": {
        "label": "WALL-E",
        "description": "Futuristic theme inspired by WALL-E presentation colors.",
        "light_palette": [
            {"label": "Primary Blue", "value": "#1C8FE8"},
            {"label": "Ion Accent", "value": "#6AD0FF"},
            {"label": "Text Strong", "value": "#0B0D10"},
            {"label": "Contrast Base", "value": "#FFFFFF"},
            {"label": "Surface Mist", "value": "#F8FAFC"},
            {"label": "Border Graphite", "value": "#1F2937"},
        ],
        "dark_palette": [
            {"label": "Background", "value": "#0B0F1A"},
            {"label": "Surface", "value": "#111827"},
            {"label": "Panel", "value": "#0F172A"},
            {"label": "Accent Blue", "value": "#1C8FE8"},
            {"label": "Cyan Glow", "value": "#59C7FF"},
            {"label": "Text Glow", "value": "#E5E7EB"},
        ],
    },
    "coffee": {
        "label": "Coffee",
        "description": "Warm espresso-inspired palette with paired light and dark variants.",
        "light_palette": [
            {"label": "Espresso", "value": "#181B1B"},
            {"label": "Roasted Bean", "value": "#212124"},
            {"label": "Steam", "value": "#87888D"},
            {"label": "Foam", "value": "#EBEEF2"},
            {"label": "Matcha", "value": "#4E992F"},
            {"label": "Red Eye", "value": "#CC0000"},
            {"label": "Caramel", "value": "#FF7A00"},
            {"label": "Mocha", "value": "#623B13"},
            {"label": "Dark Roast", "value": "#462C16"},
        ],
        "dark_palette": [
            {"label": "Cream", "value": "#FCFCFC"},
            {"label": "Froth", "value": "#F1F1F1"},
            {"label": "Smoke", "value": "#777777"},
            {"label": "Herbal", "value": "#4BC400"},
            {"label": "Espresso Shot", "value": "#FF0000"},
            {"label": "Spice", "value": "#FF7A00"},
            {"label": "Latte", "value": "#FFE0C3"},
            {"label": "Strawberry Foam", "value": "#FFE2E6"},
        ],
    },
}


def get_password():
    pw = get_config_value(PASSWORD_KEY)
    if pw is None:
        pw = "admin"
        set_config_value(PASSWORD_KEY, pw)
    return pw


def get_token():
    token = get_config_value(TOKEN_KEY)
    if not token:
        token = DEFAULT_DASHBOARD_TOKEN
        set_config_value(TOKEN_KEY, token)
    return token


def is_token_login_enabled() -> bool:
    """Return True when token based dashboard login is enabled."""
    return get_config_value(TOKEN_LOGIN_ENABLED_KEY) == "1"


def get_dashboard_user() -> str | None:
    """Return the identifier of the currently authenticated dashboard user."""

    user = getattr(g, "dashboard_user", None)
    if user is not None:
        return user

    token_cookie = request.cookies.get(COOKIE_NAME)
    if token_cookie and hmac.compare_digest(token_cookie, get_token()):
        user = "admin"
        g.dashboard_hidden_menus_override = set()
    else:
        tenant_user = _load_tenant_session_from_cookie()
        if tenant_user:
            user = tenant_user["login"]
            g.dashboard_tenant_user = tenant_user
            g.dashboard_hidden_menus_override = tenant_user.get("hidden_menus") or set()
        else:
            user = None

    g.dashboard_user = user
    return user


def _is_api_authorized() -> bool:
    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        return False
    provided = auth_header.split(" ", 1)[1]
    return hmac.compare_digest(provided, get_token())


def _unauthorized_response() -> tuple[Response, int]:
    return jsonify({"error": "unauthorized"}), 401


def _build_tenant_signature(payload: str, password: str) -> str:
    return hmac.new(get_token().encode(), f"{payload}:{password}".encode(), "sha256").hexdigest()


def _load_tenant_session_from_cookie() -> dict | None:
    cached = getattr(g, "tenant_user_cache", None)
    if cached is not None:
        return cached

    raw_cookie = request.cookies.get(TENANT_COOKIE_NAME)
    if not raw_cookie:
        g.tenant_user_cache = None
        return None

    try:
        user_id_raw, login, signature = raw_cookie.split(":", 2)
        user_id = int(user_id_raw)
    except ValueError:
        g.tenant_user_cache = None
        return None

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT user_id, tenant_id, name, email, login, password, hidden_menus FROM op_tenant_users WHERE user_id=%s",
                (user_id,),
            )
            user = cur.fetchone()
    finally:
        conn.close()

    if not user or user["login"] != login:
        g.tenant_user_cache = None
        return None

    expected_signature = _build_tenant_signature(f"{user_id}:{login}", user["password"])
    if not hmac.compare_digest(signature, expected_signature):
        g.tenant_user_cache = None
        return None

    try:
        raw_hidden = json.loads(user.get("hidden_menus") or "[]")
    except (TypeError, json.JSONDecodeError):
        raw_hidden = []
    user["hidden_menus"] = _load_hidden_menus(raw_hidden)

    g.tenant_user_cache = user
    return user


def is_admin_user() -> bool:
    """Return True if the current dashboard user has administrator privileges."""

    return get_dashboard_user() == "admin"


def _get_safe_redirect_target(default_url: str) -> str:
    target = request.values.get("next") or request.referrer or default_url
    try:
        parsed = urlparse(target)
    except ValueError:
        return default_url
    if parsed.netloc and parsed.netloc != request.host:
        return default_url
    return target


@app.before_request
def require_login():
    if request.path.startswith('/api/'):
        return
    if request.path.startswith('/static'):
        return
    if request.endpoint in (
        'login',
        'direct_login',
        'robots_txt',
        'public_check',
        'set_language',
    ):
        return
    if is_token_login_enabled():
        provided = request.args.get('token', '')
        if provided:
            expected = get_token()
            if hmac.compare_digest(provided, expected):
                remaining_args = []
                for key in request.args:
                    if key == 'token':
                        continue
                    for value in request.args.getlist(key):
                        remaining_args.append((key, value))
                redirect_url = request.path
                if remaining_args:
                    redirect_url = f"{request.path}?{urlencode(remaining_args, doseq=True)}"
                resp = redirect(redirect_url)
                resp.set_cookie(
                    COOKIE_NAME,
                    expected,
                    max_age=10 * 365 * 24 * 3600,
                    httponly=True,
                )
                resp.delete_cookie(TENANT_COOKIE_NAME)
                return resp
    token = get_token()
    if request.cookies.get(COOKIE_NAME) == token:
        g.dashboard_user = "admin"
        g.dashboard_hidden_menus_override = set()
        return

    tenant_user = _load_tenant_session_from_cookie()
    if tenant_user:
        g.dashboard_user = tenant_user["login"]
        g.dashboard_tenant_user = tenant_user
        g.dashboard_hidden_menus_override = tenant_user.get("hidden_menus") or set()
        return

    return redirect(url_for('login'))


@app.get('/robots.txt')
def robots_txt():
    return Response('User-agent: *\nDisallow: /\n', mimetype='text/plain')


@app.route('/set_language', methods=['GET', 'POST'])
def set_language():
    selected = (request.values.get('lang') or '').lower()
    if selected not in SUPPORTED_LANGUAGES:
        selected = DEFAULT_LANGUAGE

    redirect_target = _get_safe_redirect_target(url_for('dashboard'))
    response = redirect(redirect_target)
    response.set_cookie(
        LANGUAGE_COOKIE_NAME,
        selected,
        max_age=365 * 24 * 3600,
        samesite="Lax",
    )
    return response


@app.route('/check', methods=['GET'])
def public_check():
    chargepoint_id = request.args.get('chargepoint_id', '').strip()
    result: dict | None = None
    error: str | None = None

    if chargepoint_id:
        normalized = normalize_station_id(chargepoint_id)
        display_id = normalized or chargepoint_id

        candidates: list[str] = []
        if normalized:
            candidates.append(normalized)
            ocpp_prefix = 'ocpp16/'
            if normalized.lower().startswith(ocpp_prefix):
                stripped = normalized[len(ocpp_prefix) :]
                if stripped:
                    candidates.append(stripped)
            else:
                candidates.append(f"{ocpp_prefix}{normalized}")

        candidate_set = {value for value in candidates if value}
        payload: dict = {}
        try:
            response = requests.get(CONNECTED_ENDPOINT, timeout=5)
            response.raise_for_status()
            payload = response.json()
        except requests.RequestException as exc:
            app.logger.warning('Failed to query connection status: %s', exc)
            error = translate_text("The broker could not be reached.")
        except ValueError:
            error = translate_text("Received invalid response from broker.")
        else:
            if not isinstance(payload, dict):
                error = translate_text("Received invalid response from broker.")
                payload = {}

        if not error and chargepoint_id:
            connected_list = payload.get('connected') or []
            disconnected_list = payload.get('disconnected') or []

            match_entry = None
            for entry in connected_list:
                station_value = entry.get('station_id')
                station = normalize_station_id(str(station_value or ''))
                if station in candidate_set:
                    match_entry = ('connected', entry)
                    break
            if not match_entry:
                for entry in disconnected_list:
                    station_value = entry.get('station_id')
                    station = normalize_station_id(str(station_value or ''))
                    if station in candidate_set:
                        match_entry = ('disconnected', entry)
                        break

            if match_entry and match_entry[0] == 'connected':
                entry = match_entry[1]
                since_display = '-'
                since_dt = _parse_iso_datetime(entry.get('connected_since'))
                if since_dt:
                    if since_dt.tzinfo:
                        since_display = since_dt.astimezone(datetime.timezone.utc).strftime(
                            '%Y-%m-%d %H:%M:%S UTC'
                        )
                    else:
                        since_display = since_dt.strftime('%Y-%m-%d %H:%M:%S')
                duration_display = _format_duration_seconds(entry.get('duration_seconds'))
                result = {
                    'connected': True,
                    'message': translate_text(
                        'The wallbox "{display_id}" is currently connected.',
                        display_id=display_id,
                    ),
                    'details': translate_text(
                        'Connected since {since_display} (duration {duration_display}).',
                        since_display=since_display,
                        duration_display=duration_display,
                    ),
                }
            elif match_entry:
                entry = match_entry[1]
                disconnected_since = entry.get('disconnected_since')
                disconnect_display = None
                if disconnected_since:
                    since_dt = _parse_iso_datetime(disconnected_since)
                    if since_dt:
                        if since_dt.tzinfo:
                            disconnect_display = since_dt.astimezone(
                                datetime.timezone.utc
                            ).strftime('%Y-%m-%d %H:%M:%S UTC')
                        else:
                            disconnect_display = since_dt.strftime('%Y-%m-%d %H:%M:%S')
                result = {
                    'connected': False,
                    'message': translate_text(
                        'The wallbox "{display_id}" is currently not connected.',
                        display_id=display_id,
                    ),
                    'details': (
                        translate_text('Last disconnect at {timestamp}.', timestamp=disconnect_display)
                        if disconnect_display
                        else None
                    ),
                }
            else:
                result = {
                    'connected': False,
                    'message': translate_text(
                        'The wallbox "{display_id}" is not registered with the broker.',
                        display_id=display_id,
                    ),
                    'details': None,
                }

    template = """
    <!doctype html>
    <html lang=\"{{ current_language }}\">
    <head>
        <meta charset=\"utf-8\">
        <title>{{ _('Check wallbox connection') }}</title>
        <style>
            body {font-family: Arial, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f5f5f5;}
            .card {background: #fff; padding: 2rem; border-radius: 10px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); width: min(92vw, 420px);}
            h1 {margin-top: 0; font-size: 1.5rem; text-align: center;}
            form {display: flex; flex-direction: column; gap: 1rem;}
            label {font-weight: 600;}
            input[type=text] {padding: 0.75rem; font-size: 1rem; border: 1px solid #d0d0d0; border-radius: 6px;}
            input[type=text]:focus {outline: none; border-color: #1a73e8; box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.2);}
            button {padding: 0.75rem; font-size: 1rem; border: none; border-radius: 6px; background: #1a73e8; color: #fff; cursor: pointer; transition: background 0.2s ease-in-out;}
            button:hover {background: #1557b0;}
            .status {margin-top: 1rem; padding: 1rem; border-radius: 6px;}
            .status.ok {background: #e6f4ea; color: #1e7b34;}
            .status.err {background: #fde8e7; color: #a62119;}
            .status small {display: block; margin-top: 0.5rem; color: inherit; opacity: 0.85;}
        </style>
    </head>
    <body>
        <div class=\"card\">
            <h1>{{ _('Check wallbox connection') }}</h1>
            <form method=\"get\" action=\"{{ url_for('public_check') }}\">
                <label for=\"chargepoint_id\">{{ _('Chargepoint ID') }}</label>
                <input type=\"text\" id=\"chargepoint_id\" name=\"chargepoint_id\" value=\"{{ chargepoint_id }}\" placeholder=\"{{ _('e.g. TACW123456') }}\" required>
                <button type=\"submit\">{{ _('Check status') }}</button>
            </form>
            {% if error %}
                <div class=\"status err\">{{ error }}</div>
            {% elif result %}
                <div class=\"status {{ 'ok' if result.connected else 'err' }}\">
                    <strong>{{ result.message }}</strong>
                    {% if result.details %}
                        <small>{{ result.details }}</small>
                    {% endif %}
                </div>
            {% endif %}
        </div>
    </body>
    </html>
    """

    return render_template_string(
        template,
        chargepoint_id=chargepoint_id,
        result=result,
        error=error,
    )


@app.route('/', methods=['GET', 'POST'])
def login():
    error = None
    if request.method == 'POST':
        username = request.form.get('username', '')
        password = request.form.get('password', '')
        if username == 'admin' and password == get_password():
            token = get_token()
            resp = redirect(url_for('dashboard'))
            resp.set_cookie(
                COOKIE_NAME,
                token,
                max_age=10 * 365 * 24 * 3600,
                httponly=True,
            )
            resp.delete_cookie(TENANT_COOKIE_NAME)
            return resp
        conn = get_db_conn()
        try:
            with conn.cursor() as cur:
                cur.execute(
                    'SELECT user_id, login, password FROM op_tenant_users WHERE login=%s',
                    (username,),
                )
                tenant_user = cur.fetchone()
        finally:
            conn.close()

        if tenant_user and tenant_user['password'] == password:
            payload = f"{tenant_user['user_id']}:{tenant_user['login']}"
            signature = _build_tenant_signature(payload, tenant_user['password'])
            resp = redirect(url_for('dashboard'))
            resp.set_cookie(
                TENANT_COOKIE_NAME,
                f"{payload}:{signature}",
                max_age=30 * 24 * 3600,
                httponly=True,
                samesite="Lax",
            )
            resp.delete_cookie(COOKIE_NAME)
            return resp
        error = translate_text("Username or password incorrect")
    return render_template('op_login.html', error=error)


@app.route('/op_directlogin')
def direct_login():
    """Authenticate user via token provided in query string."""
    provided = request.args.get('token', '')
    if not is_token_login_enabled():
        return redirect(url_for('login'))
    token = get_token()
    if provided and hmac.compare_digest(provided, token):
        resp = redirect(url_for('dashboard'))
        resp.set_cookie(
            COOKIE_NAME,
            token,
            max_age=10 * 365 * 24 * 3600,
            httponly=True,
        )
        return resp
    return redirect(url_for('login'))


@app.route('/logout')
def logout():
    """Remove authentication cookie and redirect to login page."""
    resp = redirect(url_for('login'))
    resp.delete_cookie(COOKIE_NAME)
    resp.delete_cookie(TENANT_COOKIE_NAME)
    return resp


@app.route('/op_change_password', methods=['GET', 'POST'])
def change_password():
    error = None
    if request.method == 'POST':
        current = request.form.get('current_password', '')
        new_pw = request.form.get('new_password', '')
        if current != get_password():
            error = translate_text('Incorrect password')
        else:
            set_config_value(PASSWORD_KEY, new_pw)
            new_token = uuid.uuid4().hex
            set_config_value(TOKEN_KEY, new_token)
            resp = redirect(url_for('dashboard'))
            resp.set_cookie(
                COOKIE_NAME,
                new_token,
                max_age=10 * 365 * 24 * 3600,
                httponly=True,
            )
            return resp
    return render_template('op_change_password.html', error=error, aside='settingsMenu')


@app.route('/op_connection_info')
def connection_info():
    cfg = load_runtime_config()
    proxy_settings = _build_proxy_settings(cfg)
    proxy_ws = proxy_settings["ws_base_url"]
    ocpp_url = f"{proxy_ws.rstrip('/')}/ocpp16/{{StationID}}"
    mqtt_broker = cfg.get('mqtt_broker', '')
    mqtt_port = cfg.get('mqtt_port', '')
    mqtt_user = cfg.get('mqtt_user', '')
    mqtt_password = cfg.get('mqtt_password', '')
    mqtt_topic_prefix = cfg.get('mqtt_topic_prefix', '')

    ocpp_proxy_ip = proxy_settings['ip']
    ocpp_proxy_port = proxy_settings['port']
    ocpp_proxy_url = proxy_settings['base_url']

    return render_template(
        'op_connection_info.html',
        ocpp_url=ocpp_url,
        ocpp_proxy_url=ocpp_proxy_url,
        ocpp_proxy_endpoint=f"{proxy_ws.rstrip('/')}/ocpp16",
        mqtt_broker=mqtt_broker,
        mqtt_port=mqtt_port,
        ocpp_proxy_ip=ocpp_proxy_ip,
        ocpp_proxy_port=ocpp_proxy_port,
        mqtt_user=mqtt_user,
        mqtt_password=mqtt_password,
        mqtt_topic_prefix=mqtt_topic_prefix,
        aside='settingsMenu',
    )


@app.route('/op_api_docs')
def api_docs():
    """Render the interactive Swagger UI for dashboard APIs."""

    return render_template('op_api_docs.html', aside='settingsMenu', api_groups=PIPELET_DASHBOARD_API_GROUPS)
def _load_redirects_with_lookup():
    """Load redirect entries along with a lookup table by station id."""

    redirects: list[dict] = []
    target_rows: list[dict] = []
    conn = get_db_conn()
    try:
        ensure_op_redirects_columns(conn)

        with conn.cursor() as cursor:
            cursor.execute('SELECT * FROM op_redirects ORDER BY source_url ASC')
            redirects = cursor.fetchall()

        for redirect in redirects:
            redirect.setdefault('location_name', '')
            redirect.setdefault('location_link', '')
            redirect.setdefault('webui_remote_access_url', '')
            redirect.setdefault('load_management_remote_access_url', '')
            redirect.setdefault('charging_analytics', 0)
            redirect['comment'] = (redirect.get('comment') or '').strip()

        with conn.cursor() as cursor:
            cursor.execute('SELECT ws_url, short_name FROM op_targets')
            target_rows = cursor.fetchall()
    finally:
        conn.close()

    target_dict = {'/'.join(t['ws_url'].split('/')[:4]): t['short_name'] for t in target_rows}

    for redirect in redirects:
        base = '/'.join(redirect['ws_url'].split('/')[:4])
        redirect['ws_url_short'] = target_dict.get(base, base)

    redirect_lookup = {}
    for redirect in redirects:
        station_short = normalize_station_id(redirect['source_url']).rsplit('/', 1)[-1]
        redirect_lookup[station_short] = redirect

    return redirects, redirect_lookup


def _build_station_card_anchor(station_id: str) -> str:
    """Create a stable HTML anchor id for a station card."""

    cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", (station_id or "").strip())
    cleaned = re.sub(r"-+", "-", cleaned).strip("-")
    if not cleaned:
        cleaned = "station"
    return f"station-card-{cleaned}"


def _normalize_ocpp_version_display(value: object) -> str:
    """Normalize OCPP version strings for UI display."""

    if not isinstance(value, str):
        return ""

    cleaned = value.strip()
    if not cleaned:
        return ""
    if cleaned in {"-", "–", "—"}:
        return ""

    lowered = cleaned.lower()
    if lowered in {"auto", "any"}:
        return "Auto"
    if "2.0.1" in lowered or lowered.startswith("ocpp20") or lowered.startswith("ocpp2.0"):
        return "2.0.1"
    if "1.6" in lowered or lowered.startswith("ocpp16"):
        return "1.6"
    return cleaned


def _station_lookup_keys(station_id: str) -> set[str]:
    """Return common lookup keys for a station id, including normalized variants."""

    if not station_id:
        return set()

    candidates: set[str] = {station_id}
    trimmed = station_id.lstrip("/")
    if trimmed:
        candidates.add(trimmed)
        candidates.add(f"/{trimmed}")

    normalized = normalize_station_id(station_id)
    if normalized:
        candidates.add(normalized)
        normalized_trimmed = normalized.lstrip("/")
        if normalized_trimmed:
            candidates.add(normalized_trimmed)
            candidates.add(f"/{normalized_trimmed}")
        short = normalized.rsplit("/", 1)[-1]
        if short:
            candidates.add(short)
            candidates.add(f"/{short}")

    return {key for key in candidates if key}


_DURATION_STRING_PATTERN = re.compile(
    r"^(?:(?P<days>\d+)\s+day[s]?,\s*)?(?P<hours>\d+):(?P<minutes>\d{1,2}):(?P<seconds>\d{1,2})$"
)


def _parse_duration_seconds(value: object) -> Optional[int]:
    """Coerce various duration representations into total seconds if possible."""

    if value is None:
        return None
    if isinstance(value, (int, float)):
        return int(value)
    if isinstance(value, str):
        text = value.strip()
        if not text:
            return None
        try:
            return int(text, 10)
        except ValueError:
            pass

        match = _DURATION_STRING_PATTERN.match(text)
        if match:
            days = int(match.group("days") or 0)
            hours = int(match.group("hours"))
            minutes = int(match.group("minutes"))
            seconds = int(match.group("seconds"))
            return days * 86400 + hours * 3600 + minutes * 60 + seconds

    return None


def _resolve_connection_duration(entry: Mapping[str, Any]) -> tuple[str, Optional[int]]:
    """Return a human readable duration and its total seconds if available."""

    secs_int = _parse_duration_seconds(entry.get("duration_seconds"))
    duration_raw = entry.get("duration")

    if secs_int is None:
        secs_int = _parse_duration_seconds(duration_raw)

    if secs_int is not None:
        return str(datetime.timedelta(seconds=secs_int)), secs_int

    if isinstance(duration_raw, str):
        duration_clean = duration_raw.strip()
        if duration_clean:
            return duration_clean, None

    return "00:00:00", None


def _store_ocpp_version(lookup: dict[str, str], station_id: str, version: str) -> None:
    """Store an OCPP version for multiple station id variants."""

    if not station_id or not version:
        return

    for key in _station_lookup_keys(station_id):
        lookup.setdefault(key, version)


def _summarize_connector_status(connectors: Iterable[Mapping[str, Any]]) -> str:
    """Collapse connector level status information into a station level label."""

    final_status = "unknown"
    has_error = False
    seen_statuses: set[str] = set()

    status_category_map = {
        "charging": "charging",
        "suspendedev": "charging",
        "suspendedevse": "charging",
        "preparing": "preparing",
        "reserved": "preparing",
        "finishing": "finishing",
        "available": "available",
        "occupied": "charging",
        "inactive": "unavailable",
        "disabled": "unavailable",
    }

    for connector in connectors:
        if not isinstance(connector, Mapping):
            continue

        status_raw = connector.get("status")
        status = str(status_raw).strip() if status_raw is not None else ""
        if status:
            seen_statuses.add(status)
        lower_status = status.lower()
        if lower_status in {"faulted", "unavailable", "error", "outofservice"}:
            has_error = True

        error_code_raw = connector.get("errorCode")
        if error_code_raw is not None:
            error_code = str(error_code_raw).strip().lower()
            if error_code and error_code not in {"noerror", "ok", "0"}:
                has_error = True

    if has_error:
        return "error"

    lowered = {status.lower() for status in seen_statuses if status}
    if lowered:
        mapped_categories = {status_category_map.get(status) for status in lowered}
        mapped_categories.discard(None)

        if "charging" in mapped_categories:
            final_status = "charging"
        elif "preparing" in mapped_categories:
            final_status = "preparing"
        elif "finishing" in mapped_categories:
            final_status = "finishing"
        elif "available" in mapped_categories:
            final_status = "available"
        elif "unavailable" in mapped_categories:
            final_status = "unavailable"
        else:
            # No recognized non-error status found; keep the default "unknown".
            pass

    return final_status


def _build_station_status_lookup(
    wallboxes: Iterable[Mapping[str, Any]]
) -> dict[str, dict[str, str]]:
    """Create a lookup table for station level charging statuses."""

    status_lookup: dict[str, dict[str, str]] = {}
    label_map = {
        "available": "Available",
        "preparing": "Preparing",
        "charging": "Charging",
        "finishing": "Finishing",
        "unavailable": "Unavailable",
        "error": "Error",
        "unknown": "Unknown",
    }

    for entry in wallboxes:
        if not isinstance(entry, Mapping):
            continue
        station_id_raw = entry.get("stationId")
        station_id = str(station_id_raw).strip() if station_id_raw is not None else ""
        if not station_id:
            continue

        connectors = entry.get("connectors")
        if isinstance(connectors, list):
            connectors_list: Iterable[Mapping[str, Any]] = connectors
        else:
            connectors_list = []

        status_key = _summarize_connector_status(connectors_list)
        label = label_map.get(status_key, status_key.title())
        payload = {"key": status_key, "label": label}

        normalized = normalize_station_id(station_id)
        normalized_short = normalized.rsplit("/", 1)[-1] if normalized else ""
        candidates = {station_id}
        if normalized:
            candidates.add(normalized)
            candidates.add(f"/{normalized}")
        if normalized_short:
            candidates.add(normalized_short)

        for candidate in candidates:
            status_lookup[candidate] = payload

    return status_lookup


DASHBOARD_KPI_LOOKBACK_HOURS = 48
DASHBOARD_KPI_ROW_LIMIT = 2000
CERTIFICATE_WARNING_DAYS = 30


def _safe_percentage(numerator: Any, denominator: Any) -> float | None:
    try:
        num = float(numerator)
        den = float(denominator)
    except (TypeError, ValueError):
        return None
    if den <= 0:
        return None
    return round((num / den) * 100.0, 1)


def _percentile_value(values: Iterable[float], percentile: float = 95.0) -> float | None:
    data = sorted(value for value in values if isinstance(value, (int, float)))
    if not data:
        return None
    if len(data) == 1:
        return round(data[0], 1)
    k = (len(data) - 1) * (percentile / 100.0)
    lower_index = math.floor(k)
    upper_index = math.ceil(k)
    if lower_index == upper_index:
        return round(data[int(k)], 1)
    lower = data[lower_index]
    upper = data[upper_index]
    interpolated = lower + (upper - lower) * (k - lower_index)
    return round(interpolated, 1)


def _as_bool(value: Any) -> bool:
    if isinstance(value, bool):
        return value
    try:
        return bool(int(value))
    except (TypeError, ValueError):
        return False


def _normalize_latency(value: Any) -> float | None:
    try:
        numeric = float(value)
    except (TypeError, ValueError):
        return None
    if numeric < 0:
        return None
    return numeric


def _is_timeout_entry(status_code: Any, detail: Any) -> bool:
    try:
        code_int = int(status_code) if status_code is not None else None
    except (TypeError, ValueError):
        code_int = None

    if code_int in {408, 504, 522, 524, 598, 599}:
        return True

    detail_text = str(detail or "").lower()
    timeout_markers = ["timeout", "timed out", "read timed out", "connection timed out", "deadline exceeded"]
    return any(marker in detail_text for marker in timeout_markers)


def _serialize_datetime(value: Any) -> str | None:
    if isinstance(value, datetime.datetime):
        return value.replace(microsecond=0).isoformat(sep=" ")
    return str(value) if value else None


def _load_sync_run_rows(hours: int = DASHBOARD_KPI_LOOKBACK_HOURS, limit: int = DASHBOARD_KPI_ROW_LIMIT) -> tuple[list[dict[str, Any]], list[str]]:
    """Load OCPI sync run rows for KPI aggregation."""

    errors: list[str] = []
    try:
        conn = get_db_conn()
    except Exception as exc:
        return [], [translate_text("Database unavailable for KPI lookup: {error}", error=str(exc))]

    rows: list[dict[str, Any]] = []
    try:
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_ocpi_sync_runs'")
            if not cur.fetchone():
                return [], [translate_text("No OCPI sync data available yet.")]

            start = datetime.datetime.utcnow() - datetime.timedelta(hours=hours)
            cur.execute(
                """
                SELECT module, backend_id, backend_name, duration_ms, success, status_code, detail, created_at
                  FROM op_ocpi_sync_runs
                 WHERE created_at >= %s
                 ORDER BY created_at DESC, id DESC
                 LIMIT %s
                """,
                (start, max(1, int(limit))),
            )
            rows = cur.fetchall()
    except Exception as exc:
        errors.append(translate_text("Could not load OCPI sync data: {error}", error=str(exc)))
    finally:
        try:
            conn.close()
        except Exception:
            pass

    return rows or [], errors


def _summarize_kpi_rows(rows: Iterable[Mapping[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
    module_labels = {
        "locations": translate_text("Locations"),
        "tariffs": translate_text("Tariffs"),
        "tokens": translate_text("Tokens"),
        "sessions": translate_text("Sessions"),
        "cdrs": translate_text("CDRs"),
    }
    modules: dict[str, dict[str, Any]] = {}
    backends: dict[str, dict[str, Any]] = {}
    all_durations: list[float] = []
    total_success = 0
    total_rows = 0
    total_timeouts = 0
    latest_ts: datetime.datetime | None = None

    def _bucket(target: dict[str, dict[str, Any]], key: str, label: str | None = None) -> dict[str, Any]:
        if key not in target:
            target[key] = {"label": label or key.title(), "total": 0, "success": 0, "timeouts": 0, "durations": []}
        elif label and not target[key].get("label"):
            target[key]["label"] = label
        return target[key]

    for row in rows or []:
        module_key = str(row.get("module") or "unknown").lower()
        backend_id = row.get("backend_id")
        backend_name = (row.get("backend_name") or "").strip()
        backend_key = backend_name or (f"backend-{backend_id}" if backend_id is not None else "unknown")
        module_bucket = _bucket(modules, module_key, module_labels.get(module_key))

        backend_label = backend_name or (f"{translate_text('Backend')} #{backend_id}" if backend_id is not None else translate_text("Unknown backend"))
        backend_bucket = _bucket(backends, backend_key, backend_label)

        module_bucket["total"] += 1
        backend_bucket["total"] += 1
        total_rows += 1

        if _as_bool(row.get("success")):
            module_bucket["success"] += 1
            backend_bucket["success"] += 1
            total_success += 1

        if _is_timeout_entry(row.get("status_code"), row.get("detail")):
            module_bucket["timeouts"] += 1
            backend_bucket["timeouts"] += 1
            total_timeouts += 1

        duration_ms = _normalize_latency(row.get("duration_ms"))
        if duration_ms is not None:
            module_bucket["durations"].append(duration_ms)
            backend_bucket["durations"].append(duration_ms)
            all_durations.append(duration_ms)

        created_at = row.get("created_at")
        if isinstance(created_at, datetime.datetime):
            if latest_ts is None or created_at > latest_ts:
                latest_ts = created_at

    def _finalize(entries: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
        finalized: list[dict[str, Any]] = []
        for key, data in entries.items():
            durations = data.get("durations") or []
            finalized.append(
                {
                    "key": key,
                    "label": data.get("label") or key.title(),
                    "total": data.get("total", 0),
                    "success_rate": _safe_percentage(data.get("success"), data.get("total")),
                    "timeout_rate": _safe_percentage(data.get("timeouts"), data.get("total")),
                    "avg_latency_ms": round(mean(durations), 1) if durations else None,
                    "p95_latency_ms": _percentile_value(durations),
                }
            )
        finalized.sort(key=lambda entry: (entry.get("success_rate") or 0, entry.get("total", 0)), reverse=True)
        return finalized

    summary = {
        "total": total_rows,
        "success_rate": _safe_percentage(total_success, total_rows),
        "timeout_rate": _safe_percentage(total_timeouts, total_rows),
        "avg_latency_ms": round(mean(all_durations), 1) if all_durations else None,
        "p95_latency_ms": _percentile_value(all_durations),
        "last_run_at": _serialize_datetime(latest_ts),
    }

    return _finalize(modules), _finalize(backends), summary


def _probe_endpoint_health(url: str) -> tuple[str, str | None, float | None]:
    start = time.monotonic()
    try:
        response = requests.head(url, timeout=2, allow_redirects=True)
        duration_ms = round((time.monotonic() - start) * 1000, 1)
        if response.status_code < 400 or response.status_code == 405:
            return "ok", None, duration_ms
        return "warning", f"{response.status_code} {response.reason or ''}".strip(), duration_ms
    except Exception as exc:  # pragma: no cover - network issues are surfaced to the UI
        return "error", str(exc), None


def _evaluate_webhook_liveness() -> dict[str, Any]:
    config = _load_config_file()
    ocpi_cfg = config.get("ocpi_api") if isinstance(config, Mapping) else {}
    alerts_cfg = ocpi_cfg.get("alerts") if isinstance(ocpi_cfg, Mapping) else {}
    webhook_url = str((alerts_cfg or {}).get("webhook_url") or "").strip()
    slack_webhook_url = str((alerts_cfg or {}).get("slack_webhook_url") or "").strip()

    endpoints = [url for url in (webhook_url, slack_webhook_url) if url]
    if not endpoints:
        return {
            "status": "missing",
            "endpoint": None,
            "detail": translate_text("No webhook configured."),
            "latency_ms": None,
            "checked_at": _serialize_datetime(datetime.datetime.utcnow()),
        }

    endpoint = endpoints[0]
    status, detail, latency_ms = _probe_endpoint_health(endpoint)
    return {
        "status": status,
        "endpoint": endpoint,
        "detail": detail,
        "latency_ms": latency_ms,
        "checked_at": _serialize_datetime(datetime.datetime.utcnow()),
    }


def _collect_certificate_warnings() -> list[dict[str, Any]]:
    certs = _collect_hubject_certificate_state()
    now = datetime.datetime.utcnow()
    entries: list[dict[str, Any]] = []
    label_map = {
        "client_cert": translate_text("Client certificate"),
        "client_key": translate_text("Client key"),
        "ca_bundle": translate_text("CA bundle"),
    }

    for key, metadata in certs.items():
        expires_raw = metadata.get("expires")
        expires_at = expires_raw if isinstance(expires_raw, datetime.datetime) else None
        expires_text = expires_at.isoformat() if isinstance(expires_at, datetime.datetime) else (str(expires_raw) if expires_raw else None)
        days_remaining: float | None = None
        if isinstance(expires_at, datetime.datetime):
            delta = expires_at - now
            days_remaining = round(delta.total_seconds() / 86400.0, 1)

        if not metadata.get("exists") or not metadata.get("readable"):
            status = "missing"
        elif isinstance(days_remaining, float) and days_remaining < 0:
            status = "expired"
        elif isinstance(days_remaining, float) and days_remaining <= CERTIFICATE_WARNING_DAYS:
            status = "expiring"
        elif expires_at is None and expires_raw:
            status = "unknown"
        else:
            status = "ok"

        entries.append(
            {
                "key": key,
                "label": label_map.get(key, key.replace("_", " ").title()),
                "status": status,
                "expires_at": expires_text,
                "days_remaining": days_remaining,
                "path": metadata.get("path"),
            }
        )

    severity = {"expired": 0, "missing": 1, "expiring": 2, "unknown": 3, "ok": 4}
    entries.sort(key=lambda entry: (severity.get(entry.get("status"), 5), entry.get("days_remaining") or 9999))
    return entries


def _load_alert_config() -> dict[str, Any]:
    cfg = _load_config_file()
    alert_cfg: dict[str, Any] = {}
    if isinstance(cfg, Mapping) and isinstance(cfg.get("alerts"), Mapping):
        alert_cfg.update(cfg.get("alerts") or {})
    ocpi_cfg = cfg.get("ocpi_api") if isinstance(cfg, Mapping) else {}
    ocpi_alerts = ocpi_cfg.get("alerts") if isinstance(ocpi_cfg, Mapping) else {}
    if isinstance(ocpi_alerts, Mapping):
        alert_cfg.update(ocpi_alerts)
    return alert_cfg


def _collect_alert_entries(
    *,
    modules: Iterable[Mapping[str, Any]],
    backends: Iterable[Mapping[str, Any]],
    webhook: Mapping[str, Any],
    certificates: Iterable[Mapping[str, Any]],
    alert_cfg: Mapping[str, Any],
) -> list[dict[str, Any]]:
    def _as_float(value: Any) -> float | None:
        try:
            num = float(value)
        except (TypeError, ValueError):
            return None
        return num if num >= 0 else None

    def _as_int(value: Any) -> int | None:
        try:
            return int(value)
        except (TypeError, ValueError):
            return None

    def _threshold_map(raw: Any) -> dict[str, float]:
        if not isinstance(raw, Mapping):
            return {}
        result: dict[str, float] = {}
        for key, value in raw.items():
            normalized = str(key or "").strip().lower()
            parsed = _as_float(value)
            if normalized and parsed is not None:
                result[normalized] = parsed
        return result

    def _parse_date(value: Any) -> datetime.datetime | None:
        if isinstance(value, datetime.datetime):
            return value
        if isinstance(value, datetime.date):
            return datetime.datetime.combine(value, datetime.time.min)
        text = str(value or "").strip()
        if not text:
            return None
        try:
            parsed = datetime.datetime.fromisoformat(text.replace("Z", ""))
        except ValueError:
            return None
        if parsed.tzinfo:
            parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
        return parsed

    alerts: list[dict[str, Any]] = []
    default_module_threshold = _as_float(alert_cfg.get("module_error_threshold_pct"))
    default_backend_threshold = _as_float(alert_cfg.get("backend_error_threshold_pct"))
    module_thresholds = _threshold_map(alert_cfg.get("module_thresholds"))
    backend_thresholds = _threshold_map(alert_cfg.get("backend_thresholds"))
    webhook_down_minutes = _as_int(alert_cfg.get("webhook_down_minutes"))
    token_warning_days = _as_int(alert_cfg.get("token_expiry_warning_days")) or 0
    token_expiry_dates = alert_cfg.get("token_expiry_dates") if isinstance(alert_cfg.get("token_expiry_dates"), Mapping) else {}
    default_token_expiry = _parse_date(alert_cfg.get("token_expiry_date"))
    cooldown_override = _as_int(alert_cfg.get("alert_cooldown_seconds"))
    notifier = _alert_notifier or _build_alert_notifier(_config)

    now = datetime.datetime.utcnow()

    def _add_alert(entry: dict[str, Any]) -> None:
        alerts.append(entry)
        key = entry.get("key") or entry.get("label") or entry.get("type")
        if key and notifier:
            try:
                notifier.notify_event(
                    str(key),
                    entry.get("label") or entry.get("summary") or "OCPI alert",
                    {"meta": entry.get("meta"), "type": entry.get("type")},
                    cooldown_seconds=cooldown_override,
                )
            except Exception:
                alert_logger.debug("Failed to send alert notification", exc_info=True)

    for module in modules or []:
        key = str(module.get("key") or "").lower()
        label = module.get("label") or key or translate_text("Module")
        threshold = module_thresholds.get(key, default_module_threshold)
        success_rate = module.get("success_rate") or 0.0
        failure_rate = max(0.0, 100.0 - float(success_rate))
        if threshold is not None and failure_rate >= threshold:
            _add_alert(
                {
                    "type": "module",
                    "key": f"module:{key}",
                    "label": translate_text("{label}: error rate {rate}%", label=label, rate=round(failure_rate, 1)),
                    "status": "warning",
                    "meta": translate_text("Threshold: {threshold}%", threshold=threshold),
                }
            )

    for backend in backends or []:
        key = str(backend.get("key") or "").lower()
        label = backend.get("label") or key or translate_text("Backend")
        threshold = backend_thresholds.get(key, default_backend_threshold)
        success_rate = backend.get("success_rate") or 0.0
        failure_rate = max(0.0, 100.0 - float(success_rate))
        if threshold is not None and failure_rate >= threshold:
            _add_alert(
                {
                    "type": "backend",
                    "key": f"backend:{key}",
                    "label": translate_text("{label}: error rate {rate}%", label=label, rate=round(failure_rate, 1)),
                    "status": "warning",
                    "meta": translate_text("Threshold: {threshold}%", threshold=threshold),
                }
            )

    webhook_status = webhook.get("status")
    if webhook_status != "ok":
        label = translate_text("Webhook unreachable")
        meta_parts = []
        if webhook.get("endpoint"):
            meta_parts.append(webhook.get("endpoint"))
        if webhook.get("detail"):
            meta_parts.append(str(webhook.get("detail")))
        if webhook_down_minutes:
            meta_parts.append(translate_text("Down > {minutes} min", minutes=webhook_down_minutes))
        _add_alert(
            {
                "type": "webhook",
                "key": "webhook",
                "label": label,
                "status": "critical",
                "meta": " · ".join(meta_parts),
            }
        )

    for cert in certificates or []:
        status = cert.get("status") or "unknown"
        if status in {"ok"}:
            continue
        label = f"{cert.get('label') or translate_text('Certificate')}: {status}"
        meta_parts = []
        if cert.get("expires_at"):
            meta_parts.append(cert["expires_at"])
        if cert.get("days_remaining") is not None:
            meta_parts.append(f"≈ {cert['days_remaining']}d")
        _add_alert(
            {
                "type": "certificate",
                "key": f"cert:{cert.get('key') or cert.get('label')}",
                "label": label,
                "status": "warning" if status == "expiring" else "critical",
                "meta": " · ".join(meta_parts),
            }
        )

    def _token_entries(raw: Mapping[str, Any]) -> Iterable[tuple[str, datetime.datetime]]:
        for name, value in (raw or {}).items():
            parsed = _parse_date(value)
            if parsed:
                yield str(name), parsed

    token_entries = dict(_token_entries(token_expiry_dates))
    if default_token_expiry:
        token_entries.setdefault("default", default_token_expiry)
    for name, expiry in token_entries.items():
        days_remaining = (expiry - now).total_seconds() / 86400.0
        if token_warning_days and days_remaining <= token_warning_days:
            status = "critical" if days_remaining < 0 else "warning"
            _add_alert(
                {
                    "type": "token",
                    "key": f"token:{name}",
                    "label": translate_text("Token {name} expires soon", name=name),
                    "status": status,
                    "meta": translate_text("≈ {days} days", days=round(days_remaining, 1)),
                }
            )

    return alerts


def _collect_dashboard_metrics(*, include_jobs: bool = True) -> dict[str, Any]:
    rows, row_errors = _load_sync_run_rows()
    modules, backends, summary = _summarize_kpi_rows(rows)
    webhook = _evaluate_webhook_liveness()
    certificates = _collect_certificate_warnings()
    alerts_cfg = _load_alert_config()
    alerts = _collect_alert_entries(
        modules=modules,
        backends=backends,
        webhook=webhook,
        certificates=certificates,
        alert_cfg=alerts_cfg,
    )
    errors = list(row_errors)
    jobs: list[dict[str, Any]] = []
    if include_jobs:
        _, _, jobs, _, jobs_error = _fetch_ocpi_sync_status()
        if jobs_error:
            errors.append(translate_text("Scheduler status unavailable: {detail}", detail=str(jobs_error)))

    return {
        "summary": summary,
        "modules": modules,
        "backends": backends,
        "webhook": webhook,
        "certificates": certificates,
        "alerts": alerts,
        "jobs": jobs,
        "errors": errors,
        "updated_at": _serialize_datetime(datetime.datetime.utcnow()),
    }


def _build_kpi_translation_map() -> dict[str, str]:
    return build_translation_map(
        ocpi_kpis="OCPI KPIs",
        module_success="Module success",
        partner_success="Partner success",
        health_alerts="Health & alerts",
        success_rate="Success rate",
        timeout_rate="Timeout rate",
        avg_latency="Average latency",
        p95_latency="p95 latency",
        latency="Latency",
        timeouts="Timeouts",
        webhook_ok="Webhook reachable",
        webhook_down="Webhook unreachable",
        webhook_missing="No webhook configured.",
        certificate_expiring="Expiring soon",
        certificate_missing="Certificate missing",
        certificate_expired="Expired",
        certificate_ok="Certificate ok",
        certificate_expiry="Certificate expiry",
        scheduler_jobs="Scheduler jobs",
        updated_label="Updated",
        samples="Samples",
        loading_failed="KPIs could not be loaded.",
        last_sync="Last sync",
        warnings="Warnings",
        running="Running",
        stopped="Stopped",
    )


@app.route('/op_dashboard')
def dashboard():
    try:
        default_broker_bundle = _build_broker_endpoint_bundle(
            **get_default_broker_settings()
        )
    except ValueError:
        default_broker_bundle = _build_broker_endpoint_bundle(
            PROXY_DISPLAY_BASE_URL,
            ocpp_port=_proxy_port,
            api_port=_proxy_api_port,
        )

    proxy_base_url = default_broker_bundle["base_url"]
    proxy_ws = default_broker_bundle["ws_url"]
    connected_endpoint = default_broker_bundle["connected_endpoint"]
    stats_endpoint = default_broker_bundle["stats_endpoint"]
    broker_status_endpoint = default_broker_bundle["broker_status_endpoint"]
    connection_stats_endpoint = default_broker_bundle["connection_stats_endpoint"]
    db_pool_metrics_endpoint = default_broker_bundle["db_pool_metrics_endpoint"]
    reconnect_metrics_endpoint = default_broker_bundle["reconnect_metrics_endpoint"]

    proxy_base_override = request.args.get('proxy_base_url', '').strip()
    override_ocpp_port = request.args.get('proxy_ocpp_port', '').strip()
    override_api_port = request.args.get('proxy_api_port', '').strip()
    if proxy_base_override:
        try:
            override_bundle = _build_broker_endpoint_bundle(
                proxy_base_override,
                ocpp_port=override_ocpp_port or default_broker_bundle["ocpp_port"],
                api_port=override_api_port or default_broker_bundle["api_port"],
            )
        except ValueError:
            app.logger.warning('Ignoring invalid proxy_base_url override: %s', proxy_base_override)
        else:
            proxy_base_url = override_bundle["base_url"]
            proxy_ws = override_bundle["ws_url"]
            connected_endpoint = override_bundle["connected_endpoint"]
            stats_endpoint = override_bundle["stats_endpoint"]
            broker_status_endpoint = override_bundle["broker_status_endpoint"]
            connection_stats_endpoint = override_bundle["connection_stats_endpoint"]
            db_pool_metrics_endpoint = override_bundle["db_pool_metrics_endpoint"]
            reconnect_metrics_endpoint = override_bundle["reconnect_metrics_endpoint"]

    hide_proxy_errors = _is_truthy(request.args.get('hide_proxy_errors'))

    def _parse_ratio_threshold(raw_value: Any, default: Optional[float]) -> Optional[float]:
        try:
            value = float(raw_value)
        except (TypeError, ValueError):
            return default
        if value > 1:
            value = value / 100.0
        return max(0.0, min(value, 1.0))

    success_threshold = _parse_ratio_threshold(
        _runtime_cfg.get("connection_success_threshold")
        or (_config.get("connection_success_threshold") if isinstance(_config, Mapping) else None),
        0.95,
    )
    uptime_threshold = _parse_ratio_threshold(
        _runtime_cfg.get("connection_uptime_threshold")
        or (_config.get("connection_uptime_threshold") if isinstance(_config, Mapping) else None),
        0.9,
    )
    redirects, redirect_lookup = _load_redirects_with_lookup()

    station_card_anchors: dict[str, str] = {}
    station_normalized_lookup: dict[str, str] = {}
    for redirect in redirects:
        source_url = redirect.get('source_url') or ''
        station_short = source_url.split('/')[-1]
        normalized_short = normalize_station_id(source_url).rsplit('/', 1)[-1]
        anchor_id = _build_station_card_anchor(normalized_short or station_short)
        if station_short:
            station_card_anchors[station_short] = anchor_id
            station_normalized_lookup[station_short] = normalized_short or station_short
        if normalized_short:
            station_card_anchors[normalized_short] = anchor_id
            station_normalized_lookup[normalized_short] = normalized_short

    marked_rows = []
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT id, station_id, reason, source, pattern_id,
                       created_at, updated_at, service_requested_at
                FROM op_station_marks
                ORDER BY COALESCE(updated_at, created_at) DESC
                """
            )
            fetched_rows = cur.fetchall()

        seen_station_ids: set[str] = set()
        for row in fetched_rows:
            station_id = row.get("station_id") or ""
            if station_id in seen_station_ids:
                continue
            seen_station_ids.add(station_id)
            marked_rows.append(row)
    finally:
        conn.close()

    marked_wallboxes = []
    marked_station_ids: set[str] = set()
    for entry in marked_rows:
        station_id = entry.get('station_id')
        if station_id:
            normalized_station_id = normalize_station_id(station_id).rsplit('/', 1)[-1]
            marked_station_ids.add(normalized_station_id)
        redirect_info = redirect_lookup.get(station_id)
        marked_wallboxes.append(
            {
                'id': entry.get('id'),
                'station_id': station_id,
                'reason': entry.get('reason') or '',
                'source': entry.get('source') or 'manual',
                'pattern_id': entry.get('pattern_id'),
                'created_at': entry.get('created_at'),
                'updated_at': entry.get('updated_at'),
                'service_requested_at': entry.get('service_requested_at'),
                'marked_at': entry.get('updated_at') or entry.get('created_at'),
                'target': (redirect_info or {}).get('ws_url_short'),
                'source_url': (redirect_info or {}).get('source_url'),
            }
        )

    highlighted_station_ids: set[str] = set()
    highlight_rows: list[dict] = []
    conn = get_db_conn()
    try:
        ensure_station_highlights_table(conn)
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT station_id, created_at, updated_at
                  FROM op_station_highlights
                 ORDER BY COALESCE(updated_at, created_at) DESC
                """
            )
            highlight_rows = cur.fetchall()
    finally:
        conn.close()

    for highlight in highlight_rows:
        station_id = highlight.get('station_id')
        if not station_id:
            continue
        highlighted_station_ids.add(station_id)
        normalized_station_id = normalize_station_id(station_id).rsplit('/', 1)[-1]
        if normalized_station_id:
            highlighted_station_ids.add(normalized_station_id)

    def _station_sort_key(entry: dict) -> tuple[int, str, str]:
        source_url = entry.get('source_url') or ''
        station_short = source_url.rsplit('/', 1)[-1]
        normalized_source = normalize_station_id(source_url)
        normalized_short = normalized_source.rsplit('/', 1)[-1] if normalized_source else ''
        display_id = station_short or normalized_short or source_url
        candidates = {
            source_url,
            normalized_source,
            station_short,
            normalized_short,
        }
        candidates = {c for c in candidates if c}
        is_highlight = any(candidate in highlighted_station_ids for candidate in candidates)
        return (
            0 if is_highlight else 1,
            display_id.casefold(),
            display_id,
        )

    redirects = sorted(redirects, key=_station_sort_key)

    broker_status = {
        "reachable": False,
        "api_reachable": False,
        "db_last_attempt": None,
        "db_last_success": None,
        "error": None,
        "api_error": None,
        "db_pool_metrics": {},
        "db_pool_warnings": [],
        "db_pool_error": None,
        "reconnect_metrics": {},
        "reconnect_warnings": [],
        "reconnect_error": None,
        "ocpp_log_queue_metrics": {},
        "ocpp_log_queue_warnings": [],
        "ocpp_log_queue_error": None,
    }

    def _format_timestamp(value: str) -> str:
        try:
            parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
        except ValueError:
            return value

        return parsed.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")

    try:
        resp = requests.get(broker_status_endpoint, timeout=3)
        resp.raise_for_status()
        broker_status["reachable"] = True
        try:
            payload = resp.json()
        except ValueError as exc:
            raise RuntimeError(f"Ungültige Antwort: {exc}") from exc

        if isinstance(payload, dict):
            mysql_info = payload.get("mysql", {})
            if isinstance(mysql_info, dict):
                attempt_raw = mysql_info.get("last_check_attempt")
                if isinstance(attempt_raw, str) and attempt_raw:
                    broker_status["db_last_attempt"] = _format_timestamp(attempt_raw)
                success_raw = mysql_info.get("last_check_success")
                if isinstance(success_raw, bool):
                    broker_status["db_last_success"] = success_raw
    except Exception as exc:  # pragma: no cover - network issues shown to user
        broker_status["error"] = str(exc)

    api_reachable, api_error = _check_endpoint_reachability(PROXY_BASE_URL)
    broker_status["api_reachable"] = api_reachable
    broker_status["api_error"] = api_error

    connection_stats: dict[str, Any] = {}
    connection_error: Optional[str] = None
    connection_warnings: list[dict[str, object]] = []
    reconnect_metrics: dict[str, Any] = {}
    reconnect_error: Optional[str] = None
    reconnect_warnings: list[dict[str, object]] = []

    def _check_thresholds(label: str, metrics: Mapping[str, Any]) -> None:
        success_rate = metrics.get("success_rate")
        uptime_ratio = metrics.get("uptime_ratio")
        if (
            success_threshold is not None
            and isinstance(success_rate, (float, int))
            and success_rate < success_threshold
        ):
            connection_warnings.append(
                {
                    "station_id": label,
                    "metric": "success_rate",
                    "value": float(success_rate),
                    "threshold": success_threshold,
                }
            )
        if (
            uptime_threshold is not None
            and isinstance(uptime_ratio, (float, int))
            and uptime_ratio < uptime_threshold
        ):
            connection_warnings.append(
                {
                    "station_id": label,
                    "metric": "uptime_ratio",
                    "value": float(uptime_ratio),
                    "threshold": uptime_threshold,
                }
            )

    try:
        resp = requests.get(connection_stats_endpoint, timeout=3)
        resp.raise_for_status()
        parsed = resp.json()
        if isinstance(parsed, Mapping):
            connection_stats = dict(parsed)
        else:
            raise ValueError("Unexpected connectionStats response")
        stations_block = connection_stats.get("stations", {})
        if isinstance(stations_block, Mapping):
            for sid, metrics in stations_block.items():
                if isinstance(metrics, Mapping):
                    _check_thresholds(str(sid), metrics)
        total_block = connection_stats.get("total", {})
        if isinstance(total_block, Mapping):
            _check_thresholds("total", total_block)
    except Exception as exc:  # pragma: no cover - network issues shown to user
        connection_error = str(exc)
        connection_stats = {}

    broker_status["connection_metrics"] = connection_stats
    broker_status["connection_thresholds"] = {
        "success_rate": success_threshold,
        "uptime_ratio": uptime_threshold,
    }
    broker_status["connection_warnings"] = connection_warnings
    broker_status["connection_error"] = connection_error

    try:
        resp = requests.get(reconnect_metrics_endpoint, timeout=3)
        resp.raise_for_status()
        parsed = resp.json()
        if isinstance(parsed, Mapping):
            reconnect_metrics = dict(parsed)
        else:
            raise ValueError("Unexpected reconnectStats response")
        thresholds = reconnect_metrics.get("thresholds", {}) if isinstance(reconnect_metrics, Mapping) else {}
        rate_threshold = thresholds.get("reconnect_rate_per_day")
        failure_threshold = thresholds.get("handshake_failures")
        if rate_threshold is not None:
            try:
                rate_threshold = float(rate_threshold)
            except (TypeError, ValueError):
                rate_threshold = None
        if failure_threshold is not None:
            try:
                failure_threshold = int(failure_threshold)
            except (TypeError, ValueError):
                failure_threshold = None
        stations_block = reconnect_metrics.get("stations", {}) if isinstance(reconnect_metrics, Mapping) else {}
        if isinstance(stations_block, Mapping):
            for sid, metrics in stations_block.items():
                rate = None
                if isinstance(metrics, Mapping):
                    rate = metrics.get("reconnect_rate_per_day")
                try:
                    rate_val = float(rate)
                except (TypeError, ValueError):
                    rate_val = None
                if (
                    rate_threshold is not None
                    and rate_val is not None
                    and rate_val >= rate_threshold
                ):
                    reconnect_warnings.append(
                        {
                            "station_id": sid,
                            "metric": "reconnect_rate",
                            "value": rate_val,
                            "threshold": rate_threshold,
                        }
                    )
        recent_failures = (
            reconnect_metrics.get("recent_handshake_failures", {}) if isinstance(reconnect_metrics, Mapping) else {}
        )
        failures_by_reason = recent_failures.get("by_reason", {}) if isinstance(recent_failures, Mapping) else {}
        for reason_key in ("invalid_path", "auth_failed"):
            try:
                count_val = int(failures_by_reason.get(reason_key, 0))
            except (TypeError, ValueError):
                count_val = 0
            if failure_threshold is not None and count_val >= failure_threshold:
                reconnect_warnings.append(
                    {
                        "station_id": "all",
                        "metric": f"handshake_failure_{reason_key}",
                        "value": count_val,
                        "threshold": failure_threshold,
                    }
                )
    except Exception as exc:  # pragma: no cover - network issues shown to user
        reconnect_error = str(exc)
        reconnect_metrics = {}

    broker_status["reconnect_metrics"] = reconnect_metrics
    broker_status["reconnect_warnings"] = reconnect_warnings
    broker_status["reconnect_error"] = reconnect_error

    db_pool_metrics: dict[str, Any] = {}
    db_pool_warnings: list[str] = []
    db_pool_error: Optional[str] = None
    try:
        resp = requests.get(db_pool_metrics_endpoint, timeout=3)
        resp.raise_for_status()
        parsed = resp.json()
        if isinstance(parsed, Mapping):
            db_pool_metrics = dict(parsed)
        else:
            raise ValueError("Unexpected db_pool_metrics response")

        freesize = db_pool_metrics.get("freesize")
        pool_max = db_pool_metrics.get("pool_maxsize")
        waiting = db_pool_metrics.get("waiting")
        latency_p95 = db_pool_metrics.get("latency_p95_ms")

        if isinstance(freesize, (int, float)) and isinstance(pool_max, (int, float)):
            threshold = max(1, int(pool_max * 0.1))
            if freesize <= threshold:
                db_pool_warnings.append(
                    translate_text(
                        "Low free DB connections: {free} of {maximum} available.",
                        free=int(freesize),
                        maximum=int(pool_max),
                    )
                )
        if isinstance(waiting, (int, float)) and waiting > 0:
            db_pool_warnings.append(
                translate_text("DB pool wait queue detected ({count} tasks).", count=int(waiting))
            )
        if isinstance(latency_p95, (int, float)) and latency_p95 > 500:
            db_pool_warnings.append(
                translate_text(
                    "High DB acquire latency (p95: {latency_ms} ms).", latency_ms=round(latency_p95, 1)
                )
            )
    except Exception as exc:  # pragma: no cover - network issues shown to user
        db_pool_error = str(exc)
        db_pool_metrics = {}

    broker_status["db_pool_metrics"] = db_pool_metrics
    broker_status["db_pool_warnings"] = db_pool_warnings
    broker_status["db_pool_error"] = db_pool_error

    ocpp_log_queue_metrics: dict[str, Any] = {}
    ocpp_log_queue_warnings: list[str] = []
    ocpp_log_queue_error: Optional[str] = None

    def _to_int(value: Any) -> int | None:
        try:
            return int(value)
        except (TypeError, ValueError):
            return None

    ocpp_log_params = {}
    if OCPP_LOG_DROP_WARNING_MINUTES is not None:
        ocpp_log_params["window_minutes"] = OCPP_LOG_DROP_WARNING_MINUTES

    try:
        resp = requests.get(
            f"{OCPP_SERVER_API_BASE_URL}/api/ocpp_log_queue_metrics",
            params=ocpp_log_params or None,
            timeout=3,
        )
        resp.raise_for_status()
        parsed = resp.json()
        if isinstance(parsed, Mapping):
            ocpp_log_queue_metrics = dict(parsed)
        else:
            raise ValueError("Unexpected ocpp_log_queue_metrics response")

        queue_size = _to_int(ocpp_log_queue_metrics.get("size"))
        queue_max = _to_int(ocpp_log_queue_metrics.get("maxsize"))
        fill_ratio = ocpp_log_queue_metrics.get("fill_ratio")
        if fill_ratio is None and queue_size is not None and queue_max:
            try:
                fill_ratio = queue_size / queue_max
            except ZeroDivisionError:
                fill_ratio = None
        ocpp_log_queue_metrics["fill_ratio"] = fill_ratio

        if isinstance(fill_ratio, (int, float)) and fill_ratio >= OCPP_LOG_QUEUE_WARNING_RATIO:
            ocpp_log_queue_warnings.append(
                translate_text(
                    "OCPP log queue usage high: {used}/{maximum} ({pct}%).",
                    used=queue_size or 0,
                    maximum=queue_max or 0,
                    pct=round(fill_ratio * 100, 1),
                )
            )

        recent_drop_count = ocpp_log_queue_metrics.get("recent_drop_count")
        window_minutes_raw = ocpp_log_queue_metrics.get("recent_window_minutes")
        window_minutes = _to_int(window_minutes_raw)
        if window_minutes is None and isinstance(window_minutes_raw, (float, int)):
            try:
                window_minutes = int(window_minutes_raw)
            except (TypeError, ValueError):
                window_minutes = None
        if window_minutes is None:
            window_minutes = OCPP_LOG_DROP_WARNING_MINUTES

        last_drop_raw = (
            ocpp_log_queue_metrics.get("last_drop_display")
            or ocpp_log_queue_metrics.get("last_drop_at")
            or ocpp_log_queue_metrics.get("last_drop")
        )
        last_drop_display = None
        if isinstance(last_drop_raw, str):
            last_drop_display = _format_timestamp(last_drop_raw)
            ocpp_log_queue_metrics["last_drop_display"] = last_drop_display

        try:
            recent_drop_int = int(recent_drop_count)
        except (TypeError, ValueError):
            recent_drop_int = 0

        if recent_drop_int > 0:
            warning_text = translate_text(
                "OCPP log queue drops in last {minutes} minutes: {count}",
                minutes=window_minutes if window_minutes is not None else "-",
                count=recent_drop_int,
            )
            if last_drop_display:
                warning_text = f"{warning_text} ({last_drop_display})"
            ocpp_log_queue_warnings.append(warning_text)
    except Exception as exc:  # pragma: no cover - network issues shown to user
        ocpp_log_queue_error = str(exc)
        ocpp_log_queue_metrics = {}

    broker_status["ocpp_log_queue_metrics"] = ocpp_log_queue_metrics
    broker_status["ocpp_log_queue_warnings"] = ocpp_log_queue_warnings
    broker_status["ocpp_log_queue_error"] = ocpp_log_queue_error

    # 5) Connected-Durations holen
    try:
        r = requests.get(connected_endpoint, timeout=5)
        connected = r.json().get('connected', [])
    except Exception:
        connected = []

    ocpp_versions: dict[str, str] = {}
    durations = {}
    session_reconnects = {}
    connection_quality = {}
    connection_durations: list[tuple[str, int]] = []
    connected_station_ids: set[str] = set()
    for entry in connected:
        sid = entry.get('station_id')
        duration_text, secs_int = _resolve_connection_duration(entry)
        if sid:
            sid_str = str(sid)
            lookup_keys = _station_lookup_keys(sid_str)
            connected_station_ids.add(sid_str)
            if secs_int is not None:
                connection_durations.append((sid, secs_int))
            for key in lookup_keys:
                durations[key] = duration_text
                session_reconnects[key] = entry.get("reconnectCounter")
                connection_quality[key] = entry.get("connectionQuality")
            protocol_display = _normalize_ocpp_version_display(entry.get("ocpp_subprotocol"))
            _store_ocpp_version(ocpp_versions, sid, protocol_display)

    for redirect in redirects:
        protocol_display = _normalize_ocpp_version_display(redirect.get("ocpp_subprotocol"))
        _store_ocpp_version(ocpp_versions, redirect.get("source_url") or "", protocol_display)

    longest_connection = {'station_id': '-', 'duration': '00:00:00'}
    shortest_connection = {'station_id': '-', 'duration': '00:00:00'}
    if connection_durations:
        longest_sid, longest_secs = max(connection_durations, key=lambda item: item[1])
        shortest_sid, shortest_secs = min(connection_durations, key=lambda item: item[1])
        longest_connection = {
            'station_id': longest_sid,
            'duration': str(datetime.timedelta(seconds=longest_secs)),
        }
        shortest_connection = {
            'station_id': shortest_sid,
            'duration': str(datetime.timedelta(seconds=shortest_secs)),
        }

    # 6) Stats holen
    station_charge_states: dict[str, dict[str, str]] = {}
    try:
        resp = requests.get(stats_endpoint, timeout=3)
        stats_payload: Optional[dict[str, Any]] = None
        try:
            parsed = resp.json()
            if isinstance(parsed, dict):
                stats_payload = parsed
        except ValueError:
            stats_payload = None

        if stats_payload is not None:
            entries = int(stats_payload.get('entries') or 0)
            connected_evse = int(stats_payload.get('connected') or 0)
            wallboxes = stats_payload.get('wallboxes')
            if isinstance(wallboxes, Iterable):
                station_charge_states = _build_station_status_lookup(wallboxes)
        else:
            text = resp.text
            m_e = re.search(r'entries\s*:\s*(\d+)', text)
            m_c = re.search(r'connected\s*:\s*(\d+)', text)
            entries = int(m_e.group(1)) if m_e else 0
            connected_evse = int(m_c.group(1)) if m_c else 0
    except Exception:
        entries = 0
        connected_evse = 0
        station_charge_states = {}

    fallback_connected = len(connected_station_ids)
    if connected_evse == 0 and fallback_connected:
        connected_evse = fallback_connected
    if entries == 0 and fallback_connected:
        entries = fallback_connected

    availability_pct = round((connected_evse / entries) * 100, 1) if entries else 0.0

    summary = {
        'connected': connected_evse,
        'total': entries,
        'availability_pct': availability_pct,
        'marked': len(marked_wallboxes),
        'longest': longest_connection,
        'shortest': shortest_connection,
    }

    # 7) Render dashboard template
    return render_template(
        "op_dashboard.html",
        rows=redirects,
        PROXY_BASE_WS=proxy_ws,
        PROXY_BASE_URL=PROXY_BASE_URL,
        durations=durations,
        reconnects=session_reconnects,
        qualities=connection_quality,
        entries=entries,
        connected_evse=connected_evse,
        broker_status=broker_status,
        marked_wallboxes=marked_wallboxes,
        summary=summary,
        marked_station_ids=marked_station_ids,
        highlighted_station_ids=highlighted_station_ids,
        station_card_anchors=station_card_anchors,
        station_normalized_lookup=station_normalized_lookup,
        station_charge_states=station_charge_states,
        ocpp_versions=ocpp_versions,
        max_compare_wallboxes=MAX_COMPARE_WALLBOXES,
        show_proxy_errors=not hide_proxy_errors,
    )


@app.route('/api/op_dashboard_metrics', methods=['GET'])
def api_dashboard_metrics():
    include_jobs = request.args.get("include_jobs", "1") != "0"
    metrics = _collect_dashboard_metrics(include_jobs=include_jobs)
    status_code = 200 if not metrics.get("errors") else 207
    return jsonify({"metrics": metrics}), status_code


@app.route('/op_redirects')
def op_redirects_view():
    """Display a compact overview of all op_redirects entries."""

    conn = get_db_conn()
    try:
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT source_url, ws_url, last_connected, ocpp_subprotocol, comment
                  FROM op_redirects
                 ORDER BY source_url ASC
                """
            )
            redirect_rows = cur.fetchall()
    finally:
        conn.close()

    connected_lookup: set[str] = set()
    connected_error: Optional[str] = None
    try:
        response = requests.get(CONNECTED_ENDPOINT, timeout=5)
        connected_entries: list[Mapping[str, Any]] = []
        try:
            payload = response.json()
        except ValueError:
            payload = None
        if isinstance(payload, dict):
            raw = payload.get('connected', [])
            if isinstance(raw, list):
                connected_entries = [entry for entry in raw if isinstance(entry, Mapping)]
    except Exception as exc:  # pragma: no cover - connection issues are surfaced to the UI
        connected_entries = []
        connected_error = str(exc)

    def _add_identifier_variants(identifier: str) -> None:
        text = str(identifier or '').strip()
        if not text:
            return
        normalized = normalize_station_id(text)
        variants = {
            text,
            text.lstrip('/'),
            normalized,
            normalized.lstrip('/'),
        }
        short = normalized.rsplit('/', 1)[-1] if normalized else ''
        if short:
            variants.add(short)
            variants.add('/' + short)
        trimmed = text.lstrip('/')
        if trimmed:
            variants.add('/' + trimmed)
        if normalized:
            variants.add('/' + normalized)
        connected_lookup.update({variant for variant in variants if variant})

    for entry in connected_entries:
        station_id = entry.get('station_id') or entry.get('stationId') or entry.get('station')
        if station_id:
            _add_identifier_variants(station_id)

    def _format_last_connected(value: Any) -> str:
        if value is None:
            return ''
        if isinstance(value, datetime.datetime):
            return value.strftime('%Y-%m-%d %H:%M:%S')
        return str(value)

    entries: list[dict[str, Any]] = []
    for row in redirect_rows:
        source_url = row.get('source_url') or ''
        normalized_source = normalize_station_id(source_url)
        normalized_trimmed = normalized_source.lstrip('/') if normalized_source else ''
        candidate_ids = {source_url, normalized_source, normalized_trimmed}
        if normalized_trimmed:
            candidate_ids.add('/' + normalized_trimmed)
        short_id = normalized_source.rsplit('/', 1)[-1] if normalized_source else ''
        if short_id:
            candidate_ids.add(short_id)
            candidate_ids.add('/' + short_id)
        status = 'status-indicator--unknown'
        status_label = 'Noch nie verbunden'

        last_connected_raw = row.get('last_connected')
        has_last_connected = False
        if last_connected_raw is not None:
            text = str(last_connected_raw).strip().lower()
            has_last_connected = bool(text and text != 'none')

        if any(identifier in connected_lookup for identifier in candidate_ids if identifier):
            status = 'status-indicator--online'
            status_label = 'Verbunden'
        elif has_last_connected:
            status = 'status-indicator--offline'
            status_label = 'Getrennt'

        protocol = row.get('ocpp_subprotocol')
        if isinstance(protocol, str):
            protocol = protocol.strip()
        protocol_display = protocol or 'OCPP 1.6J'

        entries.append(
            {
                'source_url': source_url,
                'ws_url': row.get('ws_url') or '',
                'last_connected': _format_last_connected(last_connected_raw),
                'ocpp_subprotocol': protocol_display,
                'comment': (row.get('comment') or '').strip(),
                'status_class': status,
                'status_label': status_label,
            }
        )

    return render_template(
        'op_redirects.html',
        entries=entries,
        connected_error=connected_error,
    )


@app.route('/op_marked_wallboxes/<path:station_id>/history')
def marked_wallbox_history(station_id: str):
    """Display the latest 100 mark entries for a given wallbox."""

    normalized = normalize_station_id(station_id)
    candidate_set = {station_id}
    if normalized:
        candidate_set.add(normalized)
        candidate_set.add(f"/{normalized}")

    candidates = [candidate for candidate in candidate_set if candidate]

    history_rows: list[dict] = []
    if candidates:
        placeholders = ", ".join(["%s"] * len(candidates))
        query = f"""
            SELECT id, station_id, reason, source, pattern_id,
                   created_at, updated_at
            FROM op_station_marks
            WHERE station_id IN ({placeholders})
            ORDER BY COALESCE(updated_at, created_at) DESC
            LIMIT 100
        """
        conn = get_db_conn()
        try:
            with conn.cursor() as cur:
                cur.execute(query, candidates)
                history_rows = cur.fetchall()
        finally:
            conn.close()

    _, redirect_lookup = _load_redirects_with_lookup()
    station_key = normalize_station_id(station_id).rsplit('/', 1)[-1] if station_id else ''
    redirect_info = redirect_lookup.get(station_key)

    return render_template(
        "op_marked_wallbox_history.html",
        station_id=station_id,
        normalized_station_id=normalized,
        history_rows=history_rows,
        redirect_info=redirect_info,
    )


@app.route('/op_active_sessions')
def active_sessions():
    """Display all sessions currently tracked by the broker."""
    error_message = None
    sessions: list[dict] = []

    try:
        response = requests.get(ACTIVE_SESSIONS_ENDPOINT, timeout=5)
        response.raise_for_status()
        payload = response.json()
    except Exception as exc:  # pragma: no cover - network issues are surfaced to the UI
        error_message = f"Aktive Sessions konnten nicht geladen werden: {exc}"
        payload = {}

    data = payload.get('sessions', []) if isinstance(payload, dict) else []
    now = datetime.datetime.now(datetime.timezone.utc)

    for item in data:
        if not isinstance(item, dict):
            continue

        station_id = item.get('stationId') or item.get('station_id')
        transaction_id = item.get('transactionId') or item.get('transaction_id')
        connector_id = item.get('connectorId') or item.get('connector_id')
        id_token = item.get('idToken') or item.get('id_tag')

        raw_start = item.get('sessionStartTimestamp') or item.get('session_start')
        start_display = raw_start
        parsed_start = None
        if raw_start:
            try:
                parsed_start = datetime.datetime.fromisoformat(raw_start.replace('Z', '+00:00'))
                start_display = parsed_start.astimezone(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S %Z')
            except ValueError:
                parsed_start = None

        duration = item.get('duration')
        duration_seconds = item.get('durationSeconds')
        if isinstance(duration, str) and '.' in duration:
            duration = duration.split('.', 1)[0]

        if (duration is None or duration_seconds is None) and parsed_start:
            delta = now - parsed_start
            duration_seconds = int(delta.total_seconds())
            duration = str(delta).split('.', 1)[0]

        meter_start_wh = item.get('meterStartWh')
        meter_start_kwh = None
        if meter_start_wh is not None:
            try:
                meter_start_kwh = float(meter_start_wh) / 1000
            except (TypeError, ValueError):
                meter_start_kwh = None

        sessions.append(
            {
                'station_id': station_id,
                'transaction_id': transaction_id,
                'connector_id': connector_id,
                'id_token': id_token,
                'start_display': start_display,
                'start_raw': raw_start,
                'duration': duration,
                'duration_seconds': duration_seconds,
                'meter_start_kwh': meter_start_kwh,
                'meter_start_wh': meter_start_wh,
            }
        )

    sessions.sort(key=lambda s: (s['start_raw'] or ''), reverse=True)

    return render_template(
        'op_active_sessions.html',
        sessions=sessions,
        error_message=error_message,
        endpoint=ACTIVE_SESSIONS_ENDPOINT,
    )


def _extract_cp_config_entries(configuration_json):
    entries: list[dict[str, object]] = []
    if not configuration_json:
        return entries
    parsed = None
    try:
        if isinstance(configuration_json, (bytes, bytearray)):
            configuration_json = configuration_json.decode('utf-8', errors='ignore')
        if isinstance(configuration_json, str):
            parsed = json.loads(configuration_json)
        elif isinstance(configuration_json, dict):
            parsed = configuration_json
    except Exception:
        app.logger.warning('Failed to parse configuration JSON', exc_info=True)
        return entries
    if not isinstance(parsed, dict):
        return entries
    for item in parsed.get('configurationKey', []):
        if not isinstance(item, dict):
            continue
        key = item.get('key')
        if key is None:
            continue
        entries.append({'key': key, 'value': item.get('value')})
    entries.sort(key=lambda e: (e['key'] or '').casefold())
    return entries


def _fetch_latest_bootnotification_info(station_id: str) -> dict | None:
    if not station_id:
        return None

    conn = get_db_conn()
    row = None
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT json_payload, ts
                FROM op_broker_bootnotifications
                WHERE station_id=%s
                ORDER BY ts DESC
                LIMIT 1
                """,
                (station_id,),
            )
            row = cur.fetchone()
    except Exception:
        app.logger.warning(
            "Failed to fetch BootNotification info for station %s", station_id, exc_info=True
        )
        return None
    finally:
        conn.close()

    if not row:
        return None

    raw_payload = row.get('json_payload') if isinstance(row, dict) else row[0]
    timestamp = row.get('ts') if isinstance(row, dict) else (row[1] if len(row) > 1 else None)

    if isinstance(raw_payload, (bytes, bytearray)):
        try:
            raw_payload = raw_payload.decode('utf-8', errors='ignore')
        except Exception:
            raw_payload = raw_payload.decode('utf-8', errors='replace')

    parsed_payload: dict[str, object] = {}
    error_message = None

    if isinstance(raw_payload, str):
        try:
            parsed = json.loads(raw_payload)
        except json.JSONDecodeError:
            parsed = None
            error_message = 'BootNotification-Daten konnten nicht gelesen werden.'
            app.logger.warning(
                'Failed to decode BootNotification JSON for station %s',
                station_id,
                exc_info=True,
            )
    elif isinstance(raw_payload, dict):
        parsed = raw_payload
    elif isinstance(raw_payload, list):
        parsed = raw_payload
    else:
        parsed = None

    if isinstance(parsed, list):
        body: dict[str, object] | None = None
        for element in parsed:
            if not isinstance(element, dict):
                continue
            if {
                'chargePointModel',
                'chargePointVendor',
                'firmwareVersion',
            } & element.keys():
                body = element
                break
        if body is None and len(parsed) >= 4 and isinstance(parsed[3], dict):
            body = parsed[3]
        parsed_payload = body or {}
        if not parsed_payload and error_message is None:
            error_message = 'BootNotification-Daten konnten nicht gelesen werden.'
    elif isinstance(parsed, dict):
        parsed_payload = parsed
    else:
        if error_message is None:
            error_message = 'BootNotification-Daten konnten nicht gelesen werden.'

    def _format_value(value: object) -> str:
        if value is None:
            return '-'
        value_str = str(value).strip()
        return value_str if value_str else '-'

    mandatory_keys = [
        'chargePointModel',
        'chargePointVendor',
        'firmwareVersion',
    ]
    optional_keys = [
        'meterSerialNumber',
        'chargeBoxSerialNumber',
        'imsi',
        'iccid',
    ]

    fields: list[dict[str, str]] = []
    for key in mandatory_keys:
        fields.append({'label': key, 'value': _format_value(parsed_payload.get(key))})
    for key in optional_keys:
        if isinstance(parsed_payload, dict) and key in parsed_payload:
            fields.append({'label': key, 'value': _format_value(parsed_payload.get(key))})

    timestamp_display = None
    if timestamp:
        if isinstance(timestamp, datetime.datetime):
            timestamp_display = timestamp.strftime('%Y-%m-%d %H:%M:%S')
        else:
            timestamp_display = str(timestamp)

    return {
        'fields': fields,
        'timestamp': timestamp,
        'timestamp_display': timestamp_display,
        'error': error_message,
    }


def _build_station_identifier_variants(*identifiers: object) -> set[str]:
    variants: set[str] = set()
    for identifier in identifiers:
        if identifier is None:
            continue
        text = str(identifier).strip()
        if not text:
            continue

        normalized = normalize_station_id(text)
        variants.update(filter(None, {text, text.lstrip('/')}))

        if normalized:
            variants.update(filter(None, {normalized, normalized.lstrip('/')}))
            short = normalized.rsplit('/', 1)[-1]
            if short:
                variants.update({short, f'/{short}'})

        trimmed = text.lstrip('/')
        if trimmed:
            variants.add(f'/{trimmed}')

    return variants


def _extract_subprotocol_value(entry: Mapping[str, Any]) -> str:
    for key in (
        'ocpp_subprotocol',
        'ocppVersion',
        'ocpp_version',
        'subprotocol',
        'protocol',
    ):
        value = entry.get(key)
        if value:
            return str(value)
    return ''


def _lookup_inbound_ocpp_version(station_variants: set[str]) -> str | None:
    if not station_variants:
        return None

    try:
        response = requests.get(CONNECTED_ENDPOINT, timeout=5)
        payload = response.json()
    except Exception:
        app.logger.warning(
            'Failed to fetch inbound OCPP version for station %s',
            next(iter(station_variants)),
            exc_info=True,
        )
        return None

    connected_entries = payload.get('connected') if isinstance(payload, Mapping) else None
    if not isinstance(connected_entries, list):
        return None

    for entry in connected_entries:
        if not isinstance(entry, Mapping):
            continue
        station_identifier = entry.get('station_id') or entry.get('stationId') or entry.get('station')
        if not station_identifier:
            continue
        entry_variants = _build_station_identifier_variants(station_identifier)
        if station_variants.isdisjoint(entry_variants):
            continue

        raw_value = _extract_subprotocol_value(entry)
        normalized = _normalize_ocpp_version_display(raw_value)
        return normalized or None

    return None


def _collect_cp_ocpp_versions(
    station_id: str, source_url: str, outbound_version_raw: object
) -> dict[str, str | None]:
    station_variants = _build_station_identifier_variants(station_id, source_url)

    inbound_version = _lookup_inbound_ocpp_version(station_variants)

    outbound_display = _normalize_ocpp_version_display(outbound_version_raw)
    if outbound_display == '':
        outbound_display = None
    if not outbound_display:
        outbound_display = inbound_version

    return {
        'inbound': inbound_version,
        'outbound': outbound_display,
    }


def _normalize_config_compare_value(value: object) -> str:
    if value is None:
        return ""
    if isinstance(value, (dict, list)):
        try:
            return json.dumps(value, ensure_ascii=False, sort_keys=True)
        except TypeError:
            return str(value)
    return str(value)


def _format_config_compare_display(value: object) -> str:
    if value is None:
        return "-"
    if isinstance(value, str):
        cleaned = value.strip()
        return cleaned if cleaned else "-"
    if isinstance(value, (dict, list)):
        try:
            return json.dumps(value, ensure_ascii=False)
        except TypeError:
            return str(value)
    return str(value)


def _fetch_cp_config_entries(station_id: str) -> list[dict[str, object]]:
    conn = get_db_conn()
    cfg_row = None
    try:
        with conn.cursor() as cur:
            cur.execute(
                'SELECT configuration_json FROM op_server_cp_config WHERE chargepoint_id=%s '
                'ORDER BY created_at DESC LIMIT 1',
                (station_id,),
            )
            cfg_row = cur.fetchone()
    finally:
        conn.close()
    configuration_json = None
    if cfg_row:
        configuration_json = (
            cfg_row.get('configuration_json')
            if isinstance(cfg_row, dict)
            else cfg_row[0]
        )
    return _extract_cp_config_entries(configuration_json)


@app.route('/op_cp_config')
def cp_config_view():
    source_url = request.args.get('source_url')
    if not source_url:
        return "source_url parameter required", 400
    station_id = normalize_station_id(source_url).rsplit('/', 1)[-1]
    target = None
    outbound_ocpp_version = None
    conn = get_db_conn()

    cfg_row = None

    try:
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            cur.execute(
                'SELECT ws_url, ocpp_subprotocol FROM op_redirects WHERE source_url=%s',
                (source_url,),
            )
            redirect_row = cur.fetchone()

            ws_url = None
            if redirect_row:
                ws_url = redirect_row.get('ws_url') if isinstance(redirect_row, dict) else redirect_row[0]
                outbound_ocpp_version = (
                    redirect_row.get('ocpp_subprotocol')
                    if isinstance(redirect_row, dict)
                    else (redirect_row[1] if len(redirect_row) > 1 else None)
                )
            if ws_url:
                base = '/'.join(ws_url.split('/')[:4])
                cur.execute('SELECT short_name, ws_url FROM op_targets WHERE ws_url LIKE %s LIMIT 1', (base + '%',))
                target_row = cur.fetchone()
                if target_row:
                    if isinstance(target_row, dict):
                        target = target_row
                    else:
                        target = {"short_name": target_row[0], "ws_url": target_row[1]}

            cur.execute('SELECT configuration_json FROM op_server_cp_config WHERE chargepoint_id=%s ORDER BY created_at DESC LIMIT 1', (station_id,))
            cfg_row = cur.fetchone()
    finally:
        conn.close()

    recent_auth_logs = _fetch_cp_auth_logs(station_id=station_id, limit=5)
    bootnotification_info = _fetch_latest_bootnotification_info(station_id)

    configuration_json = None
    if cfg_row:
        configuration_json = (
            cfg_row.get('configuration_json')
            if isinstance(cfg_row, dict)
            else cfg_row[0]
        )
    config_entries = _extract_cp_config_entries(configuration_json)
    ocpp_versions = _collect_cp_ocpp_versions(
        station_id=station_id,
        source_url=source_url,
        outbound_version_raw=outbound_ocpp_version,
    )
    return render_template(
        'op_cp_config.html',
        station_id=station_id,
        target=target,
        source_url=source_url,
        config_entries=config_entries,
        recent_auth_logs=recent_auth_logs,
        bootnotification_info=bootnotification_info,
        ocpp_versions=ocpp_versions,
    )


@app.get('/op_cp_config/config_entries')
def cp_config_entries_json():
    station_id = request.args.get('station_id', '').strip()
    if not station_id:
        return jsonify({'error': 'station_id required'}), 400
    entries = _fetch_cp_config_entries(station_id)
    return jsonify({'entries': entries})


@app.get('/op_cp_config/compare')
def cp_config_compare_view():
    raw_station_ids = request.args.getlist('station_id')
    normalized_station_ids: list[str] = []
    seen_ids: set[str] = set()
    for raw_id in raw_station_ids:
        if raw_id is None:
            continue
        cleaned = normalize_station_id(str(raw_id))
        if not cleaned:
            continue
        final_id = cleaned.rsplit('/', 1)[-1]
        if final_id in seen_ids:
            continue
        seen_ids.add(final_id)
        normalized_station_ids.append(final_id)

    if not normalized_station_ids:
        return redirect(url_for('dashboard'))

    trimmed = False
    if len(normalized_station_ids) > MAX_COMPARE_WALLBOXES:
        normalized_station_ids = normalized_station_ids[:MAX_COMPARE_WALLBOXES]
        trimmed = True

    entry_maps: list[dict[str, object]] = []
    all_keys: set[str] = set()

    for station_id in normalized_station_ids:
        entries = _fetch_cp_config_entries(station_id)
        entry_map: dict[str, object] = {}
        for entry in entries:
            key = entry.get('key') if isinstance(entry, dict) else None
            if key is None:
                continue
            key_str = str(key)
            entry_map[key_str] = entry.get('value') if isinstance(entry, dict) else None
            all_keys.add(key_str)
        entry_maps.append(entry_map)

    sorted_keys = sorted(all_keys, key=lambda item: item.casefold())
    comparison_rows: list[dict[str, object]] = []
    for key in sorted_keys:
        values = [entry_map.get(key) for entry_map in entry_maps]
        normalized_values = [_normalize_config_compare_value(value) for value in values]
        has_difference = len(set(normalized_values)) > 1
        cells = [
            {
                'display': _format_config_compare_display(value),
                'is_different': has_difference,
            }
            for value in values
        ]
        comparison_rows.append({'key': key, 'cells': cells, 'has_difference': has_difference})

    return render_template(
        'op_cp_config_compare.html',
        station_ids=normalized_station_ids,
        comparison_rows=comparison_rows,
        trimmed_selection=trimmed,
        max_compare=MAX_COMPARE_WALLBOXES,
    )


def _fetch_cp_auth_logs(*, station_id=None, limit=200):
    limit = int(limit)
    conn = get_db_conn()
    rows = []
    try:
        with conn.cursor() as cur:
            if station_id:
                cur.execute(
                    """
                    SELECT
                        created_at,
                        chargepoint_id,
                        incoming_auth_header,
                        incoming_auth_username,
                        incoming_auth_password,
                        used_auth_header,
                        used_auth_source
                    FROM op_broker_cp_auth_log
                    WHERE chargepoint_id=%s
                    ORDER BY created_at DESC
                    LIMIT %s
                    """,
                    (station_id, limit),
                )
            else:
                cur.execute(
                    """
                    SELECT
                        created_at,
                        chargepoint_id,
                        incoming_auth_header,
                        incoming_auth_username,
                        incoming_auth_password,
                        used_auth_header,
                        used_auth_source
                    FROM op_broker_cp_auth_log
                    ORDER BY created_at DESC
                    LIMIT %s
                    """,
                    (limit,),
                )
            rows = cur.fetchall()
    except Exception:
        app.logger.warning("Failed to fetch charge point auth logs", exc_info=True)
        rows = []
    finally:
        conn.close()
    return rows


@app.post('/op_cp_config/refresh')
def cp_config_refresh():
    payload = request.get_json(silent=True) or {}
    station_id = str(payload.get('station_id', '')).strip()
    if not station_id:
        return jsonify({'error': 'station_id required'}), 400
    try:
        _get_ocpp_command('/api/getConfiguration', {'station_id': station_id})
    except RuntimeError as exc:
        return jsonify({'error': str(exc)}), 502
    entries = _fetch_cp_config_entries(station_id)
    return jsonify({'entries': entries})


@app.route('/op_cp_authorization_log')
def cp_authorization_log_view():
    logs = _fetch_cp_auth_logs(limit=200)
    return render_template('op_cp_authorization_log.html', logs=logs)


def _fetch_external_api_log_entries(limit: int = 200) -> list[dict[str, object]]:
    filters: dict[str, object] = {}
    rows, _ = _load_external_api_logs(filters, limit=limit, offset=0)
    return [_format_external_api_entry(row, for_display=True) for row in rows]


@app.route('/op_api_logging')
def external_api_logging_view():
    entries = _fetch_external_api_log_entries(limit=200)
    return render_template('op_api_logging.html', entries=entries, aside='repo_api_logs')


@app.post('/op_cp_config/update_entry')
def cp_config_update_entry():
    payload = request.get_json(silent=True) or {}
    station_id = str(payload.get('station_id', '')).strip()
    key = payload.get('key')
    value = payload.get('value')
    if not station_id or key is None or value is None:
        return jsonify({'error': 'station_id, key and value required'}), 400
    try:
        result = _post_ocpp_command(
            '/api/setConfiguration',
            {'station_id': station_id, 'key': key, 'value': value},
        )
    except RuntimeError as exc:
        return jsonify({'error': str(exc)}), 502

    refresh_error = None
    entries = None
    try:
        _get_ocpp_command('/api/getConfiguration', {'station_id': station_id})
        entries = _fetch_cp_config_entries(station_id)
    except Exception as exc:
        refresh_error = str(exc)
        app.logger.warning('Failed to refresh configuration after update', exc_info=True)

    response_body: dict[str, object] = {'result': result}
    if entries is not None:
        response_body['entries'] = entries
    if refresh_error:
        response_body['refresh_error'] = refresh_error
    return jsonify(response_body)


@app.route('/op_performance')
def performance_monitoring():
    """Display WebSocket usage for a selectable timeframe."""
    hours_param = request.args.get('hours', '48')
    try:
        hours = int(hours_param)
    except ValueError:
        hours = 48
    if hours not in {2, 6, 24, 48}:
        hours = 48

    conn = get_db_conn()
    rows = []
    try:
        with conn.cursor() as cur:
            cur.execute(
                f"""
                SELECT
                    ts,
                    wallbox_to_broker,
                    broker_to_backend,
                    ocpp_endpoint
                FROM op_log_websockets
                WHERE ts >= NOW() - INTERVAL {hours} HOUR
                ORDER BY ts
                """
            )
            rows = cur.fetchall()
    except Exception:
        rows = []
    finally:
        conn.close()

    labels: list[str] = []
    data_by_endpoint: dict[str, dict[str, dict[str, int]]] = {}
    latest_per_endpoint: dict[str, dict[str, int]] = {}
    for r in rows:
        ts = r.get('ts')
        if isinstance(ts, datetime.datetime):
            label = ts.strftime('%Y-%m-%d %H:%M')
        else:
            label = str(ts)
        if label not in labels:
            labels.append(label)
        w2b = r.get('wallbox_to_broker') or 0
        b2b = r.get('broker_to_backend') or 0
        endpoint = r.get('ocpp_endpoint') or 'default'
        ep_data = data_by_endpoint.setdefault(endpoint, {
            'wallbox_to_broker': {},
            'broker_to_backend': {},
        })
        ep_data['wallbox_to_broker'][label] = w2b
        ep_data['broker_to_backend'][label] = b2b
        latest_per_endpoint[endpoint] = {
            'wallbox_to_broker': w2b,
            'broker_to_backend': b2b,
        }

    peak_per_endpoint = {
        ep: {
            'wallbox_to_broker': max(values['wallbox_to_broker'].values() or [0]),
            'broker_to_backend': max(values['broker_to_backend'].values() or [0]),
        }
        for ep, values in data_by_endpoint.items()
    }

    color_palette = [
        ('rgba(75, 192, 192, 1)', 'rgba(75, 192, 192, 0.2)'),
        ('rgba(255, 99, 132, 1)', 'rgba(255, 99, 132, 0.2)'),
        ('rgba(54, 162, 235, 1)', 'rgba(54, 162, 235, 0.2)'),
        ('rgba(255, 206, 86, 1)', 'rgba(255, 206, 86, 0.2)'),
        ('rgba(153, 102, 255, 1)', 'rgba(153, 102, 255, 0.2)'),
        ('rgba(255, 159, 64, 1)', 'rgba(255, 159, 64, 0.2)'),
    ]

    datasets = []
    for idx, (endpoint, values) in enumerate(data_by_endpoint.items()):
        border_w2b, background_w2b = color_palette[(2 * idx) % len(color_palette)]
        border_b2b, background_b2b = color_palette[(2 * idx + 1) % len(color_palette)]
        datasets.append({
            'label': f"{endpoint} wallbox_to_broker",
            'data': [values['wallbox_to_broker'].get(label, 0) for label in labels],
            'borderColor': border_w2b,
            'backgroundColor': background_w2b,
            'tension': 0.1,
        })
        datasets.append({
            'label': f"{endpoint} broker_to_backend",
            'data': [values['broker_to_backend'].get(label, 0) for label in labels],
            'borderColor': border_b2b,
            'backgroundColor': background_b2b,
            'tension': 0.1,
        })

    return render_template(
        'op_performance.html',
        labels=labels,
        datasets=datasets,
        peak_per_endpoint=peak_per_endpoint,
        latest_per_endpoint=latest_per_endpoint,
        selected_hours=hours,
    )


@app.route('/op_ping_monitor')
def ping_monitor():
    """Display ping round-trip times for a single wallbox."""
    source_url = request.args.get('source_url')
    hours_param = request.args.get('hours', '24')
    try:
        hours = int(hours_param)
    except ValueError:
        hours = 24
    if hours not in {2, 6, 24, 48}:
        hours = 24
    if not source_url:
        return "source_url required", 400

    alternate_source_url = (
        source_url[1:]
        if source_url.startswith('/')
        else f"/{source_url}" if source_url else source_url
    )

    end = datetime.datetime.now()
    start = end - datetime.timedelta(hours=hours)
    months = {end.strftime('%y%m'), start.strftime('%y%m')}

    conn = get_db_conn()
    rows = []
    try:
        with conn.cursor() as cur:
            for m in months:
                table = f"op_messages_{m}"
                cur.execute(
                    f"""SELECT timestamp, message FROM {table} WHERE (source_url=%s OR source_url=%s) AND direction='ping' AND timestamp >= %s ORDER BY timestamp""",
                    (source_url, alternate_source_url, start),
                )
                rows.extend(cur.fetchall())
    except Exception:
        rows = []
    finally:
        conn.close()

    labels = []
    values = []
    for r in rows:
        ts = r.get('timestamp')
        msg = r.get('message')
        label = ts.strftime('%Y-%m-%d %H:%M') if isinstance(ts, datetime.datetime) else str(ts)
        try:
            payload = json.loads(msg)
            rtt = payload.get('rtt_ms')
        except Exception:
            rtt = None
        if rtt is not None:
            labels.append(label)
            values.append(rtt)

    return render_template(
        'op_ping_monitor.html',
        labels=labels,
        values=values,
        selected_hours=hours,
        source_url=source_url,
    )

@app.route('/op_download_diag', methods=['POST'])
def download_diag():
    station_id = request.form['station_id']
    url = f"{PROXY_BASE_URL}/getDiagnostic/{station_id}"
    resp = requests.get(url, stream=True)
    headers = {k: resp.headers.get(k) for k in ['Content-Type', 'Content-Disposition'] if resp.headers.get(k)}
    return Response(resp.raw, headers=headers)

@app.route('/op_download_diag_get', methods=['GET'])
def download_diag_get():
    raw_station_id = request.args.get('station_id')
    if not raw_station_id:
        raw_station_id = request.args.get('source_url')

    station_id = normalize_station_id(raw_station_id or "")
    if not station_id:
        app.logger.warning(
            "download_diag_get missing station_id/source_url query parameter"
        )
        abort(400, description="station_id or source_url query parameter required")

    url = f"{PROXY_BASE_URL}/getDiagnostic/{station_id}"
    resp = requests.get(url, stream=True)
    headers = {k: resp.headers.get(k) for k in ['Content-Type', 'Content-Disposition'] if resp.headers.get(k)}
    return Response(resp.raw, headers=headers)

@app.route('/op_flush_reconnects', methods=['POST'])
def flush_reconnects():
    # Reset reconnect_count in DB
    conn = get_db_conn()
    with conn.cursor() as cur:
        cur.execute("UPDATE op_redirects SET reconnect_count = 0")
        conn.commit()
    conn.close()
    return redirect(url_for('dashboard'))


@app.route('/op_tenants', methods=['GET', 'POST'])
def manage_tenants():
    if not is_admin_user():
        return redirect(url_for('dashboard'))

    errors: list[str] = []
    message: str | None = None
    tenants: list[dict] = []
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            if request.method == 'POST':
                action = (request.form.get('action') or '').strip().lower()
                payload: dict[str, str] = {}
                if action in {'add', 'update'}:
                    payload, validation_errors = _parse_tenant_form(request.form)
                    errors.extend(validation_errors)

                if action == 'add' and not errors:
                    try:
                        cur.execute(
                            "INSERT INTO op_tenants (name, comment, endpoint) VALUES (%s, %s, %s)",
                            (
                                payload['name'],
                                payload['comment'] or None,
                                payload['endpoint'],
                            ),
                        )
                    except pymysql.IntegrityError:
                        conn.rollback()
                        errors.append('A tenant with this endpoint already exists.')
                    else:
                        conn.commit()
                        message = 'Tenant created successfully.'

                elif action == 'update' and not errors:
                    tenant_id_raw = (request.form.get('tenant_id') or '').strip()
                    try:
                        tenant_id = int(tenant_id_raw)
                    except (TypeError, ValueError):
                        errors.append('Invalid tenant identifier.')
                    else:
                        cur.execute(
                            'SELECT tenant_id FROM op_tenants WHERE tenant_id=%s',
                            (tenant_id,),
                        )
                        if not cur.fetchone():
                            errors.append('Tenant not found.')
                        else:
                            try:
                                cur.execute(
                                    'UPDATE op_tenants SET name=%s, comment=%s, endpoint=%s WHERE tenant_id=%s',
                                    (
                                        payload['name'],
                                        payload['comment'] or None,
                                        payload['endpoint'],
                                        tenant_id,
                                    ),
                                )
                            except pymysql.IntegrityError:
                                conn.rollback()
                                errors.append('A tenant with this endpoint already exists.')
                            else:
                                conn.commit()
                                message = 'Tenant updated successfully.'

                elif action == 'delete':
                    tenant_id_raw = (request.form.get('tenant_id') or '').strip()
                    try:
                        tenant_id = int(tenant_id_raw)
                    except (TypeError, ValueError):
                        errors.append('Invalid tenant identifier.')
                    else:
                        cur.execute(
                            'DELETE FROM op_tenants WHERE tenant_id=%s',
                            (tenant_id,),
                        )
                        if cur.rowcount:
                            conn.commit()
                            message = 'Tenant deleted.'
                        else:
                            conn.rollback()
                            errors.append('Tenant not found.')

                elif action and action not in {'add', 'update', 'delete'}:
                    errors.append('Unsupported action.')

            cur.execute(
                'SELECT tenant_id, name, comment, endpoint FROM op_tenants ORDER BY tenant_id'
            )
            tenants = cur.fetchall()
    finally:
        conn.close()

    return render_template(
        'op_tenants.html',
        aside='tenant_manage',
        tenants=tenants,
        errors=errors,
        message=message,
    )


@app.route('/op_tenant_users', methods=['GET', 'POST'])
def manage_tenant_users():
    if not is_admin_user():
        return redirect(url_for('dashboard'))

    errors: list[str] = []
    message: str | None = None
    tenants: list[dict] = []
    users: list[dict] = []

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                'SELECT tenant_id, name FROM op_tenants ORDER BY name, tenant_id'
            )
            tenants = cur.fetchall()

        tenant_ids = {row['tenant_id'] for row in tenants}

        with conn.cursor() as cur:
            if request.method == 'POST':
                action = (request.form.get('action') or '').strip().lower()
                payload: dict[str, str | int | None] = {}

                if action in {'add', 'update'}:
                    require_password = action == 'add'
                    if not tenant_ids and action == 'add':
                        errors.append('Please create a tenant before adding users.')
                    payload, validation_errors = _parse_tenant_user_form(
                        request.form,
                        require_password=require_password,
                        valid_tenant_ids=tenant_ids,
                    )
                    errors.extend(validation_errors)

                if action == 'add' and not errors:
                    try:
                        cur.execute(
                            'INSERT INTO op_tenant_users (tenant_id, name, email, login, password, hidden_menus) VALUES (%s, %s, %s, %s, %s, %s)',
                            (
                                payload['tenant_id'],
                                payload['name'],
                                payload['email'],
                                payload['login'],
                                payload['password'],
                                payload['hidden_menus'],
                            ),
                        )
                    except pymysql.IntegrityError:
                        conn.rollback()
                        errors.append('A user with this login already exists.')
                    else:
                        conn.commit()
                        message = 'User created successfully.'

                elif action == 'update' and not errors:
                    user_id_raw = (request.form.get('user_id') or '').strip()
                    try:
                        user_id = int(user_id_raw)
                    except (TypeError, ValueError):
                        errors.append('Invalid user identifier.')
                    else:
                        cur.execute(
                            'SELECT user_id FROM op_tenant_users WHERE user_id=%s',
                            (user_id,),
                        )
                        if not cur.fetchone():
                            errors.append('User not found.')
                        else:
                            update_parts = [
                                'tenant_id=%s',
                                'name=%s',
                                'email=%s',
                                'login=%s',
                                'hidden_menus=%s',
                            ]
                            update_values: list[object] = [
                                payload['tenant_id'],
                                payload['name'],
                                payload['email'],
                                payload['login'],
                                payload['hidden_menus'],
                            ]
                            if payload['password']:
                                update_parts.append('password=%s')
                                update_values.append(payload['password'])

                            update_values.append(user_id)

                            try:
                                cur.execute(
                                    f"UPDATE op_tenant_users SET {', '.join(update_parts)} WHERE user_id=%s",
                                    update_values,
                                )
                            except pymysql.IntegrityError:
                                conn.rollback()
                                errors.append('A user with this login already exists.')
                            else:
                                conn.commit()
                                message = 'User updated successfully.'

                elif action == 'delete':
                    user_id_raw = (request.form.get('user_id') or '').strip()
                    try:
                        user_id = int(user_id_raw)
                    except (TypeError, ValueError):
                        errors.append('Invalid user identifier.')
                    else:
                        cur.execute(
                            'DELETE FROM op_tenant_users WHERE user_id=%s',
                            (user_id,),
                        )
                        if cur.rowcount:
                            conn.commit()
                            message = 'User deleted.'
                        else:
                            conn.rollback()
                            errors.append('User not found.')

                elif action and action not in {'add', 'update', 'delete'}:
                    errors.append('Unsupported action.')

            cur.execute(
                'SELECT u.user_id, u.tenant_id, u.name, u.email, u.login, u.hidden_menus, t.name AS tenant_name '
                'FROM op_tenant_users u '
                'LEFT JOIN op_tenants t ON t.tenant_id = u.tenant_id '
                'ORDER BY u.user_id'
            )
            users = cur.fetchall()

            for user in users:
                try:
                    raw_hidden = json.loads(user.get("hidden_menus") or "[]")
                except (TypeError, json.JSONDecodeError):
                    raw_hidden = []
                user["hidden_menus"] = _load_hidden_menus(raw_hidden)
    finally:
        conn.close()

    return render_template(
        'op_tenant_users.html',
        aside='tenant_users',
        tenants=tenants,
        users=users,
        menu_choices=MAIN_MENU_CHOICES,
        errors=errors,
        message=message,
    )


@app.route('/op_idtags', methods=['GET', 'POST'])
def idtag_management():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            if request.method == 'POST':
                action = request.form.get('action')
                if action == 'add':
                    cur.execute(
                        "INSERT INTO op_idtags (real_uid, virtual_uid, comment) VALUES (%s, %s, %s)",
                        (
                            request.form.get('real_uid', ''),
                            request.form.get('virtual_uid', ''),
                            request.form.get('comment', ''),
                        ),
                    )
                elif action == 'update':
                    cur.execute(
                        "UPDATE op_idtags SET real_uid=%s, virtual_uid=%s, comment=%s WHERE id=%s",
                        (
                            request.form.get('real_uid', ''),
                            request.form.get('virtual_uid', ''),
                            request.form.get('comment', ''),
                            request.form.get('id'),
                        ),
                    )
                elif action == 'delete':
                    cur.execute(
                        "DELETE FROM op_idtags WHERE id=%s",
                        (request.form.get('id'),),
                    )
                elif action == 'save_config':
                    map_all = 'map_all' in request.form
                    master = request.form.get('master_uid', '')
                    set_config_value('idtag_map_all', '1' if map_all else '0')
                    set_config_value('idtag_master_uid', master)
                conn.commit()
            cur.execute("SELECT id, real_uid, virtual_uid, comment FROM op_idtags ORDER BY id")
            rows = cur.fetchall()
    finally:
        conn.close()

    map_all = get_config_value('idtag_map_all') == '1'
    master_uid = get_config_value('idtag_master_uid') or ''
    return render_template(
        'op_idtags.html',
        rows=rows,
        map_all=map_all,
        master_uid=master_uid,
    )


@app.route('/op_vehicle_catalog', methods=['GET', 'POST'])
def vehicle_catalog():
    errors = []
    message = None
    show_add_form = False

    default_add_form = {
        "name": "",
        "manufacturer": "",
        "model": "",
        "battery_size_gross_kwh": "",
        "battery_size_net_kwh": "",
        "ac_charging_losses_percent": _format_decimal_for_input(
            DEFAULT_AC_CHARGING_LOSSES
        ),
    }
    new_vehicle_form = dict(default_add_form)

    conn = get_db_conn()
    try:
        if request.method == 'POST':
            action = request.form.get('action', '').strip().lower()
            if action == 'add':
                show_add_form = True
                new_vehicle_form = {
                    key: (request.form.get(key) or "").strip()
                    for key in default_add_form
                }
                payload, validation_errors = _parse_vehicle_form(
                    request.form, apply_default_losses=True
                )
                errors.extend(validation_errors)
                if not errors:
                    with conn.cursor() as cur:
                        cur.execute(
                            """
                            INSERT INTO op_vehicle_catalog (
                                name,
                                manufacturer,
                                model,
                                battery_size_gross_kwh,
                                battery_size_net_kwh,
                                ac_charging_losses_percent
                            ) VALUES (%s, %s, %s, %s, %s, %s)
                            """,
                            (
                                payload["name"],
                                payload["manufacturer"],
                                payload["model"],
                                payload["battery_size_gross_kwh"],
                                payload["battery_size_net_kwh"],
                                payload["ac_charging_losses_percent"],
                            ),
                        )
                    conn.commit()
                    message = "Vehicle added successfully."
                    show_add_form = False
                    new_vehicle_form = dict(default_add_form)
                else:
                    conn.rollback()
            elif action == 'update':
                vehicle_id_raw = request.form.get('id')
                try:
                    vehicle_id = int(vehicle_id_raw)
                except (TypeError, ValueError):
                    errors.append("Invalid vehicle identifier.")
                    conn.rollback()
                else:
                    payload, validation_errors = _parse_vehicle_form(request.form)
                    errors.extend(validation_errors)
                    if not errors:
                        with conn.cursor() as cur:
                            cur.execute(
                                """
                                UPDATE op_vehicle_catalog
                                SET name=%s,
                                    manufacturer=%s,
                                    model=%s,
                                    battery_size_gross_kwh=%s,
                                    battery_size_net_kwh=%s,
                                    ac_charging_losses_percent=%s
                                WHERE id=%s
                                """,
                                (
                                    payload["name"],
                                    payload["manufacturer"],
                                    payload["model"],
                                    payload["battery_size_gross_kwh"],
                                    payload["battery_size_net_kwh"],
                                    payload["ac_charging_losses_percent"],
                                    vehicle_id,
                                ),
                            )
                            if cur.rowcount == 0:
                                errors.append("Vehicle not found.")
                                conn.rollback()
                            else:
                                conn.commit()
                                message = "Vehicle updated successfully."
                    else:
                        conn.rollback()
            elif action == 'delete':
                vehicle_id_raw = request.form.get('id')
                try:
                    vehicle_id = int(vehicle_id_raw)
                except (TypeError, ValueError):
                    errors.append("Invalid vehicle identifier.")
                    conn.rollback()
                else:
                    with conn.cursor() as cur:
                        cur.execute(
                            "DELETE FROM op_vehicle_catalog WHERE id=%s",
                            (vehicle_id,),
                        )
                        if cur.rowcount == 0:
                            errors.append("Vehicle not found.")
                            conn.rollback()
                        else:
                            conn.commit()
                            message = "Vehicle deleted successfully."
            else:
                errors.append("Unsupported action requested.")
                conn.rollback()

        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT
                    id,
                    name,
                    manufacturer,
                    model,
                    battery_size_gross_kwh,
                    battery_size_net_kwh,
                    ac_charging_losses_percent
                FROM op_vehicle_catalog
                ORDER BY manufacturer, model, name
                """
            )
            vehicles = cur.fetchall()
    finally:
        conn.close()

    return render_template(
        'op_vehicle_catalog.html',
        vehicles=vehicles,
        errors=errors,
        message=message,
        new_vehicle_form=new_vehicle_form,
        show_add_form=show_add_form,
        format_decimal_for_input=_format_decimal_for_input,
        aside='chargingAnalyticsMenu',
    )


@app.route('/op_vehicle_fleet', methods=['GET', 'POST'])
def vehicle_fleet():
    errors = []
    message = None
    show_add_form = False

    default_add_form = {
        "name": "",
        "vehicle_catalog_id": "",
        "build_year": "",
        "vin": "",
        "license_plate": "",
    }
    new_fleet_form = dict(default_add_form)

    vehicle_catalog = []

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT
                    id,
                    name,
                    manufacturer,
                    model
                FROM op_vehicle_catalog
                ORDER BY manufacturer, model, name
                """
            )
            vehicle_catalog = cur.fetchall()

        catalog_by_id = {entry["id"]: entry for entry in vehicle_catalog}

        if request.method == 'POST':
            action = request.form.get('action', '').strip().lower()

            if action == 'add':
                show_add_form = True
                new_fleet_form = {
                    key: (request.form.get(key) or "").strip()
                    for key in default_add_form
                }
                payload, validation_errors = _parse_vehicle_fleet_form(request.form)
                errors.extend(validation_errors)

                vehicle_catalog_id = payload.get("vehicle_catalog_id")
                if (
                    vehicle_catalog_id is not None
                    and vehicle_catalog_id not in catalog_by_id
                ):
                    errors.append(
                        "Selected vehicle no longer exists in the vehicle database."
                    )

                if errors:
                    conn.rollback()
                else:
                    with conn.cursor() as cur:
                        cur.execute(
                            """
                            INSERT INTO op_vehicle_fleet (
                                name,
                                vehicle_catalog_id,
                                build_year,
                                vin,
                                license_plate
                            ) VALUES (%s, %s, %s, %s, %s)
                            """,
                            (
                                payload["name"],
                                payload["vehicle_catalog_id"],
                                payload["build_year"],
                                payload["vin"],
                                payload["license_plate"],
                            ),
                        )
                    conn.commit()
                    message = "Fleet vehicle added successfully."
                    show_add_form = False
                    new_fleet_form = dict(default_add_form)

            elif action == 'update':
                fleet_id_raw = request.form.get('id')
                try:
                    fleet_id = int(fleet_id_raw)
                except (TypeError, ValueError):
                    errors.append("Invalid fleet vehicle identifier.")
                    conn.rollback()
                else:
                    payload, validation_errors = _parse_vehicle_fleet_form(request.form)
                    errors.extend(validation_errors)

                    vehicle_catalog_id = payload.get("vehicle_catalog_id")
                    if (
                        vehicle_catalog_id is not None
                        and vehicle_catalog_id not in catalog_by_id
                    ):
                        errors.append(
                            "Selected vehicle no longer exists in the vehicle database."
                        )

                    if errors:
                        conn.rollback()
                    else:
                        with conn.cursor() as cur:
                            cur.execute(
                                """
                                UPDATE op_vehicle_fleet
                                SET name=%s,
                                    vehicle_catalog_id=%s,
                                    build_year=%s,
                                    vin=%s,
                                    license_plate=%s
                                WHERE id=%s
                                """,
                                (
                                    payload["name"],
                                    payload["vehicle_catalog_id"],
                                    payload["build_year"],
                                    payload["vin"],
                                    payload["license_plate"],
                                    fleet_id,
                                ),
                            )
                            if cur.rowcount == 0:
                                errors.append("Fleet vehicle not found.")
                                conn.rollback()
                            else:
                                conn.commit()
                                message = "Fleet vehicle updated successfully."

            elif action == 'delete':
                fleet_id_raw = request.form.get('id')
                try:
                    fleet_id = int(fleet_id_raw)
                except (TypeError, ValueError):
                    errors.append("Invalid fleet vehicle identifier.")
                    conn.rollback()
                else:
                    with conn.cursor() as cur:
                        cur.execute(
                            "DELETE FROM op_vehicle_fleet WHERE id=%s",
                            (fleet_id,),
                        )
                        if cur.rowcount == 0:
                            errors.append("Fleet vehicle not found.")
                            conn.rollback()
                        else:
                            conn.commit()
                            message = "Fleet vehicle deleted successfully."

            else:
                errors.append("Unsupported action requested.")
                conn.rollback()

        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT
                    f.id,
                    f.name,
                    f.vehicle_catalog_id,
                    f.build_year,
                    f.vin,
                    f.license_plate,
                    vc.manufacturer,
                    vc.model,
                    vc.name AS catalog_name
                FROM op_vehicle_fleet AS f
                LEFT JOIN op_vehicle_catalog AS vc ON vc.id = f.vehicle_catalog_id
                ORDER BY f.name
                """
            )
            fleet_entries = cur.fetchall()
    finally:
        conn.close()

    return render_template(
        'op_vehicle_fleet.html',
        fleet_entries=fleet_entries,
        vehicle_catalog=vehicle_catalog,
        errors=errors,
        message=message,
        show_add_form=show_add_form,
        new_fleet_form=new_fleet_form,
        aside='chargingAnalyticsMenu',
    )


@app.route('/op_ocpi_wallboxes', methods=['GET', 'POST'])
def ocpi_wallbox_management():
    ensure_ocpi_backend_tables()
    message = None
    errors: list[str] = []
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            if request.method == 'POST':
                action = request.form.get('action')
                station_id = (request.form.get('station_id') or '').strip()
                backend_id_raw = request.form.get('backend_id')
                try:
                    backend_id = int(backend_id_raw) if backend_id_raw else None
                except (TypeError, ValueError):
                    backend_id = None
                enabled = 1 if request.form.get('enabled') == '1' else 0
                try:
                    priority = int(request.form.get('priority') or 100)
                except (TypeError, ValueError):
                    priority = 100

                if not station_id:
                    errors.append(translate_text('Station ID is required.'))
                if backend_id is None:
                    errors.append(translate_text('Backend selection is required.'))

                if not errors:
                    if action == 'add':
                        cur.execute(
                            """
                            INSERT INTO op_ocpi_wallbox_backends (station_id, backend_id, enabled, priority)
                            VALUES (%s, %s, %s, %s)
                            ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), priority = VALUES(priority)
                            """,
                            (station_id, backend_id, enabled, priority),
                        )
                        message = translate_text('Assignment saved.')
                    elif action == 'update':
                        cur.execute(
                            """
                            UPDATE op_ocpi_wallbox_backends
                            SET enabled=%s, priority=%s
                            WHERE station_id=%s AND backend_id=%s
                            """,
                            (enabled, priority, station_id, backend_id),
                        )
                        message = translate_text('Assignment updated.')
                    elif action == 'delete':
                        cur.execute(
                            "DELETE FROM op_ocpi_wallbox_backends WHERE station_id=%s AND backend_id=%s",
                            (station_id, backend_id),
                        )
                        message = translate_text('Assignment deleted.')
                    conn.commit()

            cur.execute(
                """
                SELECT wb.station_id, wb.backend_id, wb.enabled, wb.priority, b.name, b.url
                FROM op_ocpi_wallbox_backends AS wb
                JOIN op_ocpi_backends AS b ON wb.backend_id = b.backend_id
                ORDER BY wb.station_id, wb.priority, b.name
                """
            )
            rows = cur.fetchall()
            cur.execute(
                "SELECT backend_id, name FROM op_ocpi_backends ORDER BY name"
            )
            backends = cur.fetchall()
    finally:
        conn.close()
    return render_template(
        'op_ocpi_wallboxes.html',
        rows=rows,
        backends=backends,
        errors=errors,
        message=message,
        aside='ocpi_wallboxes',
    )


def _parse_filter_set(param_name: str) -> set[str]:
    values = request.args.getlist(param_name)
    parsed: list[str] = []
    for raw in values:
        if not raw:
            continue
        parsed.extend([part.strip() for part in raw.split(",") if part.strip()])
    return {value for value in parsed if value}


def _normalize_capability_list(raw_caps: Any) -> list[str]:
    if isinstance(raw_caps, list):
        return [str(item).strip() for item in raw_caps if str(item).strip()]
    if isinstance(raw_caps, str):
        return [part.strip() for part in raw_caps.replace(";", ",").split(",") if part.strip()]
    return []


def _import_location_payloads(items: list[Any]) -> tuple[int, int, list[str]]:
    imported_locations = 0
    imported_evses = 0
    errors: list[str] = []

    def _normalize_tariffs(raw_tariff: Any) -> list[str]:
        if isinstance(raw_tariff, list):
            return [str(item) for item in raw_tariff if item not in (None, "")]
        if raw_tariff not in (None, ""):
            return [str(raw_tariff)]
        return []

    repo = _location_repo()
    for idx, item in enumerate(items):
        if not isinstance(item, Mapping):
            errors.append(translate_text("Entry {row}: Unexpected data type.", row=idx + 1))
            continue
        location_id = str(item.get("location_id") or item.get("id") or "").strip()
        evse_uid = str(item.get("evse_uid") or item.get("uid") or "").strip()
        if not location_id:
            errors.append(translate_text("Entry {row}: Missing required field \"{field}\".", row=idx + 1, field="location_id"))
            continue

        if evse_uid:
            payload = dict(item)
            payload.setdefault("uid", evse_uid)
            caps = _normalize_capability_list(item.get("capabilities"))
            tariffs = _normalize_tariffs(item.get("tariff_id") or item.get("tariff_ids"))
            if caps:
                payload["capabilities"] = caps
            if tariffs:
                payload["tariff_ids"] = tariffs
            repo.upsert_location_override(location_id, payload, evse_uid=evse_uid)
            imported_evses += 1
        else:
            payload = dict(item)
            payload.setdefault("id", location_id)
            caps = _normalize_capability_list(payload.get("capabilities"))
            if caps:
                payload["capabilities"] = caps
            tariffs = _normalize_tariffs(payload.get("tariff_id") or payload.get("tariff_ids"))
            if tariffs:
                payload["tariff_ids"] = tariffs
            repo.upsert_location_override(location_id, payload)
            imported_locations += 1
    return imported_locations, imported_evses, errors


def _import_tariff_payloads(items: list[Any]) -> tuple[int, list[str]]:
    service = get_tariff_service()
    imported = 0
    errors: list[str] = []
    for idx, item in enumerate(items):
        if not isinstance(item, Mapping):
            errors.append(translate_text("Entry {row}: Unexpected data type.", row=idx + 1))
            continue
        payload = dict(item)
        if isinstance(payload.get("elements"), str):
            try:
                payload["elements"] = json.loads(payload["elements"])
            except Exception:
                pass
        try:
            service.upsert_tariff(payload)
            imported += 1
        except Exception as exc:
            errors.append(str(exc))
    return imported, errors


def _import_token_payloads(items: list[Any]) -> tuple[int, list[str]]:
    service = get_token_service()
    normalized: list[Mapping[str, Any]] = []
    errors: list[str] = []
    for idx, item in enumerate(items):
        if not isinstance(item, Mapping):
            errors.append(translate_text("Entry {row}: Unexpected data type.", row=idx + 1))
            continue
        entry = dict(item)
        entry.setdefault("type", entry.get("type") or "RFID")
        normalized.append(entry)
    imported = service.upsert_tokens(normalized) if normalized else 0
    return imported, errors


def _import_cdr_payloads(items: list[Any]) -> tuple[int, list[str]]:
    try:
        conn = get_db_conn()
    except Exception:
        return 0, [translate_text("Database unavailable for import.")]

    errors: list[str] = []
    imported = 0
    try:
        _ensure_ocpi_export_table(conn)
        with conn.cursor() as cur:
            for idx, item in enumerate(items):
                if not isinstance(item, Mapping):
                    errors.append(translate_text("Entry {row}: Unexpected data type.", row=idx + 1))
                    continue
                transaction_id = (
                    item.get("transaction_id") or item.get("id") or item.get("cdr_id") or item.get("session_id")
                )
                station_id = item.get("station_id") or item.get("evse_uid") or item.get("location_id")
                cur.execute(
                    """
                    INSERT INTO op_ocpi_exports (station_id, transaction_id, payload, record_type, should_retry, success)
                    VALUES (%s, %s, %s, %s, %s, %s)
                    """,
                    (
                        station_id,
                        transaction_id,
                        json.dumps(item),
                        "cdr",
                        0,
                        None,
                    ),
                )
                imported += 1
        conn.commit()
    except Exception as exc:
        logger.debug("Failed to import CDR payload", exc_info=True)
        errors.append(str(exc))
    finally:
        try:
            conn.close()
        except Exception:
            pass
    return imported, errors


def _import_session_payloads(items: list[Any]) -> tuple[int, list[str]]:
    try:
        conn = get_db_conn()
    except Exception:
        return 0, [translate_text("Database unavailable for import.")]

    errors: list[str] = []
    imported = 0
    try:
        _ensure_ocpi_export_table(conn)
        with conn.cursor() as cur:
            for idx, item in enumerate(items):
                if not isinstance(item, Mapping):
                    errors.append(translate_text("Entry {row}: Unexpected data type.", row=idx + 1))
                    continue
                session_id = item.get("id") or item.get("session_id") or item.get("uid") or item.get("transaction_id")
                station_id = item.get("station_id") or item.get("location_id") or item.get("evse_uid")
                cur.execute(
                    """
                    INSERT INTO op_ocpi_exports (station_id, transaction_id, payload, record_type, should_retry, success)
                    VALUES (%s, %s, %s, %s, %s, %s)
                    """,
                    (
                        station_id,
                        session_id,
                        json.dumps(item),
                        "session",
                        0,
                        None,
                    ),
                )
                imported += 1
        conn.commit()
    except Exception as exc:
        logger.debug("Failed to import session payload", exc_info=True)
        errors.append(str(exc))
    finally:
        try:
            conn.close()
        except Exception:
            pass
    return imported, errors


def _import_ocpi_module_items(module: str, items: list[Any], version: str, *, dry_run: bool = False) -> tuple[dict[str, Any], int]:
    if module.lower() not in SUPPORTED_OCPI_MODULES:
        message = translate_text("Unsupported OCPI module: {module}", module=module)
        return {
            "status": "error",
            "errors": [message],
            "warnings": [],
            "validation": {"errors": [message], "warnings": [], "total": len(items), "version": version},
            "imported": {},
            "skipped": len(items),
            "version": version,
            "dry_run": dry_run,
        }, 400
    validation = _summarize_validation(module, version, items) if items else {"errors": [], "warnings": [], "issues": [], "has_errors": False, "version": version, "total": 0}
    issues = validation.get("issues", [])
    _audit_validation_issues(module, version, issues, context="dry_run_import" if dry_run else "import")
    invalid_rows = {issue.row for issue in issues if getattr(issue, "level", "") == "error" and issue.row}
    filtered_items = [item for idx, item in enumerate(items) if (idx + 1) not in invalid_rows]

    errors = list(validation.get("errors", []))
    warnings = list(validation.get("warnings", []))
    imported: dict[str, int] = {}
    status_code = 200
    module = module.lower()
    if dry_run:
        evse_count = sum(len((item.get("evses") or [])) for item in filtered_items if isinstance(item, Mapping)) if module == "locations" else 0
        if module == "locations":
            imported["locations"] = len(filtered_items)
            imported["evses"] = evse_count
        elif module == "tariffs":
            imported["tariffs"] = len(filtered_items)
        elif module == "tokens":
            imported["tokens"] = len(filtered_items)
        elif module == "sessions":
            imported["sessions"] = len(filtered_items)
        elif module == "cdrs":
            imported["cdrs"] = len(filtered_items)
    else:
        if module == "locations":
            loc_count, evse_count, import_errors = _import_location_payloads(filtered_items)
            imported["locations"] = loc_count
            imported["evses"] = evse_count
            errors.extend(import_errors)
        elif module == "tariffs":
            tariff_count, import_errors = _import_tariff_payloads(filtered_items)
            imported["tariffs"] = tariff_count
            errors.extend(import_errors)
        elif module == "tokens":
            token_count, import_errors = _import_token_payloads(filtered_items)
            imported["tokens"] = token_count
            errors.extend(import_errors)
        elif module == "sessions":
            session_count, import_errors = _import_session_payloads(filtered_items)
            imported["sessions"] = session_count
            errors.extend(import_errors)
        elif module == "cdrs":
            cdr_count, import_errors = _import_cdr_payloads(filtered_items)
            imported["cdrs"] = cdr_count
            errors.extend(import_errors)

    total_imported = sum(imported.values()) if imported else 0
    if (errors and total_imported == 0) or validation.get("has_errors"):
        status_code = 400
    status_label = "ok" if status_code == 200 else "partial"
    if dry_run and status_code == 200:
        status_label = "dry_run"
    result = {
        "status": status_label,
        "imported": imported,
        "errors": errors,
        "warnings": warnings,
        "validation": {
            "errors": validation.get("errors", []),
            "warnings": warnings,
            "total": validation.get("total", len(items)),
            "version": version,
            "issues": _serialize_issues(issues),
        },
        "skipped": len(items) - len(filtered_items),
        "version": version,
        "issues": _serialize_issues(issues),
        "dry_run": dry_run,
        "has_errors": bool(errors) or bool(validation.get("has_errors")),
    }
    return result, status_code


def _collect_location_data() -> dict[str, Any]:
    repo = _location_repo()
    records = repo.location_records()
    location_ids = [
        str((record.get("location") or {}).get("id") or record.get("station_id") or "")
        for record in records
    ]
    sync_map = repo.latest_sync_results(location_ids)
    tariff_pool = set(_available_tariff_ids())

    locations: list[dict[str, Any]] = []
    evses: list[dict[str, Any]] = []
    status_pool: set[str] = set()
    capability_pool: set[str] = set()
    for record in records:
        location = record.get("location") or {}
        loc_id = str(location.get("id") or record.get("station_id") or "").strip()
        evse_items = location.get("evses") or []
        backend_entries = record.get("backends") or []
        backend_names = [
            entry.get("name") or entry.get("url") or str(entry.get("backend_id") or "")
            for entry in backend_entries
            if isinstance(entry, Mapping)
        ]

        location_statuses: set[str] = set()
        location_capabilities: set[str] = set()
        location_tariffs: set[str] = set()

        loc_tariff_raw = location.get("tariff_id") or location.get("tariff_ids")
        if isinstance(loc_tariff_raw, list):
            location_tariffs.update({str(item) for item in loc_tariff_raw if item})
        elif loc_tariff_raw not in (None, ""):
            location_tariffs.add(str(loc_tariff_raw))

        for evse in evse_items:
            evse_uid = str(evse.get("uid") or evse.get("evse_id") or loc_id)
            status = str(evse.get("status") or "").upper()
            if status:
                status_pool.add(status)
                location_statuses.add(status)
            capabilities = _normalize_capability_list(evse.get("capabilities"))
            for cap_str in capabilities:
                capability_pool.add(cap_str)
                location_capabilities.add(cap_str)
            evse_tariffs: set[str] = set()
            evse_tariff_raw = evse.get("tariff_id") or evse.get("tariff_ids")
            if isinstance(evse_tariff_raw, list):
                evse_tariffs.update({str(item) for item in evse_tariff_raw if item})
            elif evse_tariff_raw not in (None, ""):
                evse_tariffs.add(str(evse_tariff_raw))
            for connector in evse.get("connectors") or []:
                tariff_value = connector.get("tariff_id") or connector.get("tariff_ids")
                if isinstance(tariff_value, list):
                    evse_tariffs.update({str(item) for item in tariff_value if item})
                elif tariff_value not in (None, ""):
                    evse_tariffs.add(str(tariff_value))
            if evse_tariffs:
                location_tariffs.update(evse_tariffs)

            evses.append(
                {
                    "location_id": loc_id,
                    "location_name": location.get("name") or loc_id,
                    "evse_uid": evse_uid,
                    "status": status or None,
                    "capabilities": capabilities,
                    "tariffs": sorted(evse_tariffs),
                    "connectors": evse.get("connectors") or [],
                    "sync_status": sync_map.get(loc_id, []),
                    "last_updated": evse.get("last_updated") or location.get("last_updated"),
                    "backends": backend_names,
                }
            )

        tariff_pool.update(location_tariffs)
        locations.append(
            {
                "id": loc_id,
                "name": location.get("name") or loc_id,
                "evses": evse_items,
                "evse_count": len(evse_items),
                "statuses": sorted(location_statuses),
                "capabilities": sorted(location_capabilities),
                "tariffs": sorted(location_tariffs),
                "last_updated": location.get("last_updated"),
                "backends": backend_names,
                "sync_status": sync_map.get(loc_id, []),
            }
        )

    return {
        "locations": locations,
        "evses": evses,
        "filters": {
            "statuses": sorted(status_pool),
            "capabilities": sorted(capability_pool),
            "tariffs": sorted(tariff_pool),
        },
    }


def _filter_location_entries(
    entries: list[Mapping[str, Any]],
    status_filter: set[str],
    capability_filter: set[str],
    tariff_filter: set[str],
) -> list[Mapping[str, Any]]:
    def matches(entry: Mapping[str, Any]) -> bool:
        if status_filter:
            if not {s.upper() for s in entry.get("statuses") or []}.intersection(status_filter):
                return False
        if capability_filter:
            if not set(entry.get("capabilities") or []).intersection(capability_filter):
                return False
        if tariff_filter:
            tariffs = {str(t) for t in entry.get("tariffs") or []}
            if not tariffs.intersection(tariff_filter):
                return False
        return True

    return [entry for entry in entries if matches(entry)]


def _filter_evse_entries(
    entries: list[Mapping[str, Any]],
    status_filter: set[str],
    capability_filter: set[str],
    tariff_filter: set[str],
) -> list[Mapping[str, Any]]:
    def matches(entry: Mapping[str, Any]) -> bool:
        if status_filter:
            status_value = str(entry.get("status") or "").upper()
            if not status_value or status_value not in status_filter:
                return False
        if capability_filter:
            if not set(entry.get("capabilities") or []).intersection(capability_filter):
                return False
        if tariff_filter:
            tariffs = {str(t) for t in entry.get("tariffs") or []}
            if not tariffs.intersection(tariff_filter):
                return False
        return True

    return [entry for entry in entries if matches(entry)]


# ---------------------------------------------------------------------------
# OCPI import/export helpers
# ---------------------------------------------------------------------------

OCPI_VERSION_OPTIONS = (DEFAULT_OCPI_VERSION, "2.3", "2.1.1")

_VALIDATION_MESSAGE_TEMPLATES: dict[str, str] = {
    "invalid_type": "Entry {row}: Unexpected data type ({detail}).",
    "missing_field": "Entry {row}: Missing required field \"{field}\".",
    "empty_list": "Entry {row}: Field \"{field}\" must not be empty.",
    "invalid_array": "Entry {row}: Field \"{field}\" must be an array.",
    "invalid_timestamp": "Entry {row}: Timestamp \"{field}\" is not a valid ISO 8601 value.",
    "missing_token_type": "Entry {row}: Token type missing, defaulting to RFID.",
    "unknown_status": "Entry {row}: Unknown status \"{detail}\".",
    "invalid_choice": "Entry {row}: Field \"{field}\" has unsupported value \"{detail}\".",
    "validated_version": "Validated {count} entries for OCPI {detail}.",
}


def _parse_uploaded_entries() -> tuple[list[Any], list[str], Any]:
    """Return uploaded items, a list of parse errors, and the raw JSON payload (if any)."""

    raw_body = request.get_data(as_text=True)
    content_type = (request.content_type or "").lower()
    errors: list[str] = []
    container: Any = None
    items: list[Any] = []
    if "csv" in content_type:
        reader = csv.DictReader(StringIO(raw_body))
        items = [row for row in reader]
    else:
        container = request.get_json(force=True, silent=True)
        if isinstance(container, list):
            items = container
        elif isinstance(container, Mapping):
            items = container.get("items") or []
    if not items:
        errors.append(translate_text("No entries found in upload."))
    return items, errors, container


def _ocpi_version_from_request(payload: Any = None) -> tuple[str | None, str | None]:
    raw_version = request.args.get("version") or request.form.get("version")
    if raw_version is None and isinstance(payload, Mapping):
        raw_version = payload.get("version")
    try:
        return normalize_version(raw_version, default=DEFAULT_OCPI_VERSION), None
    except ValueError as exc:
        return None, translate_text(str(exc))


def _should_dry_run(payload: Any = None) -> bool:
    raw_flag = request.args.get("dry_run") or request.form.get("dry_run")
    if raw_flag is None and isinstance(payload, Mapping):
        raw_flag = payload.get("dry_run")
    if isinstance(raw_flag, str):
        return raw_flag.strip().lower() in {"1", "true", "yes", "on"}
    return bool(raw_flag)


def _format_validation_issue(issue: ValidationIssue, *, total: int) -> str:
    template = _VALIDATION_MESSAGE_TEMPLATES.get(issue.code, "Entry {row}: {detail}")
    return translate_text(
        template,
        row=issue.row,
        field=issue.field or "",
        detail=issue.detail or "",
        count=total,
    )


def _serialize_issues(issues: Sequence[Any]) -> list[dict[str, Any]]:
    serialized: list[dict[str, Any]] = []
    for issue in issues:
        if isinstance(issue, Mapping):
            serialized.append(
                {
                    "row": issue.get("row", 0),
                    "level": issue.get("level"),
                    "code": issue.get("code"),
                    "field": issue.get("field"),
                    "detail": issue.get("detail"),
                    "version": issue.get("version"),
                }
            )
        else:
            serialized.append(
                {
                    "row": getattr(issue, "row", 0),
                    "level": getattr(issue, "level", None),
                    "code": getattr(issue, "code", None),
                    "field": getattr(issue, "field", None),
                    "detail": getattr(issue, "detail", None),
                    "version": getattr(issue, "version", None),
                }
            )
    return serialized


def _audit_validation_issues(module: str, version: str, issues: Sequence[Any], *, context: str) -> None:
    try:
        serialized = _serialize_issues(issues)
        error_items = [issue for issue in serialized if issue.get("level") == "error"]
        if not error_items:
            return
        audit_logger.info(
            json.dumps(
                {
                    "event": "ocpi_validation_error",
                    "context": context,
                    "module": module,
                    "version": version,
                    "errors": error_items,
                },
                ensure_ascii=False,
            )
        )
    except Exception:
        logger.debug("Failed to write validation audit log", exc_info=True)


def _summarize_validation(module: str, version: str, entries: Sequence[Any]) -> dict[str, Any]:
    issues = validate_payloads(module, version, entries)
    errors = [issue for issue in issues if issue.level == "error"]
    warnings = [issue for issue in issues if issue.level == "warning"]
    return {
        "errors": [_format_validation_issue(issue, total=len(entries)) for issue in errors],
        "warnings": [_format_validation_issue(issue, total=len(entries)) for issue in warnings],
        "has_errors": bool(errors),
        "total": len(entries),
        "version": version,
        "issues": issues,
    }


def _serialize_scalar(value: Any) -> Any:
    if isinstance(value, (dict, list)):
        try:
            return json.dumps(value, ensure_ascii=False)
        except Exception:
            return str(value)
    if value is None:
        return ""
    return value


def _csv_response(module: str, rows: list[Mapping[str, Any]]) -> Response:
    fieldnames: list[str] = []
    for row in rows:
        for key in row.keys():
            if key not in fieldnames:
                fieldnames.append(key)
    output = StringIO()
    writer = csv.DictWriter(output, fieldnames=fieldnames)
    writer.writeheader()
    for row in rows:
        writer.writerow({key: _serialize_scalar(row.get(key)) for key in fieldnames})
    output.seek(0)
    filename = f"ocpi_{module}_export.csv"
    return Response(
        output.read(),
        mimetype="text/csv",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


def _json_response(module: str, rows: list[Any]) -> Response:
    filename = f"ocpi_{module}_export.json"
    return Response(
        json.dumps(rows, ensure_ascii=False, indent=2),
        mimetype="application/json",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


def _ensure_ocpi_export_table(conn) -> None:
    with conn.cursor() as cur:
        cur.execute(
            """
            CREATE TABLE IF NOT EXISTS op_ocpi_exports (
                id INT AUTO_INCREMENT PRIMARY KEY,
                station_id VARCHAR(255),
                backend_id INT,
                backend_name VARCHAR(255),
                transaction_id VARCHAR(255),
                payload JSON,
                success TINYINT(1),
                response_status INT,
                response_body TEXT,
                retry_count INT DEFAULT 0,
                should_retry TINYINT(1) DEFAULT 0,
                record_type VARCHAR(32) DEFAULT 'cdr',
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
            """
        )
        cur.execute("SHOW COLUMNS FROM op_ocpi_exports LIKE 'backend_id'")
        if not cur.fetchone():
            cur.execute("ALTER TABLE op_ocpi_exports ADD COLUMN backend_id INT NULL AFTER station_id")
        cur.execute("SHOW COLUMNS FROM op_ocpi_exports LIKE 'backend_name'")
        if not cur.fetchone():
            cur.execute("ALTER TABLE op_ocpi_exports ADD COLUMN backend_name VARCHAR(255) NULL AFTER backend_id")
        cur.execute("SHOW COLUMNS FROM op_ocpi_exports LIKE 'retry_count'")
        if not cur.fetchone():
            cur.execute(
                "ALTER TABLE op_ocpi_exports ADD COLUMN retry_count INT NULL DEFAULT 0 AFTER response_body"
            )
        cur.execute("SHOW COLUMNS FROM op_ocpi_exports LIKE 'should_retry'")
        if not cur.fetchone():
            cur.execute(
                "ALTER TABLE op_ocpi_exports ADD COLUMN should_retry TINYINT(1) NULL DEFAULT 0 AFTER retry_count"
            )
        cur.execute("SHOW COLUMNS FROM op_ocpi_exports LIKE 'record_type'")
        if not cur.fetchone():
            cur.execute(
                "ALTER TABLE op_ocpi_exports ADD COLUMN record_type VARCHAR(32) NULL DEFAULT 'cdr' AFTER should_retry"
            )
    conn.commit()


def _cdr_payload_rows(limit: int = 500, *, record_type: str = "cdr") -> list[dict[str, Any]]:
    try:
        conn = get_db_conn()
    except Exception:
        return []
    try:
        _ensure_ocpi_export_table(conn)
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT payload
                FROM op_ocpi_exports
                WHERE record_type=%s
                ORDER BY created_at DESC
                LIMIT %s
                """,
                (record_type, max(1, limit)),
            )
            rows = cur.fetchall()
    except Exception:
        logger.debug("Failed to export CDR payloads", exc_info=True)
        return []
    finally:
        try:
            conn.close()
        except Exception:
            pass

    payloads: list[dict[str, Any]] = []
    for row in rows or []:
        payload = _parse_export_payload(row.get("payload"))
        if isinstance(payload, Mapping):
            payloads.append(dict(payload))
    return payloads


def _normalize_tariff_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
    data = dict(payload)
    if isinstance(data.get("elements"), str):
        try:
            data["elements"] = json.loads(data["elements"])
        except Exception:
            pass
    if not data.get("last_updated"):
        data["last_updated"] = ocpi_timestamp_str(None)
    data.setdefault("currency", "EUR")
    return data


def _normalize_token_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
    data = dict(payload)
    data.setdefault("type", data.get("type") or "RFID")
    if data.get("updated_at") and not data.get("last_updated"):
        data["last_updated"] = ocpi_timestamp_str(data.get("updated_at"))
    return data


def _collect_ocpi_export_rows(module: str, version: str) -> list[Mapping[str, Any]]:
    normalized_module = module.lower()
    if normalized_module == "locations":
        return _location_repo().list_locations()
    if normalized_module == "tariffs":
        tariffs, _ = get_tariff_service().list_tariffs(limit=5000)
        return [_normalize_tariff_payload(tariff) for tariff in tariffs]
    if normalized_module == "tokens":
        tokens, _ = get_token_service().list_tokens(limit=5000)
        return [_normalize_token_payload(token) for token in tokens]
    if normalized_module == "cdrs":
        return _cdr_payload_rows(limit=500, record_type="cdr")
    if normalized_module == "sessions":
        return _cdr_payload_rows(limit=500, record_type="session")
    return []


@app.route('/api/op_ocpi/<string:module>/export', methods=['GET'])
def api_export_ocpi(module: str):
    normalized_module = module.lower()
    if normalized_module not in SUPPORTED_OCPI_MODULES:
        return jsonify({"error": translate_text("Unsupported OCPI module: {module}", module=module)}), 400
    format_hint = (request.args.get("format") or "json").lower()
    version, version_error = _ocpi_version_from_request()
    if version_error:
        return jsonify({"error": version_error}), 400
    version = version or DEFAULT_OCPI_VERSION
    rows = _collect_ocpi_export_rows(normalized_module, version)
    if format_hint == "csv":
        return _csv_response(normalized_module, rows)
    return _json_response(normalized_module, rows)


@app.route('/api/op_ocpi/<string:module>/validate', methods=['POST'])
def api_validate_ocpi(module: str):
    normalized_module = module.lower()
    if normalized_module not in SUPPORTED_OCPI_MODULES:
        return jsonify({"error": translate_text("Unsupported OCPI module: {module}", module=module)}), 400

    items, parse_errors, payload = _parse_uploaded_entries()
    version, version_error = _ocpi_version_from_request(payload)
    version = version or DEFAULT_OCPI_VERSION

    try:
        validation = _summarize_validation(normalized_module, version, items) if items else {"errors": [], "warnings": [], "has_errors": False, "total": 0, "version": version, "issues": []}
    except ValueError as exc:
        validation = {"errors": [translate_text(str(exc))], "warnings": [], "has_errors": True, "total": len(items), "issues": []}

    errors = list(parse_errors)
    if version_error:
        errors.append(version_error)
    errors.extend(validation.get("errors", []))
    warnings = validation.get("warnings", [])
    serialized_issues = _serialize_issues(validation.get("issues", []))
    _audit_validation_issues(normalized_module, version, validation.get("issues", []), context="validate")
    status_code = 400 if errors else 200
    return jsonify({"module": normalized_module, "version": version, "errors": errors, "warnings": warnings, "total": len(items), "issues": serialized_issues, "has_errors": bool(errors)}), status_code


@app.route('/api/op_ocpi/<string:module>/import', methods=['POST'])
def api_import_ocpi(module: str):
    normalized_module = module.lower()
    if normalized_module not in SUPPORTED_OCPI_MODULES:
        return jsonify({"error": translate_text("Unsupported OCPI module: {module}", module=module)}), 400

    items, parse_errors, payload = _parse_uploaded_entries()
    version, version_error = _ocpi_version_from_request(payload)
    dry_run = _should_dry_run(payload)
    base_errors = list(parse_errors)
    if version_error:
        base_errors.append(version_error)
    version = version or DEFAULT_OCPI_VERSION
    result, status_code = _import_ocpi_module_items(normalized_module, items if not parse_errors else [], version, dry_run=dry_run)
    if base_errors:
        result["errors"] = base_errors + result.get("errors", [])
        if status_code == 200:
            status_code = 400
    return jsonify(result), status_code


def _sandbox_samples() -> dict[str, Any]:
    now_iso = ocpi_timestamp_str(None)
    return {
        "version": "2.2",
        "locations": [
            {
                "id": "DE*PIP*SANDBOX",
                "name": "Sandbox Location",
                "last_updated": now_iso,
                "evses": [
                    {
                        "uid": "EVSE-SANDBOX-1",
                        "evse_id": "DE*PIP*SANDBOX*E1",
                        "status": "AVAILABLE",
                        "last_updated": now_iso,
                        "connectors": [
                            {
                                "id": "1",
                                "standard": "IEC_62196_T2",
                                "format": "SOCKET",
                                "power_type": "AC_3_PHASE",
                                "max_voltage": 400,
                                "max_amperage": 32,
                            }
                        ],
                    }
                ],
            }
        ],
        "tariffs": [
            {
                "id": "SAND-AC-01",
                "currency": "EUR",
                "elements": [
                    {"price_components": [{"type": "ENERGY", "price": 0.35, "vat": 19.0, "step_size": 1}]}
                ],
                "last_updated": now_iso,
            }
        ],
        "tokens": [
            {
                "uid": "SANDBOXRFID01",
                "type": "RFID",
                "auth_id": "DE-SBX-001",
                "issuer": "Sandbox Partner",
                "valid": True,
                "whitelist": "ALLOWED",
                "status": "valid",
                "last_updated": now_iso,
            }
        ],
        "sessions": [
            {
                "id": "SANDBOX-SESSION-1",
                "start_date_time": now_iso,
                "last_updated": now_iso,
                "kwh": 12.5,
                "location_id": "DE*PIP*SANDBOX",
                "evse_uid": "EVSE-SANDBOX-1",
                "connector_id": "1",
                "status": "COMPLETED",
            }
        ],
        "cdrs": [
            {
                "id": "SANDBOX-CDR-1",
                "start_date_time": now_iso,
                "stop_date_time": now_iso,
                "total_energy": 10.5,
                "total_time": 3600,
                "total_cost": {"incl_vat": 5.25, "excl_vat": 4.41},
                "cdr_token": {
                    "country_code": "DE",
                    "party_id": "SBX",
                    "uid": "SANDBOXRFID01",
                    "type": "RFID",
                },
                "auth_id": "DE-SBX-001",
                "auth_method": "AUTH_REQUEST",
                "last_updated": now_iso,
                "charging_periods": [
                    {
                        "start_date_time": now_iso,
                        "dimensions": [
                            {"type": "ENERGY", "volume": 10.5},
                            {"type": "TIME", "volume": 1.0},
                        ],
                    }
                ],
            }
        ],
    }


@app.route('/api/op_ocpi/sandbox_samples', methods=['GET', 'POST'])
def api_ocpi_sandbox_samples():
    samples = _sandbox_samples()
    if request.method == 'POST':
        applied: dict[str, Any] = {}
        errors: list[str] = []
        for module in ("locations", "tariffs", "tokens", "sessions", "cdrs"):
            result, _status = _import_ocpi_module_items(module, samples.get(module, []), samples.get("version", DEFAULT_OCPI_VERSION))
            applied[module] = result.get("imported", {})
            errors.extend(result.get("errors", []))
        status_code = 200 if not errors else 400
        return jsonify(
            {
                "samples": samples,
                "imported": applied,
                "errors": errors,
                "status": "ok" if status_code == 200 else "error",
            }
        ), status_code
    return jsonify(samples)


@app.route('/api/op_locations', methods=['GET'])
def api_list_locations():
    dataset = _collect_location_data()
    filters = dataset["filters"]
    status_filter = {item.upper() for item in _parse_filter_set("status")}
    capability_filter = _parse_filter_set("capability")
    tariff_filter = _parse_filter_set("tariff")

    filtered_locations = _filter_location_entries(
        dataset["locations"],
        status_filter,
        capability_filter,
        tariff_filter,
    )
    return jsonify({"data": filtered_locations, "filters": filters})


@app.route('/api/op_evses', methods=['GET'])
def api_list_evses():
    dataset = _collect_location_data()
    filters = dataset["filters"]
    status_filter = {item.upper() for item in _parse_filter_set("status")}
    capability_filter = _parse_filter_set("capability")
    tariff_filter = _parse_filter_set("tariff")

    filtered_evses = _filter_evse_entries(
        dataset["evses"], status_filter, capability_filter, tariff_filter
    )
    return jsonify({"data": filtered_evses, "filters": filters})


@app.route('/api/op_locations/<string:location_id>', methods=['PUT', 'DELETE'])
def api_location_detail(location_id: str):
    repo = _location_repo()
    if request.method == 'DELETE':
        repo.delete_location_override(location_id)
        _record_admin_audit(
            "location_deleted",
            "location",
            location_id,
            {},
        )
        return jsonify({"status": "ok", "location_id": location_id})

    payload = request.get_json(force=True, silent=True)
    if not isinstance(payload, Mapping):
        return jsonify({"error": "JSON body is required"}), 400
    body = dict(payload)
    body.setdefault("id", location_id)
    repo.upsert_location_override(location_id, body)
    _record_admin_audit(
        "location_upserted",
        "location",
        location_id,
        {"payload": body},
    )
    return jsonify({"status": "ok", "location": body})


@app.route('/api/op_evses/<string:location_id>/<string:evse_uid>', methods=['PUT', 'DELETE'])
def api_evse_detail(location_id: str, evse_uid: str):
    repo = _location_repo()
    if request.method == 'DELETE':
        repo.delete_evse_override(location_id, evse_uid)
        _record_admin_audit(
            "evse_deleted",
            "evse",
            f"{location_id}:{evse_uid}",
            {},
        )
        return jsonify({"status": "ok", "location_id": location_id, "evse_uid": evse_uid})

    payload = request.get_json(force=True, silent=True)
    if not isinstance(payload, Mapping):
        return jsonify({"error": "JSON body is required"}), 400
    body = dict(payload)
    body.setdefault("uid", evse_uid)
    repo.upsert_location_override(location_id, body, evse_uid=evse_uid)
    _record_admin_audit(
        "evse_upserted",
        "evse",
        f"{location_id}:{evse_uid}",
        {"payload": body},
    )
    return jsonify({"status": "ok", "location_id": location_id, "evse_uid": evse_uid, "evse": body})


@app.route('/api/op_locations/import', methods=['POST'])
def import_locations():
    items, parse_errors, payload = _parse_uploaded_entries()
    version, version_error = _ocpi_version_from_request(payload)
    dry_run = _should_dry_run(payload)
    base_errors = list(parse_errors)
    if version_error:
        base_errors.append(version_error)
    version = version or DEFAULT_OCPI_VERSION

    result, status_code = _import_ocpi_module_items("locations", items if not parse_errors else [], version, dry_run=dry_run)
    if base_errors:
        result["errors"] = base_errors + result.get("errors", [])
        if status_code == 200:
            status_code = 400

    try:
        imported_data = result.get("imported", {})
        _record_admin_audit(
            "locations_import",
            "locations",
            None,
            {
                "imported_locations": imported_data.get("locations", 0),
                "imported_evses": imported_data.get("evses", 0),
                "errors": (result.get("errors") or [])[:5],
                "version": version,
                "dry_run": dry_run,
            },
        )
    except Exception:
        logger.debug("Audit trail for location import failed", exc_info=True)
    return jsonify(result), status_code


@app.route('/op_locations', methods=['GET'])
def locations_view():
    dataset = _collect_location_data()
    status_filter = {item.upper() for item in _parse_filter_set("status")}
    capability_filter = _parse_filter_set("capability")
    tariff_filter = _parse_filter_set("tariff")
    locations = _filter_location_entries(
        dataset["locations"],
        status_filter,
        capability_filter,
        tariff_filter,
    )
    selected_filters = {
        "status": sorted(status_filter),
        "capability": sorted(capability_filter),
        "tariff": sorted(tariff_filter),
    }
    return render_template(
        'op_locations.html',
        locations=locations,
        filters=dataset["filters"],
        selected_filters=selected_filters,
        tariffs=_load_ocpi_tariffs(),
        import_url=url_for('import_locations'),
        validate_url=url_for('api_validate_ocpi', module='locations'),
        export_links={
            "json": url_for('api_export_ocpi', module='locations', format='json'),
            "csv": url_for('api_export_ocpi', module='locations', format='csv'),
        },
        version_options=OCPI_VERSION_OPTIONS,
        sandbox_url=url_for('api_ocpi_sandbox_samples'),
        aside='ocpi_locations',
    )


@app.route('/op_charging_points', methods=['GET'])
def charging_points_view():
    dataset = _collect_location_data()
    status_filter = {item.upper() for item in _parse_filter_set("status")}
    capability_filter = _parse_filter_set("capability")
    tariff_filter = _parse_filter_set("tariff")
    evses = _filter_evse_entries(
        dataset["evses"],
        status_filter,
        capability_filter,
        tariff_filter,
    )
    selected_filters = {
        "status": sorted(status_filter),
        "capability": sorted(capability_filter),
        "tariff": sorted(tariff_filter),
    }
    return render_template(
        'op_charging_points.html',
        evses=evses,
        filters=dataset["filters"],
        selected_filters=selected_filters,
        tariffs=_load_ocpi_tariffs(),
        import_url=url_for('import_locations'),
        aside='ocpi_evses',
    )


@app.route('/op_rfid_mapping', methods=['GET', 'POST'])
def rfid_mapping_management():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            if request.method == 'POST':
                action = request.form.get('action')
                freecharging = 1 if request.form.get('freecharging') == '1' else 0
                single_uuid = request.form.get('single_uuid', '').strip().upper()
                if action == 'add':
                    cur.execute(
                        """
                        INSERT INTO op_rfid_mapping (chargepoint_id, rfid_list, single_uuid, freecharging)
                        VALUES (%s, %s, %s, %s)
                        """,
                        (
                            request.form.get('chargepoint_id', ''),
                            request.form.get('rfid_list', ''),
                            single_uuid,
                            freecharging,
                        ),
                    )
                elif action == 'update':
                    cur.execute(
                        """
                        UPDATE op_rfid_mapping
                        SET chargepoint_id=%s, rfid_list=%s, single_uuid=%s, freecharging=%s
                        WHERE id=%s
                        """,
                        (
                            request.form.get('chargepoint_id', ''),
                            request.form.get('rfid_list', ''),
                            single_uuid,
                            freecharging,
                            request.form.get('id'),
                        ),
                    )
                elif action == 'delete':
                    cur.execute(
                        "DELETE FROM op_rfid_mapping WHERE id=%s",
                        (request.form.get('id'),),
                    )
                conn.commit()
            cur.execute(
                "SELECT id, chargepoint_id, rfid_list, single_uuid, freecharging FROM op_rfid_mapping ORDER BY id"
            )
            rows = cur.fetchall()
            cur.execute(
                "SELECT DISTINCT rfid_list_id FROM op_server_rfid_lists ORDER BY rfid_list_id"
            )
            lists = [r['rfid_list_id'] for r in cur.fetchall()]
    finally:
        conn.close()

    return render_template('op_rfid_mapping.html', rows=rows, lists=lists)


@app.route('/op_rfid_free', methods=['GET', 'POST'])
def rfid_free_management():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            if request.method == 'POST':
                action = request.form.get('action')
                if action == 'add':
                    cur.execute(
                        "INSERT INTO op_rfid_free (chargepoint_id) VALUES (%s)",
                        (request.form.get('chargepoint_id', ''),),
                    )
                elif action == 'update':
                    cur.execute(
                        "UPDATE op_rfid_free SET chargepoint_id=%s WHERE chargepoint_id=%s",
                        (
                            request.form.get('chargepoint_id', ''),
                            request.form.get('original_chargepoint_id'),
                        ),
                    )
                elif action == 'delete':
                    cur.execute(
                        "DELETE FROM op_rfid_free WHERE chargepoint_id=%s",
                        (request.form.get('chargepoint_id'),),
                    )
                conn.commit()
            cur.execute(
                "SELECT chargepoint_id FROM op_rfid_free ORDER BY chargepoint_id",
            )
            rows = cur.fetchall()
    finally:
        conn.close()

    return render_template('op_rfid_free.html', rows=rows)


@app.route('/op_server_rfid_global', methods=['GET', 'POST'])
def rfid_global_management():
    if request.method == 'GET' and request.args.get('download') == 'csv':
        conn = get_db_conn()
        try:
            with conn.cursor() as cur:
                cur.execute("SELECT uuid, status FROM op_server_rfid_global ORDER BY uuid")
                rows = cur.fetchall()
        finally:
            conn.close()

        output = StringIO()
        writer = csv.writer(output)
        writer.writerow(['uuid', 'status'])
        for row in rows:
            writer.writerow([row.get('uuid') or '', row.get('status') or 'accepted'])
        output.seek(0)
        response = Response(output.getvalue(), mimetype='text/csv')
        response.headers['Content-Disposition'] = 'attachment; filename=op_server_rfid_global.csv'
        return response

    success_messages: list[str] = []
    error_messages: list[str] = []

    conn = get_db_conn()
    try:
        if request.method == 'POST':
            def _normalize_global_status(raw_status: str | None) -> str:
                value = (raw_status or 'accepted').strip().lower() or 'accepted'
                return value if value in {'accepted', 'blocked'} else 'accepted'

            action = (request.form.get('action') or '').strip()
            try:
                if action == 'add':
                    uuid_value = (request.form.get('uuid') or '').strip()
                    if not uuid_value:
                        raise ValueError('Bitte eine UUID angeben.')
                    status = _normalize_global_status(request.form.get('status'))
                    try:
                        with conn.cursor() as cur:
                            cur.execute(
                                "INSERT INTO op_server_rfid_global (uuid, status) VALUES (%s, %s)",
                                (uuid_value, status),
                            )
                    except pymysql.err.IntegrityError as exc:
                        raise ValueError('UUID existiert bereits.') from exc
                    conn.commit()
                    success_messages.append(f'UUID {uuid_value} wurde hinzugefügt.')
                elif action == 'update':
                    uuid_value = (request.form.get('uuid') or '').strip()
                    original_uuid = (request.form.get('original_uuid') or '').strip()
                    if not uuid_value or not original_uuid:
                        raise ValueError('Ungültige UUID für die Aktualisierung.')
                    status = _normalize_global_status(request.form.get('status'))
                    try:
                        with conn.cursor() as cur:
                            cur.execute(
                                "UPDATE op_server_rfid_global SET uuid=%s, status=%s WHERE uuid=%s",
                                (
                                    uuid_value,
                                    status,
                                    original_uuid,
                                ),
                            )
                            if cur.rowcount == 0:
                                raise ValueError('Eintrag wurde nicht gefunden.')
                    except pymysql.err.IntegrityError as exc:
                        raise ValueError('UUID existiert bereits.') from exc
                    conn.commit()
                    success_messages.append(f'UUID {uuid_value} wurde aktualisiert.')
                elif action == 'delete':
                    uuid_value = (request.form.get('uuid') or '').strip()
                    if not uuid_value:
                        raise ValueError('Ungültige UUID für die Löschung.')
                    with conn.cursor() as cur:
                        cur.execute(
                            "DELETE FROM op_server_rfid_global WHERE uuid=%s",
                            (uuid_value,),
                        )
                        if cur.rowcount == 0:
                            raise ValueError('Eintrag wurde nicht gefunden.')
                    conn.commit()
                    success_messages.append(f'UUID {uuid_value} wurde gelöscht.')
                elif action == 'upload_csv':
                    upload_file = request.files.get('csv_file')
                    if upload_file is None or not upload_file.filename:
                        raise ValueError('Bitte eine CSV-Datei auswählen.')
                    file_content = upload_file.read()
                    if not file_content:
                        raise ValueError('Die hochgeladene Datei ist leer.')
                    try:
                        text_content = file_content.decode('utf-8-sig')
                    except UnicodeDecodeError as exc:
                        raise ValueError('Die CSV-Datei muss im UTF-8-Format vorliegen.') from exc

                    reader = csv.DictReader(StringIO(text_content))
                    if not reader.fieldnames:
                        raise ValueError('Die CSV-Datei enthält keine Kopfzeile.')
                    normalized_header = {name.strip().lower() for name in reader.fieldnames if name}
                    if 'uuid' not in normalized_header:
                        raise ValueError('Die CSV-Datei muss die Spalte uuid enthalten.')

                    entries: list[tuple[str, str]] = []
                    seen: set[str] = set()
                    for line_number, row in enumerate(reader, start=2):
                        uuid_value = (row.get('uuid') or '').strip()
                        if not uuid_value:
                            raise ValueError(f'Zeile {line_number}: UUID fehlt.')
                        uuid_key = uuid_value.upper()
                        if uuid_key in seen:
                            raise ValueError(
                                f'Zeile {line_number}: UUID {uuid_value} ist mehrfach vorhanden.'
                            )
                        seen.add(uuid_key)
                        status = _normalize_global_status(row.get('status'))
                        entries.append((uuid_value, status))

                    with conn.cursor() as cur:
                        if entries:
                            cur.executemany(
                                """
                                INSERT INTO op_server_rfid_global (uuid, status)
                                VALUES (%s, %s)
                                ON DUPLICATE KEY UPDATE status=VALUES(status)
                                """,
                                entries,
                            )
                    conn.commit()
                    success_messages.append(
                        f'CSV-Import abgeschlossen. {len(entries)} Einträge wurden übernommen.'
                    )
                elif action == 'dedupe':
                    with conn.cursor() as cur:
                        cur.execute("SELECT uuid, status FROM op_server_rfid_global")
                        rows = cur.fetchall()
                    if not rows:
                        success_messages.append('Keine Einträge zum Bereinigen gefunden.')
                    else:
                        canonical: dict[str, dict[str, str]] = {}
                        for row in rows:
                            uuid_value = (row.get('uuid') or '').strip()
                            if not uuid_value:
                                continue
                            normalized = uuid_value.upper()
                            status = _normalize_global_status(row.get('status'))
                            existing = canonical.get(normalized)
                            if existing is None:
                                canonical[normalized] = {'uuid': uuid_value, 'status': status}
                            else:
                                if status == 'blocked' and existing['status'] != 'blocked':
                                    existing['status'] = 'blocked'
                        unique_entries = list(canonical.values())
                        duplicates_removed = max(0, len(rows) - len(unique_entries))
                        if duplicates_removed > 0:
                            with conn.cursor() as cur:
                                cur.execute("DELETE FROM op_server_rfid_global")
                                cur.executemany(
                                    "INSERT INTO op_server_rfid_global (uuid, status) VALUES (%s, %s)",
                                    [(entry['uuid'], entry['status']) for entry in unique_entries],
                                )
                            conn.commit()
                            success_messages.append(
                                f'Dubletten entfernt. {duplicates_removed} Einträge gelöscht, '
                                f'{len(unique_entries)} eindeutige Einträge bleiben erhalten.'
                            )
                        else:
                            success_messages.append('Keine Dubletten gefunden.')
                else:
                    raise ValueError('Unbekannte Aktion.')
            except ValueError as exc:
                conn.rollback()
                error_messages.append(str(exc))
            except Exception as exc:
                conn.rollback()
                logging.exception('Fehler bei der Verwaltung der globalen RFID-Liste')
                error_messages.append('Unerwarteter Fehler beim Verarbeiten der Anfrage.')

        with conn.cursor() as cur:
            cur.execute("SELECT uuid, status FROM op_server_rfid_global ORDER BY uuid")
            rows = cur.fetchall()
    finally:
        conn.close()

    return render_template(
        'op_server_rfid_global.html',
        rows=rows,
        success_messages=success_messages,
        error_messages=error_messages,
    )


@app.route('/op_vouchers', methods=['GET', 'POST'])
def manage_vouchers():
    success_messages: list[str] = []
    error_messages: list[str] = []
    vouchers: list[dict[str, Any]] = []

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            if request.method == 'POST':
                action = (request.form.get('action') or '').strip()
                try:
                    if action in {'add', 'update'}:
                        (
                            rfid_uuid,
                            energy_kwh,
                            valid_until,
                            allowed_value,
                            voucher_id,
                        ) = _parse_voucher_payload(request.form, require_id=action == 'update')

                        if action == 'add':
                            cur.execute(
                                """
                                INSERT INTO op_vouchers (rfid_uuid, energy_kwh, valid_until, allowed_chargepoints)
                                VALUES (%s, %s, %s, %s)
                                """,
                                (rfid_uuid, str(energy_kwh), valid_until, allowed_value),
                            )
                            success_messages.append('Voucher gespeichert.')
                        else:
                            cur.execute(
                                """
                                UPDATE op_vouchers
                                SET rfid_uuid=%s, energy_kwh=%s, valid_until=%s, allowed_chargepoints=%s
                                WHERE id=%s
                                """,
                                (
                                    rfid_uuid,
                                    str(energy_kwh),
                                    valid_until,
                                    allowed_value,
                                    voucher_id,
                                ),
                            )
                            if cur.rowcount == 0:
                                raise ValueError('Voucher wurde nicht gefunden.')
                            success_messages.append('Voucher aktualisiert.')
                    elif action == 'delete':
                        voucher_id = request.form.get('voucher_id')
                        if not voucher_id:
                            raise ValueError('Ungültige Voucher-ID.')
                        cur.execute(
                            "DELETE FROM op_vouchers WHERE id=%s",
                            (voucher_id,),
                        )
                        success_messages.append('Voucher gelöscht.')
                except Exception as exc:  # pragma: no cover - defensive logging only
                    conn.rollback()
                    error_messages.append(str(exc))
                else:
                    conn.commit()

        vouchers = _load_vouchers(conn)
    finally:
        conn.close()

    normalized_vouchers = [
        _serialize_voucher_row(row, for_api=False)
        for row in vouchers
    ]

    return render_template(
        'op_vouchers.html',
        vouchers=normalized_vouchers,
        success_messages=success_messages,
        error_messages=error_messages,
        aside='miniCpmsMenu',
    )


@app.route('/api/op_vouchers', methods=['GET'])
def api_list_vouchers():
    """Return one or multiple vouchers as JSON."""

    if not _is_api_authorized():
        return _unauthorized_response()

    voucher_id_param = request.args.get('id')
    voucher_id: int | None = None
    if voucher_id_param is not None:
        try:
            voucher_id = int(voucher_id_param)
        except (TypeError, ValueError):
            return jsonify({'error': 'id must be an integer'}), 400

    conn = get_db_conn()
    try:
        vouchers = _load_vouchers(conn, voucher_id=voucher_id)
    finally:
        conn.close()

    if voucher_id is not None and not vouchers:
        return jsonify({'error': 'not found'}), 404

    serialized = [
        _serialize_voucher_row(row, for_api=True) for row in vouchers
    ]
    if voucher_id is not None:
        return jsonify(serialized[0])
    return jsonify(serialized)


@app.route('/api/op_vouchers', methods=['POST'])
def api_create_voucher():
    if not _is_api_authorized():
        return _unauthorized_response()

    payload = request.get_json(silent=True) or {}
    if not isinstance(payload, Mapping):
        return jsonify({'error': 'invalid payload'}), 400

    try:
        rfid_uuid, energy_kwh, valid_until, allowed_value, _ = _parse_voucher_payload(
            payload
        )
    except ValueError as exc:
        return jsonify({'error': str(exc)}), 400

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            try:
                cur.execute(
                    """
                    INSERT INTO op_vouchers (rfid_uuid, energy_kwh, valid_until, allowed_chargepoints)
                    VALUES (%s, %s, %s, %s)
                    """,
                    (rfid_uuid, str(energy_kwh), valid_until, allowed_value),
                )
            except pymysql.err.IntegrityError:
                conn.rollback()
                return jsonify({'error': 'rfid_uuid already exists'}), 409

            voucher_id = cur.lastrowid
            conn.commit()

        vouchers = _load_vouchers(conn, voucher_id=voucher_id)
    finally:
        conn.close()

    if not vouchers:
        return jsonify({'error': 'not found'}), 404

    return jsonify(_serialize_voucher_row(vouchers[0], for_api=True)), 201


@app.route('/api/op_vouchers', methods=['PUT', 'PATCH'])
def api_update_voucher():
    if not _is_api_authorized():
        return _unauthorized_response()

    payload = request.get_json(silent=True) or {}
    if not isinstance(payload, Mapping):
        return jsonify({'error': 'invalid payload'}), 400

    try:
        (
            rfid_uuid,
            energy_kwh,
            valid_until,
            allowed_value,
            voucher_id,
        ) = _parse_voucher_payload(payload, require_id=True)
    except ValueError as exc:
        return jsonify({'error': str(exc)}), 400

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            try:
                cur.execute(
                    """
                    UPDATE op_vouchers
                    SET rfid_uuid=%s, energy_kwh=%s, valid_until=%s, allowed_chargepoints=%s
                    WHERE id=%s
                    """,
                    (rfid_uuid, str(energy_kwh), valid_until, allowed_value, voucher_id),
                )
            except pymysql.err.IntegrityError:
                conn.rollback()
                return jsonify({'error': 'rfid_uuid already exists'}), 409

            if cur.rowcount == 0:
                conn.rollback()
                return jsonify({'error': 'not found'}), 404
            conn.commit()

        vouchers = _load_vouchers(conn, voucher_id=voucher_id)
    finally:
        conn.close()

    if not vouchers:
        return jsonify({'error': 'not found'}), 404

    return jsonify(_serialize_voucher_row(vouchers[0], for_api=True))


@app.route('/api/op_vouchers', methods=['DELETE'])
def api_delete_voucher():
    if not _is_api_authorized():
        return _unauthorized_response()

    payload = request.get_json(silent=True) or {}
    if not isinstance(payload, Mapping):
        return jsonify({'error': 'invalid payload'}), 400

    try:
        voucher_id = int(payload.get('id') or payload.get('voucher_id'))
    except (TypeError, ValueError):
        return jsonify({'error': 'Ungültige Voucher-ID.'}), 400

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                "DELETE FROM op_vouchers WHERE id=%s",
                (voucher_id,),
            )
            if cur.rowcount == 0:
                return jsonify({'error': 'not found'}), 404
            conn.commit()
    finally:
        conn.close()

    return jsonify({'status': 'deleted', 'id': voucher_id})


@app.route('/api/tariffs', methods=['GET'])
def api_list_tariffs():
    if not _is_api_authorized():
        return _unauthorized_response()

    tariff_id = request.args.get('id') or request.args.get('tariff_id')
    service = get_tariff_service()
    if tariff_id:
        tariff = service.get_tariff(tariff_id)
        if not tariff:
            return jsonify({'error': 'not found'}), 404
        return jsonify(tariff)

    tariffs, total = service.list_tariffs(limit=500)
    return jsonify({'total': total, 'tariffs': tariffs})


@app.route('/api/tariffs', methods=['POST'])
def api_create_tariff():
    if not _is_api_authorized():
        return _unauthorized_response()

    payload = request.get_json(silent=True) or {}
    if not isinstance(payload, Mapping):
        return jsonify({'error': 'invalid payload'}), 400
    try:
        result = get_tariff_service().upsert_tariff(payload)
    except ValueError as exc:
        return jsonify({'error': str(exc)}), 400
    return jsonify(result), 201


@app.route('/api/tariffs', methods=['PUT', 'PATCH'])
def api_update_tariff():
    if not _is_api_authorized():
        return _unauthorized_response()

    payload = request.get_json(silent=True) or {}
    if not isinstance(payload, Mapping):
        return jsonify({'error': 'invalid payload'}), 400
    try:
        result = get_tariff_service().upsert_tariff(payload)
    except ValueError as exc:
        return jsonify({'error': str(exc)}), 400
    return jsonify(result)


@app.route('/api/tariffs', methods=['DELETE'])
def api_delete_tariff():
    if not _is_api_authorized():
        return _unauthorized_response()

    payload = request.get_json(silent=True) or {}
    if not isinstance(payload, Mapping):
        return jsonify({'error': 'invalid payload'}), 400
    tariff_id = (payload.get('id') or payload.get('tariff_id') or '').strip()
    if not tariff_id:
        return jsonify({'error': 'Tariff ID is required.'}), 400
    try:
        removed = get_tariff_service().delete_tariff(tariff_id)
    except ValueError as exc:
        return jsonify({'error': str(exc)}), 400
    if not removed:
        return jsonify({'error': 'not found'}), 404
    return jsonify({'status': 'deleted', 'id': tariff_id})


@app.route('/api/tariffs/assignments', methods=['GET'])
def api_list_tariff_assignments():
    if not _is_api_authorized():
        return _unauthorized_response()
    tariff_id = request.args.get('tariff_id') or request.args.get('id')
    assignments = get_tariff_service().list_assignments(tariff_id=tariff_id)
    return jsonify(assignments)


@app.route('/api/tariffs/assignments', methods=['POST'])
def api_assign_tariff():
    if not _is_api_authorized():
        return _unauthorized_response()
    payload = request.get_json(silent=True) or {}
    if not isinstance(payload, Mapping):
        return jsonify({'error': 'invalid payload'}), 400
    try:
        get_tariff_service().assign_tariff(
            payload.get('tariff_id') or payload.get('id'),
            location_id=payload.get('location_id') or '',
            evse_uid=payload.get('evse_uid'),
        )
    except ValueError as exc:
        return jsonify({'error': str(exc)}), 400
    return jsonify({'status': 'saved'})


@app.route('/api/tariffs/assignments', methods=['DELETE'])
def api_delete_tariff_assignment():
    if not _is_api_authorized():
        return _unauthorized_response()
    payload = request.get_json(silent=True) or {}
    if not isinstance(payload, Mapping):
        return jsonify({'error': 'invalid payload'}), 400
    tariff_id = payload.get('tariff_id') or payload.get('id') or ''
    location_id = payload.get('location_id') or ''
    evse_uid = payload.get('evse_uid') or None
    try:
        get_tariff_service().remove_assignment(
            tariff_id,
            location_id=location_id,
            evse_uid=evse_uid,
        )
    except ValueError as exc:
        return jsonify({'error': str(exc)}), 400
    return jsonify({'status': 'deleted'})


@app.route('/op_targets', methods=['GET', 'POST'])
def manage_targets():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            if request.method == 'POST':
                action = request.form.get('action')
                if action == 'add':
                    cur.execute(
                        "INSERT INTO op_targets (short_name, ws_url) VALUES (%s, %s)",
                        (
                            request.form.get('short_name', ''),
                            request.form.get('ws_url', ''),
                        ),
                    )
                elif action == 'update':
                    cur.execute(
                        "UPDATE op_targets SET short_name=%s, ws_url=%s WHERE id=%s",
                        (
                            request.form.get('short_name', ''),
                            request.form.get('ws_url', ''),
                            request.form.get('id'),
                        ),
                    )
                elif action == 'delete':
                    cur.execute(
                        "DELETE FROM op_targets WHERE id=%s",
                        (request.form.get('id'),),
                    )
                conn.commit()
            cur.execute("SELECT id, short_name, ws_url FROM op_targets ORDER BY id")
            rows = cur.fetchall()
    finally:
        conn.close()

    return render_template('op_targets.html', rows=rows)


@app.route('/op_broker_instances', methods=['GET', 'POST'])
def manage_broker_instances():
    error_message = None
    success_message = None

    conn = get_db_conn()
    try:
        ensure_broker_instances_table(conn)

        if request.method == 'POST':
            action = request.form.get('action')
            try:
                if action == 'add':
                    name = (request.form.get('name') or '').strip()
                    base_url = _normalize_broker_base_url(request.form.get('base_url', ''))
                    ocpp_port = _normalize_broker_port(
                        request.form.get('ocpp_port'),
                        default=_proxy_port or _DEFAULT_PROXY_PORT,
                        field_label=translate_text("OCPP port"),
                    )
                    api_port = _normalize_broker_port(
                        request.form.get('api_port'),
                        default=_proxy_api_port or _DEFAULT_PROXY_API_PORT,
                        field_label=translate_text("API port"),
                    )
                    if not name:
                        name = base_url
                    with conn.cursor() as cur:
                        cur.execute(
                            "INSERT INTO op_broker_instances (name, base_url, ocpp_port, api_port) VALUES (%s, %s, %s, %s)",
                            (name, base_url, ocpp_port, api_port),
                        )
                    conn.commit()
                    success_message = translate_text('Broker endpoint added.')
                elif action == 'update':
                    entry_id = request.form.get('id')
                    if not entry_id:
                        raise ValueError(translate_text('Invalid broker ID.'))
                    name = (request.form.get('name') or '').strip()
                    base_url = _normalize_broker_base_url(request.form.get('base_url', ''))
                    ocpp_port = _normalize_broker_port(
                        request.form.get('ocpp_port'),
                        default=_proxy_port or _DEFAULT_PROXY_PORT,
                        field_label=translate_text("OCPP port"),
                    )
                    api_port = _normalize_broker_port(
                        request.form.get('api_port'),
                        default=_proxy_api_port or _DEFAULT_PROXY_API_PORT,
                        field_label=translate_text("API port"),
                    )
                    if not name:
                        name = base_url
                    with conn.cursor() as cur:
                        cur.execute(
                            "UPDATE op_broker_instances SET name=%s, base_url=%s, ocpp_port=%s, api_port=%s WHERE id=%s",
                            (name, base_url, ocpp_port, api_port, entry_id),
                        )
                    conn.commit()
                    success_message = translate_text('Broker endpoint updated.')
                elif action == 'delete':
                    entry_id = request.form.get('id')
                    if not entry_id:
                        raise ValueError(translate_text('Invalid broker ID.'))
                    base_url = request.form.get('base_url', '')
                    normalized_url = None
                    try:
                        normalized_url = _normalize_broker_base_url(base_url)
                    except ValueError:
                        normalized_url = None
                    with conn.cursor() as cur:
                        cur.execute(
                            "DELETE FROM op_broker_instances WHERE id=%s",
                            (entry_id,),
                        )
                    conn.commit()
                    default_settings = get_default_broker_settings()
                    if (
                        normalized_url
                        and normalized_url == default_settings["base_url"]
                        and _normalize_broker_port(
                            request.form.get('ocpp_port'),
                            default=_proxy_port or _DEFAULT_PROXY_PORT,
                            field_label=translate_text("OCPP port"),
                        )
                        == default_settings["ocpp_port"]
                        and _normalize_broker_port(
                            request.form.get('api_port'),
                            default=_proxy_api_port or _DEFAULT_PROXY_API_PORT,
                            field_label=translate_text("API port"),
                        )
                        == default_settings["api_port"]
                    ):
                        _set_default_broker_config(
                            PROXY_DISPLAY_BASE_URL, _proxy_port, _proxy_api_port
                        )
                    success_message = translate_text('Broker endpoint removed.')
                elif action == 'set_default':
                    selected_base_url = _normalize_broker_base_url(
                        request.form.get('base_url', '')
                    )
                    selected_ocpp_port = _normalize_broker_port(
                        request.form.get('ocpp_port'),
                        default=_proxy_port or _DEFAULT_PROXY_PORT,
                        field_label=translate_text("OCPP port"),
                    )
                    selected_api_port = _normalize_broker_port(
                        request.form.get('api_port'),
                        default=_proxy_api_port or _DEFAULT_PROXY_API_PORT,
                        field_label=translate_text("API port"),
                    )
                    _set_default_broker_config(
                        selected_base_url, selected_ocpp_port, selected_api_port
                    )
                    success_message = translate_text('Default broker updated.')
            except ValueError as exc:
                error_message = str(exc)
            except pymysql.MySQLError as exc:
                error_message = str(exc)

        broker_instances = _load_broker_instances(conn)
        default_broker_settings = get_default_broker_settings()
    finally:
        conn.close()

    return render_template(
        'op_broker_instances.html',
        broker_instances=broker_instances,
        default_broker_settings=default_broker_settings,
        dashboard_url=url_for('dashboard'),
        aside='broker_instances',
        error_message=error_message,
        success_message=success_message,
    )


def _fetch_known_chargepoints(conn) -> list[str]:
    """Return a sorted list of chargepoint identifiers known to the system."""

    chargepoints: dict[str, str] = {}
    queries = (
        "SELECT DISTINCT chargepoint_id FROM cp_server_cp_metadata",
        "SELECT DISTINCT chargepoint_id FROM op_server_cp_config",
        "SELECT DISTINCT chargepoint_id FROM op_ocpp_routing_rules",
    )

    for sql in queries:
        try:
            with conn.cursor() as cur:
                cur.execute(sql)
                rows = cur.fetchall()
        except pymysql.err.ProgrammingError:
            continue
        except Exception:
            app.logger.warning(
                "Failed to load chargepoints using query '%s'", sql, exc_info=True
            )
            continue
        for row in rows:
            if isinstance(row, Mapping):
                cp_raw = row.get('chargepoint_id')
            else:
                cp_raw = row[0]
            cp_id = _normalize_chargepoint_id(cp_raw)
            if not cp_id:
                continue
            chargepoints.setdefault(cp_id, cp_id)

    return sorted(chargepoints.values(), key=str.lower)


@app.route('/op_ocpp_routing_configuration', methods=['GET', 'POST'])
def op_ocpp_routing_configuration():
    """Manage routing rules for forwarding OCPP messages per chargepoint."""

    success_message: str | None = None
    error_message: str | None = None

    chargepoints: list[str] = []
    option_entries: list[dict[str, Any]] = []
    configuration_entries: list[dict[str, Any]] = []
    selected_backend_url: str | None = None
    selected_protocol: str | None = None

    if request.method == 'POST':
        selected_chargepoint = _normalize_chargepoint_id(
            request.form.get('chargepoint_id')
        )
        selected_backend_url = (request.form.get('ocpp_backend_url') or '').strip()
        protocol_choice = (request.form.get('ocpp_protocol') or '').strip()
        selected_protocol = (
            protocol_choice if protocol_choice in {'OCPP 1.6', 'OCPP 2.0.1'} else ''
        )
    else:
        selected_chargepoint = _normalize_chargepoint_id(
            request.args.get('chargepoint_id')
        )

    conn = get_db_conn()
    try:
        ensure_ocpp_routing_rules_table(conn)

        if request.method == 'POST':
            action = (request.form.get('action') or 'save').strip().lower()
            if not selected_chargepoint:
                error_message = translate_text('Please select a charge point.')
            else:
                try:
                    if action == 'delete':
                        with conn.cursor() as cur:
                            cur.execute(
                                "DELETE FROM op_ocpp_routing_rules WHERE chargepoint_id=%s",
                                (selected_chargepoint,),
                            )
                        conn.commit()
                        success_message = translate_text(
                            'Configuration for {chargepoint} was deleted.',
                            chargepoint=selected_chargepoint,
                        )
                    else:
                        backend_url = selected_backend_url or ''
                        protocol = selected_protocol or None
                        with conn.cursor() as cur:
                            for field_name, message_type, _ in OCPP_ROUTING_MESSAGE_CHOICES:
                                field_key = f'option_{field_name}'
                                enabled = 1 if request.form.get(field_key) else 0
                                cur.execute(
                                    """
                                    INSERT INTO op_ocpp_routing_rules (
                                        chargepoint_id,
                                        message_type,
                                        enabled,
                                        ocpp_backend_url,
                                        ocpp_protocol
                                    ) VALUES (%s, %s, %s, %s, %s)
                                    ON DUPLICATE KEY UPDATE
                                        enabled=VALUES(enabled),
                                        ocpp_backend_url=VALUES(ocpp_backend_url),
                                        ocpp_protocol=VALUES(ocpp_protocol)
                                    """,
                                    (
                                        selected_chargepoint,
                                        message_type,
                                        enabled,
                                        backend_url or None,
                                        protocol,
                                    ),
                            )
                        conn.commit()
                        success_message = translate_text(
                            'Configuration for {chargepoint} was saved.',
                            chargepoint=selected_chargepoint,
                        )
                except Exception:
                    conn.rollback()
                    error_message = translate_text('Configuration could not be saved.')
                    app.logger.warning(
                        'Failed to update OCPP routing configuration', exc_info=True
                    )

        chargepoints = _fetch_known_chargepoints(conn)
        if selected_chargepoint and selected_chargepoint not in chargepoints:
            chargepoints.append(selected_chargepoint)
            chargepoints.sort(key=str.lower)
        if not selected_chargepoint and chargepoints:
            selected_chargepoint = chargepoints[0]

        selected_options: dict[str, bool] = {}
        if selected_chargepoint:
            with conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT message_type, enabled, ocpp_backend_url, ocpp_protocol
                    FROM op_ocpp_routing_rules
                    WHERE chargepoint_id=%s
                    """,
                    (selected_chargepoint,),
                )
                rows = cur.fetchall()
            for row in rows:
                if isinstance(row, Mapping):
                    message_type = row.get('message_type')
                    enabled_raw = row.get('enabled')
                    backend_url_raw = row.get('ocpp_backend_url')
                    protocol_raw = row.get('ocpp_protocol')
                else:
                    message_type = row[0]
                    enabled_raw = row[1]
                    backend_url_raw = row[2] if len(row) > 2 else None
                    protocol_raw = row[3] if len(row) > 3 else None
                if message_type is None:
                    continue
                selected_options[str(message_type)] = _coerce_db_bool(enabled_raw)
                if (not selected_backend_url) and backend_url_raw:
                    selected_backend_url = str(backend_url_raw)
                if (not selected_protocol) and protocol_raw:
                    selected_protocol = str(protocol_raw)

        option_entries = [
            {
                'field_id': f'option_{field_name}',
                'label': translate_text(label),
                'checked': selected_options.get(message_type, False),
            }
            for field_name, message_type, label in OCPP_ROUTING_MESSAGE_CHOICES
        ]

        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT chargepoint_id, message_type, enabled, ocpp_backend_url, ocpp_protocol
                FROM op_ocpp_routing_rules
                ORDER BY chargepoint_id, message_type
                """
            )
            all_rows = cur.fetchall()

        configuration_map: dict[str, dict[str, Any]] = {}
        for row in all_rows:
            if isinstance(row, Mapping):
                cp_raw = row.get('chargepoint_id')
                message_type = row.get('message_type')
                enabled_raw = row.get('enabled')
                backend_url_raw = row.get('ocpp_backend_url')
                protocol_raw = row.get('ocpp_protocol')
            else:
                cp_raw = row[0]
                message_type = row[1]
                enabled_raw = row[2]
                backend_url_raw = row[3] if len(row) > 3 else None
                protocol_raw = row[4] if len(row) > 4 else None
            cp_id = _normalize_chargepoint_id(cp_raw)
            if not cp_id or message_type is None:
                continue
            config_entry = configuration_map.setdefault(
                cp_id,
                {
                    'messages': {},
                    'backend_url': None,
                    'protocol': None,
                },
            )
            config_entry['messages'][str(message_type)] = _coerce_db_bool(enabled_raw)
            if not config_entry['backend_url'] and backend_url_raw:
                config_entry['backend_url'] = str(backend_url_raw)
            if not config_entry['protocol'] and protocol_raw:
                config_entry['protocol'] = str(protocol_raw)

        all_label = next(
            (
                translate_text(label)
                for _field_name, message_type, label in OCPP_ROUTING_MESSAGE_CHOICES
                if message_type == '__all__'
            ),
            translate_text('All OCPP Messages'),
        )
        configuration_entries = []
        for cp_id, config_entry in sorted(
            configuration_map.items(), key=lambda item: str(item[0]).lower()
        ):
            all_enabled = bool(config_entry['messages'].get('__all__'))
            enabled_labels = [
                translate_text(label)
                for _field_name, message_type, label in OCPP_ROUTING_MESSAGE_CHOICES
                if message_type != '__all__'
                and config_entry['messages'].get(message_type)
            ]
            configuration_entries.append(
                {
                    'chargepoint_id': cp_id,
                    'backend_url': config_entry.get('backend_url'),
                    'protocol': config_entry.get('protocol'),
                    'enabled_labels': enabled_labels,
                    'all_enabled': all_enabled,
                    'all_label': all_label,
                }
            )
    finally:
        conn.close()

    return render_template(
        'op_ocpp_routing_configuration.html',
        aside='ocpp_routing_configuration',
        chargepoints=chargepoints,
        selected_chargepoint=selected_chargepoint,
        options=option_entries,
        selected_backend_url=selected_backend_url or '',
        selected_protocol=selected_protocol or '',
        success_message=success_message,
        error_message=error_message,
        configuration_entries=configuration_entries,
    )


@app.route('/op_ocpp_routing_monitoring')
def op_ocpp_routing_monitoring():
    """Placeholder view for monitoring OCPP routing."""

    return render_template(
        'op_ocpp_routing_monitoring.html',
        aside='ocpp_routing_monitoring',
    )


@app.route('/op_pnc_overview')
def op_pnc_overview():
    """Overview entry point for Plug & Charge."""

    return render_template(
        'op_pnc_overview.html',
        aside='plug_and_charge_overview',
    )


@app.route('/op_pnc_authorizations')
def op_pnc_authorizations():
    """List Plug & Charge authorization handling details."""

    return render_template(
        'op_pnc_authorizations.html',
        aside='plug_and_charge_authorizations',
    )


@app.route('/op_pnc_certificates', methods=['GET', 'POST'])
def op_pnc_certificates():
    """Manage Plug & Charge certificates."""

    message = None
    error = None
    certs = _collect_hubject_certificate_state()

    if request.method == 'POST':
        artifact = request.form.get('artifact') or ''
        upload = request.files.get('file')
        error, message = _validate_and_store_hubject_upload(artifact, upload)
        certs = _collect_hubject_certificate_state()

    return render_template(
        'op_pnc_certificates.html',
        aside='plug_and_charge_certificates',
        message=message,
        error=error,
        certs=certs,
    )


@app.route('/op_pnc_secc_check_digit')
def op_pnc_secc_check_digit():
    """Calculate the SECC check digit for a SECC ID input."""

    default_secc_id = "DE-WLE-S-TEST0SEB0WALTER000000000000000000000001"

    return render_template(
        'op_pnc_secc_check_digit.html',
        aside='plug_and_charge_secc_check_digit',
        default_secc_id=default_secc_id,
    )


def _current_hubject_config() -> dict[str, Any]:
    cfg = _load_config_file()
    block = cfg.get("hubject") or {}
    if not isinstance(block, Mapping):
        return {}
    return dict(block)


def _merge_hubject_config(
    existing: Mapping[str, Any], payload: Mapping[str, Any]
) -> dict[str, Any]:
    merged = dict(existing)
    for key in (
        "api_base_url",
        "authorization",
        "client_cert",
        "client_key",
        "ca_bundle",
        "operator_id",
    ):
        if key in payload:
            value = payload.get(key)
            if isinstance(value, str):
                value = value.strip()
            merged[key] = value or None

    if "timeout" in payload:
        timeout_value = payload.get("timeout")
        try:
            timeout_int = int(timeout_value)
            if timeout_int <= 0:
                raise ValueError
        except Exception:
            raise ValueError("timeout must be a positive integer")
        merged["timeout"] = timeout_int
    elif "timeout" not in merged:
        merged["timeout"] = existing.get("timeout", 30)

    return merged


def _serialize_hubject_config(cfg: Any) -> dict[str, Any]:
    return {
        "api_base_url": cfg.api_base_url,
        "authorization": cfg.authorization,
        "timeout": cfg.timeout,
        "client_cert": _serialize_config_path(cfg.client_cert),
        "client_key": _serialize_config_path(cfg.client_key),
        "ca_bundle": _serialize_config_path(cfg.ca_bundle),
        "operator_id": cfg.operator_id,
    }


def _persist_hubject_overrides(cfg: Mapping[str, Any]) -> None:
    set_config_value("hubject_api_gw", cfg.get("api_base_url", ""))
    set_config_value("hubject_api_gw_authorization", cfg.get("authorization") or "")
    set_config_value("hubject_operator_id", cfg.get("operator_id") or "")
    set_config_value("hubject_api_gw_timeout", str(cfg.get("timeout") or ""))
    set_config_value("hubject_api_gw_client_cert", cfg.get("client_cert") or "")
    set_config_value("hubject_api_gw_client_key", cfg.get("client_key") or "")
    set_config_value("hubject_api_gw_ca_bundle", cfg.get("ca_bundle") or "")


def _write_pnc_reload_flag() -> str:
    _PNC_RELOAD_FLAG_PATH.parent.mkdir(parents=True, exist_ok=True)
    _PNC_RELOAD_FLAG_PATH.touch()
    return str(_PNC_RELOAD_FLAG_PATH)


def _audit_hubject_change(action: str, details: Mapping[str, Any]) -> None:
    user = get_dashboard_user() or "api"
    audit_logger.info(
        "hubject.%s user=%s details=%s", action, user, json.dumps(details, ensure_ascii=False)
    )


@app.route('/op_pnc_hubject_settings', methods=['GET', 'POST'])
def op_pnc_hubject_settings():
    """Edit Hubject API credentials and endpoints for PnC flows."""

    message = None
    error = None

    current_cfg = _current_hubject_config()
    for key, cfg_key in (
        ("api_base_url", "hubject_api_gw"),
        ("authorization", "hubject_api_gw_authorization"),
        ("operator_id", "hubject_operator_id"),
        ("timeout", "hubject_api_gw_timeout"),
        ("client_cert", "hubject_api_gw_client_cert"),
        ("client_key", "hubject_api_gw_client_key"),
        ("ca_bundle", "hubject_api_gw_ca_bundle"),
    ):
        override = get_config_value(cfg_key)
        if isinstance(override, str):
            override = override.strip()
        if override not in {None, "", "None"}:
            current_cfg[key] = override

    if request.method == 'POST':
        action = (request.form.get('action') or '').strip().lower()
        if action == 'reload':
            flag_path = _write_pnc_reload_flag()
            _audit_hubject_change("reload_requested", {"flag": flag_path})
            message = f'Reload-Flag geschrieben: {flag_path}'
        elif action == 'upload':
            upload_messages: list[str] = []
            upload_errors: list[str] = []
            uploads_found = False

            for artifact in ("client_cert", "client_key", "ca_bundle"):
                upload = request.files.get(artifact)
                if upload and upload.filename:
                    uploads_found = True
                    err, msg = _validate_and_store_hubject_upload(artifact, upload)
                    if err:
                        upload_errors.append(err)
                    elif msg:
                        upload_messages.append(msg)

            if not uploads_found and not upload_errors:
                upload_errors.append('Bitte mindestens eine Datei auswählen.')

            if upload_errors:
                error = '; '.join(upload_errors)
            if upload_messages:
                message = ' '.join(upload_messages)
        else:
            timeout_input = request.form.get('timeout')
            payload = {
                "api_base_url": (request.form.get('api_base_url') or '').strip(),
                "authorization": (request.form.get('authorization') or '').strip(),
                "operator_id": (request.form.get('operator_id') or '').strip(),
                "client_cert": (request.form.get('client_cert') or '').strip(),
                "client_key": (request.form.get('client_key') or '').strip(),
                "ca_bundle": (request.form.get('ca_bundle') or '').strip(),
            }
            if timeout_input not in {None, ""}:
                payload["timeout"] = timeout_input

            try:
                merged = _merge_hubject_config(current_cfg, payload)
                validated = load_hubject_config(
                    {"hubject": merged},
                    config_dir=_CONFIG_BASE_DIR,
                    certs_dir=_CONFIG_BASE_DIR / "certs",
                )
            except (HubjectConfigurationError, ValueError) as exc:
                error = str(exc)
            else:
                serialized = _serialize_hubject_config(validated)
                new_config = _load_config_file()
                new_config["hubject"] = serialized
                _write_config_file(new_config)

                global _config, _hubject_cfg
                _config = new_config
                _hubject_cfg = serialized

                _persist_hubject_overrides(serialized)
                _audit_hubject_change("update", {"updated_fields": list(payload.keys())})

                current_cfg = serialized
                message = 'Konfiguration gespeichert.'

    certs = _collect_hubject_certificate_state()
    mtls_missing = {
        k for k in ("client_cert", "client_key", "ca_bundle") if not current_cfg.get(k)
    }
    mtls_missing.update({k for k, meta in certs.items() if not meta.get("exists")})
    mtls_unreadable = {
        k for k, meta in certs.items() if meta.get("exists") and not meta.get("readable")
    }
    mtls_ready = len(mtls_missing) == 0 and len(mtls_unreadable) == 0

    return render_template(
        'op_pnc_hubject_settings.html',
        aside='plug_and_charge_hubject_settings',
        message=message,
        error=error,
        api_base_url=current_cfg.get('api_base_url', ''),
        authorization=current_cfg.get('authorization', ''),
        operator_id=current_cfg.get('operator_id', ''),
        timeout=current_cfg.get('timeout', 30),
        client_cert=current_cfg.get('client_cert', ''),
        client_key=current_cfg.get('client_key', ''),
        ca_bundle=current_cfg.get('ca_bundle', ''),
        certs=certs,
        mtls_ready=mtls_ready,
        mtls_missing=sorted(mtls_missing),
        mtls_unreadable=sorted(mtls_unreadable),
    )


@app.route('/api/pnc/hubject/settings', methods=['GET'])
def api_get_pnc_hubject_settings():
    if not _is_api_authorized():
        return _unauthorized_response()

    cfg = _current_hubject_config()
    response = {
        "api_base_url": cfg.get("api_base_url"),
        "authorization": cfg.get("authorization"),
        "timeout": cfg.get("timeout"),
        "client_cert": cfg.get("client_cert"),
        "client_key": cfg.get("client_key"),
        "ca_bundle": cfg.get("ca_bundle"),
        "operator_id": cfg.get("operator_id"),
    }
    return jsonify(response)


@app.route('/api/pnc/hubject/settings', methods=['PUT', 'POST'])
def api_update_pnc_hubject_settings():
    if not _is_api_authorized():
        return _unauthorized_response()

    payload = request.get_json(silent=True) or {}
    if not isinstance(payload, Mapping):
        return jsonify({"error": "invalid payload"}), 400

    current_cfg = _current_hubject_config()

    try:
        merged = _merge_hubject_config(current_cfg, payload)
        validated = load_hubject_config(
            {"hubject": merged},
            config_dir=_CONFIG_BASE_DIR,
            certs_dir=_CONFIG_BASE_DIR / "certs",
        )
    except HubjectConfigurationError as exc:
        return jsonify({"error": str(exc)}), 400
    except ValueError as exc:
        return jsonify({"error": str(exc)}), 400

    serialized = _serialize_hubject_config(validated)
    new_config = _load_config_file()
    new_config["hubject"] = serialized
    _write_config_file(new_config)

    global _config, _hubject_cfg
    _config = new_config
    _hubject_cfg = serialized

    _persist_hubject_overrides(serialized)

    rotations: dict[str, Any] = {}
    for key in ("client_cert", "client_key", "ca_bundle"):
        before = current_cfg.get(key)
        after = serialized.get(key)
        if before != after:
            rotations[key] = {"from": before, "to": after}

    change_details = {
        "updated_fields": [k for k, v in payload.items() if v is not None],
        "rotations": rotations,
    }
    _audit_hubject_change("update", change_details)

    flag_path = _write_pnc_reload_flag()
    _audit_hubject_change("reload_requested", {"flag": flag_path})

    return jsonify({"status": "ok", "hubject": serialized, "reload_flag": flag_path})


@app.route('/api/pnc/hubject/reload', methods=['POST'])
def api_trigger_pnc_reload():
    if not _is_api_authorized():
        return _unauthorized_response()

    flag_path = _write_pnc_reload_flag()
    _audit_hubject_change("reload_requested", {"flag": flag_path})
    return jsonify({"status": "queued", "reload_flag": flag_path}), 202


@app.route('/op_pnc_stations', methods=['GET', 'POST'])
def op_pnc_stations():
    """List stations and allow toggling Plug & Charge support."""

    message = None
    error = None

    page_size = 40

    page_raw = request.values.get('page', '1')
    try:
        page = max(1, int(page_raw))
    except (TypeError, ValueError):
        page = 1

    chargepoint_query = _normalize_chargepoint_id(request.values.get('chargepoint_id'))

    conn = get_db_conn()
    stations: list[dict[str, Any]] = []
    total_stations = 0
    total_pages = 1
    try:
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            if request.method == 'POST':
                station_id = normalize_station_id(request.form.get('station_id', ''))
                target_state_raw = request.form.get('target_state')
                if not station_id:
                    error = 'Station-ID fehlt.'
                else:
                    try:
                        target_state = int(target_state_raw)
                    except (TypeError, ValueError):
                        target_state = None
                    if target_state not in {0, 1}:
                        error = 'Ungültiger Zielstatus für Plug & Charge.'
                    else:
                        cur.execute(
                            "UPDATE op_redirects SET pnc_enabled=%s WHERE source_url LIKE %s",
                            (target_state, f"%/{station_id}"),
                        )
                        conn.commit()
                        message = (
                            f"Plug & Charge {'aktiviert' if target_state else 'deaktiviert'} für {station_id}."
                        )

            where_clause = ""
            params: list[Any] = []
            if chargepoint_query:
                where_clause = "WHERE source_url LIKE %s"
                params.append(f"%{chargepoint_query}%")

            cur.execute(
                f"SELECT COUNT(*) AS total FROM op_redirects {where_clause}", params
            )
            count_row = cur.fetchone() or {}
            total_stations = int(count_row.get('total') or 0)
            total_pages = max(1, math.ceil(total_stations / page_size)) if total_stations else 1
            if page > total_pages:
                page = total_pages

            offset = (page - 1) * page_size

            cur.execute(
                f"""
                SELECT source_url, ws_url, pnc_enabled, activity, location_name
                FROM op_redirects
                {where_clause}
                ORDER BY source_url
                LIMIT %s OFFSET %s
                """,
                [*params, page_size, offset],
            )
            for row in cur.fetchall():
                station_id = normalize_station_id(row.get('source_url', '')).rsplit('/', 1)[-1]
                stations.append(
                    {
                        'station_id': station_id,
                        'source_url': row.get('source_url'),
                        'ws_url': row.get('ws_url'),
                        'pnc_enabled': bool(row.get('pnc_enabled')),
                        'activity': row.get('activity'),
                        'location_name': row.get('location_name'),
                    }
                )
    finally:
        conn.close()

    return render_template(
        'op_pnc_stations.html',
        stations=stations,
        message=message,
        error=error,
        current_page=page,
        total_pages=total_pages,
        total_stations=total_stations,
        chargepoint_query=chargepoint_query,
        aside='plug_and_charge_stations',
    )


@app.route('/op_pnc_commands', methods=['GET', 'POST'])
def op_pnc_commands():
    """Send Plug & Charge related OCPP commands to connected stations."""

    success_message = None
    error_message = None
    response_payload = None
    selected_station = None
    selected_action = None

    stations, fetch_error = fetch_connected_stations()

    if request.method == 'POST':
        selected_station = normalize_station_id(request.form.get('station_id', ''))
        selected_action = (request.form.get('action') or '').strip().lower()
        connector_raw = request.form.get('connector_id')
        transaction_raw = request.form.get('transaction_id')
        id_tag = (request.form.get('id_tag') or '').strip() or None
        contract_ref = (request.form.get('contract_cert_reference') or '').strip() or None
        metadata = {
            key: value
            for key, value in {
                'contract_cert_reference': contract_ref,
                'session_id': (request.form.get('session_id') or '').strip() or None,
                'partner_session_id': (request.form.get('partner_session_id') or '').strip()
                or None,
                'initiated_by': (request.form.get('initiated_by') or '').strip() or None,
                'notes': (request.form.get('notes') or '').strip() or None,
            }.items()
            if value is not None
        }

        payload: dict[str, Any] = {
            'station_id': selected_station,
            'action': selected_action,
            'metadata': metadata,
        }

        try:
            if connector_raw not in (None, ''):
                payload['connectorId'] = int(connector_raw)
        except (TypeError, ValueError):
            error_message = 'Connector-ID muss eine Zahl sein.'
        if not error_message:
            try:
                if transaction_raw not in (None, ''):
                    payload['transaction_id'] = int(transaction_raw)
            except (TypeError, ValueError):
                error_message = 'Transaction-ID muss eine Zahl sein.'

        if not error_message:
            if selected_action == 'remote_start' and not id_tag and contract_ref:
                payload['id_tag'] = f"pnc:{contract_ref}"
            elif id_tag:
                payload['id_tag'] = id_tag

            diagnostics_location = request.form.get('diagnostics_location') or ''
            if diagnostics_location:
                payload['diagnostics_location'] = diagnostics_location.strip()
            retries_raw = request.form.get('retries')
            retry_interval_raw = request.form.get('retry_interval')
            if retries_raw not in (None, ''):
                payload['retries'] = retries_raw
            if retry_interval_raw not in (None, ''):
                payload['retryInterval'] = retry_interval_raw

            reset_type = request.form.get('reset_type') or ''
            if reset_type:
                payload['reset_type'] = reset_type

            if not selected_station:
                error_message = 'Bitte eine Station auswählen.'
            elif selected_action not in {
                'remote_start',
                'remote_stop',
                'diagnostics',
                'reset',
                'trigger_boot',
            }:
                error_message = 'Ungültige Aktion.'
            else:
                try:
                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-command', payload
                    )
                    if isinstance(response_payload, dict) and response_payload.get('error'):
                        error_message = str(response_payload.get('error'))
                    else:
                        action_label = {
                            'remote_start': 'RemoteStartTransaction',
                            'remote_stop': 'RemoteStopTransaction',
                            'diagnostics': 'GetDiagnostics',
                            'reset': 'Reset',
                            'trigger_boot': 'Trigger BootNotification',
                        }.get(selected_action, selected_action)
                        success_message = (
                            f"{action_label} an {selected_station} gesendet."
                        )
                except RuntimeError as exc:
                    error_message = str(exc)

    return render_template(
        'op_pnc_commands.html',
        stations=stations,
        fetch_error=fetch_error,
        success_message=success_message,
        error_message=error_message,
        response_payload=response_payload,
        selected_station=selected_station,
        selected_action=selected_action,
        aside='plug_and_charge_commands',
    )


@app.route('/op_pnc_ocpp_certificates', methods=['GET', 'POST'])
def op_pnc_ocpp_certificates():
    """Manage Plug & Charge certificate-related OCPP calls via the dashboard."""

    success_message = None
    error_message = None
    response_payload = None
    selected_station = None
    selected_action = None
    certificate_type = None
    certificate_content = ''
    hash_algorithm = None
    issuer_name_hash = ''
    issuer_key_hash = ''
    serial_number = ''

    stations, fetch_error = fetch_connected_stations()
    allowed_certificate_types = {
        'V2GRootCertificate',
        'MORootCertificate',
        'CSMSRootCertificate',
    }
    allowed_hash_algorithms = {'SHA256', 'SHA384', 'SHA512'}

    if request.method == 'POST':
        selected_station = normalize_station_id(request.form.get('station_id', ''))
        selected_action = (request.form.get('action') or '').strip().lower()
        certificate_type = (request.form.get('certificate_type') or '').strip() or None
        certificate_content = request.form.get('certificate') or ''
        hash_algorithm = (request.form.get('hash_algorithm') or '').strip() or None
        issuer_name_hash = (request.form.get('issuer_name_hash') or '').strip()
        issuer_key_hash = (request.form.get('issuer_key_hash') or '').strip()
        serial_number = (request.form.get('serial_number') or '').strip()

        payload: dict[str, Any] = {
            'station_id': selected_station,
            'action': selected_action,
        }

        if not selected_station:
            error_message = 'Bitte eine Station auswählen.'
        elif selected_action == 'install_certificate':
            if certificate_type not in allowed_certificate_types:
                error_message = 'certificateType muss gewählt werden.'
            elif not certificate_content.strip():
                error_message = 'Zertifikatsinhalt fehlt.'
            else:
                payload.update(
                    {
                        'certificateType': certificate_type,
                        'certificate': certificate_content.strip(),
                    }
                )
        elif selected_action == 'certificate_signed':
            if not certificate_content.strip():
                error_message = 'CertificateChain fehlt.'
            else:
                payload['certificateChain'] = certificate_content.strip()
        elif selected_action == 'get_installed_certificate_ids':
            if certificate_type:
                if certificate_type not in allowed_certificate_types:
                    error_message = 'certificateType ist ungültig.'
                else:
                    payload['certificateType'] = certificate_type
        elif selected_action == 'delete_certificate':
            if hash_algorithm not in allowed_hash_algorithms:
                error_message = 'hashAlgorithm ist erforderlich.'
            elif not issuer_name_hash or not issuer_key_hash or not serial_number:
                error_message = 'issuerNameHash, issuerKeyHash und serialNumber sind erforderlich.'
            else:
                payload.update(
                    {
                        'certificateHashData': {
                            'hashAlgorithm': hash_algorithm,
                            'issuerNameHash': issuer_name_hash,
                            'issuerKeyHash': issuer_key_hash,
                            'serialNumber': serial_number,
                        }
                    }
                )
        else:
            error_message = 'Ungültige Aktion.'

        if not error_message:
            try:
                response_payload = _post_ocpp_command(
                    '/api/pnc/ocpp-certificates', payload
                )
                if isinstance(response_payload, dict) and response_payload.get('error'):
                    error_message = str(response_payload.get('error'))
                else:
                    action_label = {
                        'install_certificate': 'InstallCertificate',
                        'certificate_signed': 'CertificateSigned',
                        'get_installed_certificate_ids': 'GetInstalledCertificateIds',
                        'delete_certificate': 'DeleteCertificate',
                    }.get(selected_action, selected_action)
                    success_message = f"{action_label} an {selected_station} gesendet."
            except RuntimeError as exc:
                error_message = str(exc)

    return render_template(
        'op_pnc_ocpp_certificates.html',
        stations=stations,
        fetch_error=fetch_error,
        success_message=success_message,
        error_message=error_message,
        response_payload=response_payload,
        selected_station=selected_station,
        selected_action=selected_action,
        certificate_type=certificate_type,
        certificate_content=certificate_content,
        hash_algorithm=hash_algorithm,
        issuer_name_hash=issuer_name_hash,
        issuer_key_hash=issuer_key_hash,
        serial_number=serial_number,
        aside='plug_and_charge_ocpp_certificates',
    )


@app.route('/op_mini_cpms_connected_devices', methods=['GET', 'POST'])
def mini_cpms_connected_devices():
    success_message = None
    error_message = None
    response_payload = None
    selected_station = None

    if request.method == 'POST':
        action = request.form.get('action')
        selected_station = request.form.get('station_id', '').strip()
        if not selected_station:
            error_message = "Bitte eine Ladestation auswählen."
        elif action not in {'set_operational', 'set_inoperative'}:
            error_message = "Unbekannte Aktion."
        else:
            availability_type = (
                'Operative' if action == 'set_operational' else 'Inoperative'
            )
            payload = {
                'station_id': selected_station,
                'type': availability_type,
                'connectorId': 0,
            }
            try:
                response_payload = _post_ocpp_command(
                    '/api/changeAvailability', payload
                )
                message_parts = [
                    f"ChangeAvailability ({availability_type}) für {selected_station} gesendet."
                ]
                if isinstance(response_payload, dict):
                    status = response_payload.get('status')
                    if status:
                        message_parts.append(f"Antwort: {status}.")
                    availability = response_payload.get('availability')
                    if availability:
                        message_parts.append(
                            f"Aktuelle Availability: {availability}."
                        )
                success_message = ' '.join(message_parts)
            except RuntimeError as exc:
                error_message = str(exc)

    stations, fetch_error = fetch_connected_stations(include_oicp_flags=True)
    return render_template(
        'op_mini_cpms_connected_devices.html',
        stations=stations,
        fetch_error=fetch_error,
        success_message=success_message,
        error_message=error_message,
        response_payload=response_payload,
        selected_station=selected_station,
        timeout_warning_threshold=STATION_LIVENESS_WARNING_SECONDS,
        aside='miniCpmsMenu',
    )


@app.route('/op_mini_cpms_oicp_toggle', methods=['POST'])
def mini_cpms_oicp_toggle():
    payload = request.get_json(silent=True) or {}
    station_raw = str(payload.get('station_id') or '').strip()
    if not station_raw:
        return (
            jsonify({'success': False, 'error': 'Station-ID fehlt.'}),
            400,
        )

    normalized_id = _normalize_chargepoint_id(station_raw)
    if not normalized_id:
        return (
            jsonify({'success': False, 'error': 'Station-ID ist ungültig.'}),
            400,
        )

    enabled_raw = payload.get('enabled')
    if enabled_raw is None:
        return (
            jsonify({'success': False, 'error': 'Status fehlt.'}),
            400,
        )

    enabled_flag = False
    if isinstance(enabled_raw, str):
        enabled_flag = enabled_raw.strip().lower() in {
            '1',
            'true',
            'yes',
            'on',
            'enable',
            'enabled',
        }
    else:
        enabled_flag = bool(enabled_raw)

    try:
        conn = get_db_conn()
    except Exception as exc:
        app.logger.warning(
            'Failed to open DB connection for OICP toggle: %s', exc, exc_info=True
        )
        return (
            jsonify(
                {
                    'success': False,
                    'error': 'Keine Datenbankverbindung für OICP-Umschaltung.',
                }
            ),
            500,
        )
    try:
        ensure_oicp_enable_table(conn)
        with conn.cursor() as cur:
            if enabled_flag:
                cur.execute(
                    """
                    INSERT INTO op_server_oicp_enable (chargepoint_id, enabled)
                    VALUES (%s, 1)
                    ON DUPLICATE KEY UPDATE enabled=VALUES(enabled)
                    """,
                    (normalized_id,),
                )
            else:
                cur.execute(
                    'DELETE FROM op_server_oicp_enable WHERE chargepoint_id=%s',
                    (normalized_id,),
                )
        conn.commit()
    except Exception:
        conn.rollback()
        app.logger.exception('Failed to toggle OICP flag for %s', normalized_id)
        return (
            jsonify(
                {
                    'success': False,
                    'error': 'OICP-Status konnte nicht gespeichert werden.',
                }
            ),
            500,
        )
    finally:
        conn.close()

    return jsonify(
        {
            'success': True,
            'station_id': normalized_id,
            'enabled': enabled_flag,
        }
    )


@app.route('/op_mini_cpms_server_startup_config', methods=['GET', 'POST'])
def mini_cpms_server_startup_config():
    success_message = None
    error_message = None
    config = _default_server_startup_config()

    conn = get_db_conn()
    try:
        ensure_server_config_table(conn)
        if request.method == 'POST':
            try:
                local_authorize_offline = 1 if request.form.get('local_authorize_offline') == '1' else 0
                authorize_remote_tx_requests = 1 if request.form.get('authorize_remote_tx_requests') == '1' else 0
                local_auth_list_enabled = 1 if request.form.get('local_auth_list_enabled') == '1' else 0
                authorization_cache_enabled = 1 if request.form.get('authorization_cache_enabled') == '1' else 0
                change_availability_operative = 1 if request.form.get('change_availability_operative') == '1' else 0
                enforce_websocket_ping_interval = 1 if request.form.get('enforce_websocket_ping_interval') == '1' else 0
                trigger_meter_values_on_start = 1 if request.form.get('trigger_meter_values_on_start') == '1' else 0
                trigger_status_notification_on_start = 1 if request.form.get('trigger_status_notification_on_start') == '1' else 0
                trigger_boot_notification_on_message_before_boot = 1 if request.form.get('trigger_boot_notification_on_message_before_boot') == '1' else 0
                heartbeat_raw = (request.form.get('heartbeat_interval') or '').strip()
                if heartbeat_raw:
                    try:
                        heartbeat_interval = int(heartbeat_raw)
                    except ValueError as exc:
                        raise ValueError('Heartbeat-Intervall muss eine Ganzzahl sein.') from exc
                else:
                    heartbeat_interval = SERVER_STARTUP_DEFAULTS['heartbeat_interval']
                if heartbeat_interval <= 0:
                    raise ValueError('Das Heartbeat-Intervall muss größer als 0 sein.')

                submitted_items = config['change_configuration_items']
                change_configuration_values: list[tuple[int, str | None, str | None]] = []
                for idx in range(1, MAX_CHANGE_CONFIGURATION_ITEMS + 1):
                    enabled_flag = 1 if request.form.get(f'change_configuration_enabled_{idx}') == '1' else 0
                    key_value = (request.form.get(f'change_configuration_key_{idx}') or '').strip()
                    value_value = (request.form.get(f'change_configuration_value_{idx}') or '').strip()
                    while len(submitted_items) < idx:
                        submitted_items.append({"enabled": False, "key": "", "value": ""})
                    entry = submitted_items[idx - 1]
                    entry['enabled'] = bool(enabled_flag)
                    entry['key'] = key_value
                    entry['value'] = value_value
                    if entry['enabled'] and not entry['key']:
                        raise ValueError(
                            f'Für Eintrag {idx} muss bei aktivierter Einstellung ein Konfigurationsschlüssel angegeben werden.'
                        )
                    change_configuration_values.append(
                        (
                            enabled_flag,
                            key_value or None,
                            value_value or None,
                        )
                    )

                columns = [
                    'local_authorize_offline',
                    'authorize_remote_tx_requests',
                    'local_auth_list_enabled',
                    'authorization_cache_enabled',
                    'change_availability_operative',
                    'enforce_websocket_ping_interval',
                    'trigger_meter_values_on_start',
                    'trigger_status_notification_on_start',
                    'trigger_boot_notification_on_message_before_boot',
                    'heartbeat_interval',
                ]
                updates = [
                    'local_authorize_offline=VALUES(local_authorize_offline)',
                    'authorize_remote_tx_requests=VALUES(authorize_remote_tx_requests)',
                    'local_auth_list_enabled=VALUES(local_auth_list_enabled)',
                    'authorization_cache_enabled=VALUES(authorization_cache_enabled)',
                    'change_availability_operative=VALUES(change_availability_operative)',
                    'enforce_websocket_ping_interval=VALUES(enforce_websocket_ping_interval)',
                    'trigger_meter_values_on_start=VALUES(trigger_meter_values_on_start)',
                    'trigger_status_notification_on_start=VALUES(trigger_status_notification_on_start)',
                    'trigger_boot_notification_on_message_before_boot=VALUES(trigger_boot_notification_on_message_before_boot)',
                    'heartbeat_interval=VALUES(heartbeat_interval)',
                ]
                values: list[Any] = [
                    local_authorize_offline,
                    authorize_remote_tx_requests,
                    local_auth_list_enabled,
                    authorization_cache_enabled,
                    change_availability_operative,
                    enforce_websocket_ping_interval,
                    trigger_meter_values_on_start,
                    trigger_status_notification_on_start,
                    trigger_boot_notification_on_message_before_boot,
                    heartbeat_interval,
                ]
                for idx, (enabled_flag, key_value, value_value) in enumerate(
                    change_configuration_values, start=1
                ):
                    columns.extend(
                        [
                            f'change_configuration_enabled_{idx}',
                            f'change_configuration_key_{idx}',
                            f'change_configuration_value_{idx}',
                        ]
                    )
                    updates.extend(
                        [
                            f'change_configuration_enabled_{idx}=VALUES(change_configuration_enabled_{idx})',
                            f'change_configuration_key_{idx}=VALUES(change_configuration_key_{idx})',
                            f'change_configuration_value_{idx}=VALUES(change_configuration_value_{idx})',
                        ]
                    )
                    values.extend([enabled_flag, key_value, value_value])

                placeholders = ', '.join(['%s'] * len(values))
                columns_sql = ',\n                            '.join(columns)
                updates_sql = ',\n                            '.join(updates)

                with conn.cursor() as cur:
                    cur.execute(
                        f"""
                        INSERT INTO op_server_config (
                            id,
                            {columns_sql}
                        ) VALUES (1, {placeholders})
                        ON DUPLICATE KEY UPDATE
                            {updates_sql}
                        """,
                        values,
                    )
                conn.commit()
                success_message = 'Einstellungen gespeichert.'
            except ValueError as exc:
                conn.rollback()
                error_message = str(exc)
            except Exception:
                conn.rollback()
                error_message = 'Fehler beim Speichern der Einstellungen.'
                app.logger.warning('Failed to store server startup config', exc_info=True)

        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT
                    local_authorize_offline,
                    authorize_remote_tx_requests,
                    local_auth_list_enabled,
                    authorization_cache_enabled,
                    change_availability_operative,
                    enforce_websocket_ping_interval,
                    trigger_meter_values_on_start,
                    trigger_status_notification_on_start,
                    trigger_boot_notification_on_message_before_boot,
                    heartbeat_interval,
                    change_configuration_enabled_1,
                    change_configuration_key_1,
                    change_configuration_value_1,
                    change_configuration_enabled_2,
                    change_configuration_key_2,
                    change_configuration_value_2,
                    change_configuration_enabled_3,
                    change_configuration_key_3,
                    change_configuration_value_3,
                    change_configuration_enabled_4,
                    change_configuration_key_4,
                    change_configuration_value_4,
                    change_configuration_enabled_5,
                    change_configuration_key_5,
                    change_configuration_value_5
                FROM op_server_config
                WHERE id=1
                """
            )
            row = cur.fetchone()
            if row:
                config = _parse_server_config_row(row)
    finally:
        conn.close()

    return render_template(
        'op_mini_cpms_server_startup_config.html',
        config=config,
        success_message=success_message,
        error_message=error_message,
        aside='miniCpmsMenu',
    )


@app.route('/op_mini_cpms_cp_config', methods=['GET', 'POST'])
def mini_cpms_cp_config():
    success_message = None
    error_message = None
    entries: list[dict[str, object]] = []

    conn = get_db_conn()
    try:
        ensure_oicp_enable_table(conn)
        ensure_cp_metadata_table(conn)
        if request.method == 'POST':
            try:
                action = (request.form.get('action') or '').strip()
                if action not in {'update_oicp', 'delete_entry'}:
                    raise ValueError('Unbekannte Aktion.')
                entry_id_raw = (request.form.get('entry_id') or '').strip()
                if not entry_id_raw:
                    raise ValueError('Ungültige Eintrags-ID.')
                try:
                    entry_id = int(entry_id_raw)
                except (TypeError, ValueError) as exc:
                    raise ValueError('Ungültige Eintrags-ID.') from exc
                with conn.cursor() as cur:
                    if action == 'update_oicp':
                        oicp_form_value = (request.form.get('oicp') or '').strip().lower()
                        oicp_enabled = (
                            1
                            if oicp_form_value in {'1', 'on', 'true', 'yes'}
                            else 0
                        )
                        cur.execute(
                            "SELECT chargepoint_id FROM op_server_cp_config WHERE id=%s",
                            (entry_id,),
                        )
                        row = cur.fetchone()
                        if not row:
                            raise ValueError('Eintrag wurde nicht gefunden.')
                        if isinstance(row, dict):
                            chargepoint_id = row.get('chargepoint_id')
                        else:
                            chargepoint_id = row[0]
                        chargepoint_id = _normalize_chargepoint_id(chargepoint_id)
                        if not chargepoint_id:
                            raise ValueError('Chargepoint-ID konnte nicht ermittelt werden.')

                        cur.execute(
                            """
                            INSERT INTO op_server_oicp_enable (chargepoint_id, enabled)
                            VALUES (%s, %s)
                            ON DUPLICATE KEY UPDATE enabled=VALUES(enabled)
                            """,
                            (chargepoint_id, oicp_enabled),
                        )
                        success_message = (
                            'OICP-Flag für {cp} wurde auf {state} gesetzt.'
                        ).format(
                            cp=chargepoint_id,
                            state='aktiv' if oicp_enabled else 'inaktiv',
                        )
                    else:
                        cur.execute(
                            'DELETE FROM op_server_cp_config WHERE id=%s',
                            (entry_id,),
                        )
                        if cur.rowcount == 0:
                            raise ValueError('Eintrag wurde nicht gefunden.')
                        success_message = 'Konfiguration gelöscht.'
                conn.commit()
            except ValueError as exc:
                conn.rollback()
                error_message = str(exc)
            except Exception:
                conn.rollback()
                error_message = 'Fehler beim Aktualisieren der Konfiguration.'
                app.logger.warning(
                    'Failed to update op_server_cp_config entry', exc_info=True
                )
        oicp_flags: dict[str, bool] = {}
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT chargepoint_id, enabled
                FROM op_server_oicp_enable
                """
            )
            raw_flags = cur.fetchall()
        for row in raw_flags:
            if isinstance(row, dict):
                cp_id_raw = row.get('chargepoint_id')
                enabled_raw = row.get('enabled')
            else:
                cp_id_raw = row[0]
                enabled_raw = row[1]
            cp_id = _normalize_chargepoint_id(cp_id_raw)
            if not cp_id:
                continue
            oicp_flags[cp_id] = _coerce_db_bool(enabled_raw)

        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT
                    c.id,
                    c.chargepoint_id,
                    c.created_at
                FROM op_server_cp_config AS c
                ORDER BY c.created_at DESC, c.id DESC
                """
            )
            raw_entries = cur.fetchall()

        entries = []
        for row in raw_entries:
            if isinstance(row, dict):
                entry_id = row.get('id')
                cp_id_raw = row.get('chargepoint_id')
                created_at = row.get('created_at')
            else:
                entry_id = row[0]
                cp_id_raw = row[1]
                created_at = row[2]

            normalized_cp_id = _normalize_chargepoint_id(cp_id_raw)
            display_cp_id = normalized_cp_id or _strip_if_str(cp_id_raw)

            entries.append(
                {
                    'id': entry_id,
                    'chargepoint_id': display_cp_id,
                    'created_at': created_at,
                    'oicp_enabled': oicp_flags.get(normalized_cp_id, False)
                    if normalized_cp_id
                    else False,
                }
            )
    finally:
        conn.close()

    return render_template(
        'op_mini_cpms_cp_config.html',
        entries=entries,
        success_message=success_message,
        error_message=error_message,
        aside='miniCpmsMenu',
    )


def _format_additional_metadata_text(value: str | None) -> str:
    if not value:
        return ''
    try:
        parsed = json.loads(value)
    except Exception:
        return value
    return json.dumps(parsed, ensure_ascii=False, indent=2, sort_keys=True)


def _format_charging_facilities_text(value: str | None) -> str:
    if not value:
        return json.dumps(
            list(DEFAULT_HUBJECT_CHARGING_FACILITIES),
            ensure_ascii=False,
            indent=2,
            sort_keys=True,
        )
    try:
        parsed = json.loads(value)
    except Exception:
        return value
    return json.dumps(parsed, ensure_ascii=False, indent=2, sort_keys=True)


def _normalize_authentication_modes(values: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: set[str] = set()
    for value in values:
        candidate = str(value or "").strip()
        if not candidate:
            continue
        if candidate not in _AUTHENTICATION_MODE_SET:
            continue
        if candidate in seen:
            continue
        seen.add(candidate)
        normalized.append(candidate)
    return normalized


def _normalize_value_added_services(values: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: set[str] = set()
    for value in values:
        candidate = str(value or "").strip()
        if not candidate:
            continue
        canonical = _VALUE_ADDED_SERVICE_NORMALIZATION.get(candidate.lower())
        if not canonical:
            continue
        if canonical in seen:
            continue
        seen.add(canonical)
        normalized.append(canonical)
    return normalized


def _extract_authentication_modes(form: Mapping[str, Any]) -> list[str]:
    values: Iterable[Any]
    if hasattr(form, 'getlist'):
        values = getattr(form, 'getlist')('authentication_modes')
    else:
        raw_value = form.get('authentication_modes')
        if isinstance(raw_value, (list, tuple, set)):
            values = raw_value
        elif raw_value is None:
            values = []
        else:
            values = [raw_value]
    modes = _normalize_authentication_modes(values)
    if not modes:
        return list(DEFAULT_AUTHENTICATION_MODES)
    return modes


def _extract_value_added_services(form: Mapping[str, Any]) -> list[str]:
    values: Iterable[Any]
    if hasattr(form, 'getlist'):
        values = getattr(form, 'getlist')('value_added_services')
    else:
        raw_value = form.get('value_added_services')
        if isinstance(raw_value, (list, tuple, set)):
            values = raw_value
        elif raw_value is None:
            values = []
        else:
            values = [raw_value]
    services = _normalize_value_added_services(values)
    if not services:
        return list(DEFAULT_HUBJECT_VALUE_ADDED_SERVICES)
    return services


def _deserialize_authentication_modes(value: Any) -> list[str]:
    if not value:
        return []
    if isinstance(value, str):
        text = value.strip()
        if not text:
            return []
        try:
            parsed = json.loads(text)
        except (TypeError, ValueError):
            candidates: Iterable[Any] = [part.strip() for part in text.split(',')]
        else:
            if isinstance(parsed, list):
                candidates = parsed
            else:
                candidates = [parsed]
    elif isinstance(value, (list, tuple, set)):
        candidates = value
    else:
        candidates = [value]
    return _normalize_authentication_modes(candidates)


def _deserialize_value_added_services(value: Any) -> list[str]:
    if not value:
        return []
    if isinstance(value, str):
        text = value.strip()
        if not text:
            return []
        try:
            parsed = json.loads(text)
        except (TypeError, ValueError):
            candidates: Iterable[Any] = [part.strip() for part in text.split(',')]
        else:
            if isinstance(parsed, list):
                candidates = parsed
            else:
                candidates = [parsed]
    elif isinstance(value, (list, tuple, set)):
        candidates = value
    else:
        candidates = [value]
    return _normalize_value_added_services(candidates)


def _deserialize_charging_facilities(value: Any) -> list[dict[str, Any]]:
    if not value:
        return list(DEFAULT_HUBJECT_CHARGING_FACILITIES)
    if isinstance(value, str):
        text = value.strip()
        if not text:
            return list(DEFAULT_HUBJECT_CHARGING_FACILITIES)
        try:
            parsed = json.loads(text)
        except (TypeError, ValueError):
            raise ValueError('Charging Facilities enthalten kein gültiges JSON.')
    elif isinstance(value, (list, tuple)):
        parsed = list(value)
    else:
        raise ValueError('Charging Facilities müssen eine Liste sein.')
    facilities: list[dict[str, Any]] = []
    for entry in parsed:
        if isinstance(entry, Mapping):
            facilities.append(dict(entry))
        else:
            raise ValueError('Charging Facilities Einträge müssen Objekte sein.')
    if not facilities:
        return list(DEFAULT_HUBJECT_CHARGING_FACILITIES)
    return facilities


def _split_metadata_values(value: Any) -> list[str]:
    if not value:
        return []
    if isinstance(value, (list, tuple, set)):
        parts: list[str] = []
        for item in value:
            parts.extend(_split_metadata_values(item))
        return parts
    text = str(value).strip()
    if not text:
        return []
    raw_parts = re.split(r"[,;\n]+", text)
    return [part.strip() for part in raw_parts if part and part.strip()]


def _deep_merge_dict(target: dict[str, Any], overrides: Mapping[str, Any]) -> dict[str, Any]:
    for key, value in overrides.items():
        if (
            isinstance(value, Mapping)
            and key in target
            and isinstance(target[key], Mapping)
        ):
            _deep_merge_dict(target[key], value)
        else:
            target[key] = value
    return target


def _build_hubject_evse_payload(
    persisted: Mapping[str, Any],
    *,
    action: str,
) -> tuple[dict[str, Any], str]:
    additional_overrides: dict[str, Any] = {}
    additional_raw = persisted.get("additional_data")
    if additional_raw:
        try:
            parsed = json.loads(additional_raw)
        except (TypeError, ValueError) as exc:
            raise ValueError(
                f"Additional Data konnte nicht gelesen werden: {exc}"
            ) from exc
        if isinstance(parsed, dict):
            additional_overrides = parsed
        else:
            raise ValueError("Additional Data muss ein JSON-Objekt sein.")

    operator_id = str(persisted.get("operator_id") or "").strip()
    evse_id = str(persisted.get("evse_id") or "").strip()
    latitude_raw = persisted.get("latitude")
    longitude_raw = persisted.get("longitude")
    address_country = str(persisted.get("address_country") or "").strip()
    address_city = str(persisted.get("address_city") or "").strip()
    address_street = str(persisted.get("address_street") or "").strip()
    address_postal_code = str(persisted.get("address_postal_code") or "").strip()

    missing_fields: list[str] = []
    if not operator_id:
        missing_fields.append("Operator ID")
    if not evse_id:
        missing_fields.append("EVSE ID")
    if not latitude_raw:
        missing_fields.append("Latitude")
    if not longitude_raw:
        missing_fields.append("Longitude")
    if not address_country:
        missing_fields.append("Land (ISO)")
    if not address_city:
        missing_fields.append("Ort")
    if not address_street:
        missing_fields.append("Straße")
    if not address_postal_code:
        missing_fields.append("PLZ")

    if missing_fields:
        joined = ", ".join(missing_fields)
        raise ValueError(f"Pflichtfelder fehlen: {joined}.")

    try:
        latitude = float(latitude_raw)
    except (TypeError, ValueError) as exc:
        raise ValueError("Latitude muss eine gültige Zahl sein.") from exc
    try:
        longitude = float(longitude_raw)
    except (TypeError, ValueError) as exc:
        raise ValueError("Longitude muss eine gültige Zahl sein.") from exc

    plugs = _split_metadata_values(persisted.get("plugs")) or _split_metadata_values(
        DEFAULT_PLUGS
    )
    if not plugs:
        raise ValueError("Mindestens ein Steckertyp muss angegeben sein.")

    authentication_modes = _deserialize_authentication_modes(
        persisted.get("authentication_modes")
    )
    if not authentication_modes:
        authentication_modes = list(DEFAULT_AUTHENTICATION_MODES)

    operator_name = str(persisted.get("operator_name") or "").strip()
    charging_station_name_de = str(
        persisted.get("charging_station_name_de") or ""
    ).strip()
    charging_station_name_en = str(
        persisted.get("charging_station_name_en") or ""
    ).strip()
    accessibility = str(persisted.get("accessibility") or DEFAULT_ACCESSIBILITY).strip()
    accessibility_location = str(
        persisted.get("accessibility_location") or DEFAULT_ACCESSIBILITY_LOCATION
    ).strip()

    value_added_services_raw = persisted.get("value_added_services")
    try:
        value_added_services = _deserialize_value_added_services(
            value_added_services_raw
        )
    except ValueError as exc:
        raise ValueError(str(exc))
    if not value_added_services:
        raise ValueError("Value Added Services dürfen nicht leer sein.")

    charging_facilities_raw = persisted.get("charging_facilities")
    try:
        charging_facilities = _deserialize_charging_facilities(
            charging_facilities_raw
        )
    except ValueError as exc:
        raise ValueError(str(exc))
    if not charging_facilities:
        raise ValueError("Charging Facilities dürfen nicht leer sein.")

    charging_station_names: list[dict[str, str]] = []
    if charging_station_name_de:
        charging_station_names.append({"lang": "de", "value": charging_station_name_de})
    if charging_station_name_en:
        charging_station_names.append({"lang": "en", "value": charging_station_name_en})

    address = {
        "Country": address_country,
        "City": address_city,
        "Street": address_street,
        "PostalCode": address_postal_code,
    }
    address_house_number = str(persisted.get("address_house_number") or "").strip()
    if address_house_number:
        address["HouseNum"] = address_house_number

    geo_coordinates = {
        "DecimalDegree": {
            "Latitude": latitude,
            "Longitude": longitude,
        }
    }

    evse_record: dict[str, Any] = {
        "EvseID": evse_id,
        "EvseStatus": DEFAULT_HUBJECT_EVSE_STATUS,
        "Address": address,
        "GeoCoordinates": geo_coordinates,
        "Accessibility": accessibility,
        "AccessibilityLocation": accessibility_location,
        "Plugs": plugs,
        "AuthenticationModes": authentication_modes,
        "RenewableEnergy": DEFAULT_HUBJECT_RENEWABLE_ENERGY,
        "CalibrationLawDataAvailability": DEFAULT_HUBJECT_CALIBRATION_DATA_AVAILABILITY,
        "PaymentOptions": _normalize_payment_options(DEFAULT_HUBJECT_PAYMENT_OPTIONS),
        "ValueAddedServices": value_added_services,
        "ChargingFacilities": charging_facilities,
        "HotlinePhoneNumber": DEFAULT_HUBJECT_HOTLINE,
        "IsOpen24Hours": DEFAULT_HUBJECT_IS_OPEN_24H,
        "IsHubjectCompatible": DEFAULT_HUBJECT_IS_COMPATIBLE,
        "DynamicInfoAvailable": DEFAULT_HUBJECT_DYNAMIC_INFO_AVAILABLE,
    }
    if charging_station_names:
        evse_record["ChargingStationNames"] = charging_station_names

    evse_override = additional_overrides.get("evse_record")
    if isinstance(evse_override, Mapping):
        evse_record = _deep_merge_dict(dict(evse_record), evse_override)

    evse_record["PaymentOptions"] = _normalize_payment_options(
        evse_record.get("PaymentOptions")
    )

    operator_payload: dict[str, Any] = {
        "OperatorID": operator_id,
        "EvseData": [evse_record],
    }
    if operator_name:
        operator_payload["OperatorName"] = {"En": operator_name, "De": operator_name}

    operator_override = additional_overrides.get("operator")
    if isinstance(operator_override, Mapping):
        operator_payload = _deep_merge_dict(dict(operator_payload), operator_override)

    payload: dict[str, Any] = {
        "ActionType": action,
        "OperatorID": operator_payload.get("OperatorID", operator_id),
        "OperatorEvseData": [operator_payload],
    }

    payload_override = additional_overrides.get("payload")
    if isinstance(payload_override, Mapping):
        payload = _deep_merge_dict(dict(payload), payload_override)

    effective_operator_id = str(payload.get("OperatorID") or "").strip()
    if not effective_operator_id:
        operator_entries = payload.get("OperatorEvseData")
        if isinstance(operator_entries, list) and operator_entries:
            first_entry = operator_entries[0]
            if isinstance(first_entry, Mapping):
                candidate = str(first_entry.get("OperatorID") or "").strip()
                if candidate:
                    effective_operator_id = candidate
    if not effective_operator_id:
        effective_operator_id = operator_id

    return payload, effective_operator_id


def _build_hubject_evse_status_payload(
    metadata_row: Mapping[str, Any],
    *,
    chargepoint_id: str,
    status: str,
    action: str,
) -> tuple[dict[str, Any], str]:
    operator_id = str(metadata_row.get("operator_id") or "").strip()
    if not operator_id:
        raise ValueError("Operator ID ist nicht konfiguriert")

    evse_id = str(metadata_row.get("evse_id") or "").strip() or chargepoint_id
    operator_name = metadata_row.get("operator_name")

    normalized_status = str(status or "").strip()
    if not normalized_status:
        raise ValueError("EVSE-Status muss ausgewählt werden")

    allowed_status_values = {value for value, _ in HUBJECT_EVSE_STATUS_CHOICES}
    if normalized_status not in allowed_status_values:
        raise ValueError("Ungültiger EVSE-Status ausgewählt")

    additional_data_raw = metadata_row.get("additional_data")
    extra_payload: dict[str, Any] = {}
    operator_overrides: dict[str, Any] = {}
    evse_overrides: dict[str, Any] = {}
    if additional_data_raw:
        try:
            parsed_additional = json.loads(additional_data_raw)
        except (TypeError, ValueError) as exc:
            raise ValueError(
                "Zusätzliche Hubject Felder enthalten ungültiges JSON"
            ) from exc
        else:
            if isinstance(parsed_additional, Mapping):
                payload_data = parsed_additional.get("payload")
                if isinstance(payload_data, Mapping):
                    extra_payload = dict(payload_data)
                operator_data = parsed_additional.get("operator")
                if isinstance(operator_data, Mapping):
                    operator_overrides = dict(operator_data)
                evse_data = parsed_additional.get("evse_record")
                if isinstance(evse_data, Mapping):
                    evse_overrides = dict(evse_data)

    evse_record: dict[str, Any] = {
        "EvseID": evse_id,
        "EvseStatus": normalized_status,
    }
    if evse_overrides:
        evse_record.update(evse_overrides)

    if "LastUpdate" not in evse_record:
        timestamp = datetime.datetime.now(datetime.timezone.utc)
        evse_record["LastUpdate"] = (
            timestamp.isoformat().replace("+00:00", "Z")
        )

    operator_entry: dict[str, Any] = {
        "OperatorID": operator_id,
        "EvseStatusRecords": [evse_record],
    }
    if operator_name:
        operator_entry["OperatorName"] = operator_name
    if operator_overrides:
        operator_entry.update(operator_overrides)

    payload: dict[str, Any] = {
        "ActionType": action,
        "OperatorEvseStatus": [operator_entry],
    }
    if extra_payload:
        payload.update(extra_payload)

    effective_operator_id = str(payload.get("OperatorID") or "").strip()
    if not effective_operator_id:
        operator_entries = payload.get("OperatorEvseStatus")
        if isinstance(operator_entries, list) and operator_entries:
            first_entry = operator_entries[0]
            if isinstance(first_entry, Mapping):
                candidate = str(first_entry.get("OperatorID") or "").strip()
                if candidate:
                    effective_operator_id = candidate
    if not effective_operator_id:
        effective_operator_id = operator_id

    payload.setdefault("OperatorID", effective_operator_id)
    return payload, effective_operator_id


def _parse_response_timestamp(value: Any) -> datetime.datetime | None:
    if isinstance(value, datetime.datetime):
        result = value
        if result.tzinfo is not None:
            try:
                result = result.astimezone(datetime.timezone.utc)
            except (OSError, ValueError):
                result = result.replace(tzinfo=None)
            else:
                result = result.replace(tzinfo=None)
        return result
    if not isinstance(value, str):
        return None
    text = value.strip()
    if not text:
        return None
    normalized = text.replace("Z", "+00:00")
    try:
        parsed = datetime.datetime.fromisoformat(normalized)
    except ValueError:
        return None
    if parsed.tzinfo is not None:
        try:
            parsed = parsed.astimezone(datetime.timezone.utc)
        except (OSError, ValueError):
            parsed = parsed.replace(tzinfo=None)
        else:
            parsed = parsed.replace(tzinfo=None)
    return parsed


def _extract_push_status(
    response: Mapping[str, Any] | None,
) -> tuple[str | None, datetime.datetime | None, bool | None]:
    status_text: str | None = None
    timestamp: datetime.datetime | None = None
    success: bool | None = None

    if isinstance(response, Mapping):
        status_code = response.get("StatusCode") or response.get("statusCode")
        if isinstance(status_code, Mapping):
            code = str(status_code.get("Code") or "").strip()
            description = str(status_code.get("Description") or "").strip()
            additional = status_code.get("AdditionalInfo")
            status_parts = [part for part in (code, description) if part]
            status_text = " – ".join(status_parts) if status_parts else None
            if isinstance(additional, str) and additional.strip():
                status_text = (status_text or "Status") + f" ({additional.strip()})"

        timestamp = _parse_response_timestamp(
            response.get("Timestamp") or response.get("timestamp")
        )

        if "Result" in response:
            result_value = response.get("Result")
            if isinstance(result_value, str):
                normalized = result_value.strip().lower()
                if normalized in {"true", "1", "yes", "ok"}:
                    success = True
                elif normalized in {"false", "0", "no"}:
                    success = False
                else:
                    success = None
            else:
                try:
                    success = bool(result_value)
                except Exception:
                    success = None

    return status_text, timestamp, success


def _persist_cp_push_result(
    conn,
    chargepoint_id: str,
    *,
    record_type: str,
    action: str | None,
    status_value: str | None = None,
    status_text: str | None = None,
    success: bool | None = None,
    timestamp: datetime.datetime | None = None,
):
    if record_type not in {"data", "status"}:
        return

    fields: dict[str, Any] = {}
    if timestamp is None:
        timestamp = datetime.datetime.utcnow()

    if record_type == "data":
        fields["last_data_push_at"] = timestamp
        fields["last_data_push_action"] = action
        fields["last_data_push_status"] = status_text
        fields["last_data_push_success"] = success
    else:
        fields["last_status_push_at"] = timestamp
        fields["last_status_push_action"] = action
        fields["last_status_push_status"] = status_text
        fields["last_status_push_success"] = success
        if status_value is not None:
            fields["last_status_value"] = status_value

    set_clause = ", ".join([f"{column}=%s" for column in fields])
    params: list[Any] = list(fields.values()) + [chargepoint_id]
    with conn.cursor() as cur:
        cur.execute(
            f"UPDATE cp_server_cp_metadata SET {set_clause} WHERE chargepoint_id=%s",
            params,
        )
    conn.commit()


def _push_hubject_payload(
    operator_id: str,
    payload: Mapping[str, Any],
    *,
    record_type: str,
) -> Mapping[str, Any]:
    operator = str(operator_id or "").strip()
    if not operator:
        raise ValueError("Operator ID ist nicht konfiguriert")
    operator_path = quote(operator, safe="*")
    if record_type == "data":
        endpoint = f"/api/oicp/evsepush/v23/operators/{operator_path}/data-records"
    elif record_type == "status":
        endpoint = (
            f"/api/oicp/evsepush/v21/operators/{operator_path}/status-records"
        )
    else:
        raise ValueError("Unbekannter Hubject-Push-Typ.")

    response_payload = _post_ocpp_command(endpoint, payload)
    if not isinstance(response_payload, Mapping):
        raise ValueError("Ungültige Antwort vom OICP-Service erhalten.")
    return response_payload


def _format_hubject_action_display(action: str) -> str:
    normalized = str(action or "").strip()
    mapping = {
        "insert": "Insert",
        "update": "Update",
        "fullload": "Full Load",
        "fullLoad": "Full Load",
        "delta": "Delta",
    }
    lower = normalized.lower()
    if lower in mapping:
        return mapping[lower]
    if normalized:
        return normalized
    return "Update"


@dataclass
class HubjectPushTarget:
    url: str
    timeout: float
    verify: bool | str
    cert: tuple[str, str] | None = None
    authorization: str | None = None


HUBJECT_CERT_CONFIG_KEYS = {
    "client_cert": "hubject_api_gw_client_cert",
    "client_key": "hubject_api_gw_client_key",
    "ca_bundle": "hubject_api_gw_ca_bundle",
}

HUBJECT_DEFAULT_CERT_FILENAMES = {
    "client_cert": "hubject_client_cert.pem",
    "client_key": "hubject_client_key.pem",
    "ca_bundle": "hubject_ca_bundle.pem",
}

MAX_HUBJECT_CERT_FILE_SIZE = 2 * 1024 * 1024  # 2 MiB safety cap


def _resolve_hubject_path(value: Any) -> Path | None:
    if value in {None, "", "None"}:
        return None
    if isinstance(value, Path):
        candidate = value
    else:
        candidate_text = str(value).strip()
        if not candidate_text:
            return None
        candidate = Path(candidate_text)
    candidate = candidate.expanduser()
    if not candidate.is_absolute():
        candidate = (_CONFIG_BASE_DIR / candidate).expanduser()
    return candidate


def _hubject_cert_path(kind: str) -> Path:
    config_key = HUBJECT_CERT_CONFIG_KEYS.get(kind)
    default_filename = HUBJECT_DEFAULT_CERT_FILENAMES.get(kind, f"hubject_{kind}.pem")
    configured_path = None
    if config_key:
        configured_path = get_config_value(config_key)
        if configured_path in {None, "", "None"}:
            configured_path = None
    if configured_path is None:
        configured_path = _hubject_cfg.get(kind)

    path = _resolve_hubject_path(configured_path)
    if path is None:
        path = (_CONFIG_BASE_DIR / "certs" / default_filename).expanduser()
    return path


def _store_hubject_cert_path(kind: str, path: Path) -> None:
    config_key = HUBJECT_CERT_CONFIG_KEYS.get(kind)
    if not config_key:
        return
    try:
        relative_path = path.relative_to(_CONFIG_BASE_DIR)
        value = str(relative_path)
    except ValueError:
        value = str(path)
    set_config_value(config_key, value)


def _validate_and_store_hubject_upload(
    artifact: str, upload: FileStorage | None
) -> tuple[str | None, str | None]:
    artifact = (artifact or "").strip()
    if artifact not in {"client_cert", "client_key", "ca_bundle"}:
        return "Unbekannter Zertifikatstyp.", None

    if upload is None or not upload.filename:
        return "Bitte eine Datei auswählen.", None

    filename = secure_filename(upload.filename)
    extension = os.path.splitext(filename)[1].lower()
    if artifact == "client_key":
        allowed_extensions = {".pem", ".key"}
    else:
        allowed_extensions = {".pem", ".crt", ".cer", ".der"}
    if extension not in allowed_extensions:
        return (
            "Ungültiges Dateiformat. Erlaubt sind: "
            + ", ".join(sorted(ext.lstrip(".") for ext in allowed_extensions)),
            None,
        )

    upload.stream.seek(0, os.SEEK_END)
    size = upload.stream.tell()
    upload.stream.seek(0)
    if size == 0:
        return "Die Datei ist leer.", None
    if size > MAX_HUBJECT_CERT_FILE_SIZE:
        max_mb = MAX_HUBJECT_CERT_FILE_SIZE // (1024 * 1024)
        return f"Die Datei überschreitet die maximale Größe von {max_mb} MB.", None

    target_path = _hubject_cert_path(artifact)
    target_path.parent.mkdir(parents=True, exist_ok=True)
    try:
        upload.save(target_path)
        _store_hubject_cert_path(artifact, target_path)
    except OSError as exc:
        return f"Datei konnte nicht gespeichert werden: {exc}", None

    label = artifact.replace("_", " ").title()
    return None, f"{label} gespeichert: {target_path}"


def _certificate_metadata(path: Path) -> dict[str, Any]:
    metadata = {
        "path": str(path),
        "exists": path.exists() and path.is_file(),
        "expires": None,
        "subject": None,
        "error": None,
        "readable": False,
        "last_modified": None,
    }
    if not metadata["exists"]:
        return metadata

    try:
        stat = path.stat()
        metadata["readable"] = os.access(path, os.R_OK)
        metadata["last_modified"] = datetime.datetime.fromtimestamp(stat.st_mtime)
    except OSError:
        metadata["readable"] = False

    try:
        decoded = ssl._ssl._test_decode_cert(str(path))  # type: ignore[attr-defined]
        not_after = decoded.get("notAfter")
        if isinstance(not_after, str) and not_after.strip():
            try:
                parsed = datetime.datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
                metadata["expires"] = parsed
            except (ValueError, TypeError):
                metadata["expires"] = not_after
        subject_parts = []
        for rdn in decoded.get("subject", []) or []:
            for entry in rdn:
                if isinstance(entry, (tuple, list)) and len(entry) >= 2:
                    subject_parts.append(f"{entry[0]}={entry[1]}")
        if subject_parts:
            metadata["subject"] = ", ".join(subject_parts)
    except Exception as exc:  # pragma: no cover - defensive parsing
        metadata["error"] = str(exc)

    return metadata


def _resolve_hubject_push_endpoint(
    operator_id: str, *, record_type: str = "data"
) -> HubjectPushTarget:
    base_url_raw = get_config_value("hubject_api_gw")
    if not base_url_raw:
        base_url_raw = _hubject_cfg.get("api_base_url")
    if not base_url_raw:
        raise ValueError(
            "Hubject API Gateway URL (hubject_api_gw) ist nicht konfiguriert."
        )
    base_url = str(base_url_raw).strip()
    try:
        parsed = urlsplit(base_url)
    except ValueError as exc:
        raise ValueError("Hubject API Gateway URL ist ungültig.") from exc
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise ValueError("Hubject API Gateway URL ist ungültig.")

    path_segments = [segment for segment in parsed.path.split("/") if segment]
    trimmed_segments = path_segments
    has_api_oicp = False
    for index in range(len(path_segments) - 1):
        if path_segments[index : index + 2] == ["api", "oicp"]:
            has_api_oicp = True
            trimmed_segments = path_segments[: index + 2]
            break
    if not has_api_oicp:
        trimmed_segments = path_segments + ["api", "oicp"]

    normalized_path = "/" + "/".join(trimmed_segments) + "/"
    normalized_base = urlunsplit(
        (parsed.scheme, parsed.netloc, normalized_path, "", "")
    )
    operator_path = quote(str(operator_id or "").strip(), safe="*")
    if not operator_path:
        raise ValueError("Operator ID für Hubject-Push fehlt.")
    record_key = record_type.strip().lower() if isinstance(record_type, str) else "data"
    if record_key == "data":
        suffix = "data-records"
        version = "v23"
    elif record_key == "status":
        suffix = "status-records"
        version = "v21"
    else:
        raise ValueError("Unbekannter Hubject-Push-Typ.")

    path = f"evsepush/{version}/operators/{operator_path}/{suffix}"
    request_url = urljoin(normalized_base, path)

    timeout: float = DEFAULT_HUBJECT_TIMEOUT_SECONDS
    timeout_raw = get_config_value("hubject_api_gw_timeout")
    timeout_source = timeout_raw
    if timeout_source in {None, "", "None"}:
        timeout_source = _hubject_cfg.get("timeout")
    if timeout_source not in {None, "", "None"}:
        try:
            timeout = float(timeout_source)
        except (TypeError, ValueError):
            timeout = DEFAULT_HUBJECT_TIMEOUT_SECONDS

    verify: bool | str
    verify_value = get_config_value("hubject_api_gw_verify_tls")
    if isinstance(verify_value, str) and verify_value.strip().lower() in {"", "none", "null"}:
        verify_value = None
    ca_bundle_raw = get_config_value("hubject_api_gw_ca_bundle")
    if not ca_bundle_raw:
        ca_bundle_raw = _hubject_cfg.get("ca_bundle")
    ca_bundle_path = _resolve_hubject_path(ca_bundle_raw)
    if ca_bundle_path is not None:
        verify = str(ca_bundle_path)
    elif verify_value is None:
        verify = True
    else:
        verify = _parse_bool_config(verify_value)

    client_cert_raw = get_config_value("hubject_api_gw_client_cert")
    client_key_raw = get_config_value("hubject_api_gw_client_key")
    if not client_cert_raw:
        client_cert_raw = _hubject_cfg.get("client_cert")
    if not client_key_raw:
        client_key_raw = _hubject_cfg.get("client_key")
    cert_path = _resolve_hubject_path(client_cert_raw)
    key_path = _resolve_hubject_path(client_key_raw)
    cert_pair: tuple[str, str] | None = None
    if cert_path and key_path:
        cert_pair = (str(cert_path), str(key_path))

    authorization_raw = get_config_value("hubject_api_gw_authorization")
    if not authorization_raw:
        authorization_raw = _hubject_cfg.get("authorization")
    if isinstance(authorization_raw, str) and authorization_raw.strip().lower() in {"", "none", "null"}:
        authorization_raw = None
    authorization = str(authorization_raw).strip() if authorization_raw else None
    if authorization == "":
        authorization = None

    return HubjectPushTarget(
        url=request_url,
        timeout=timeout,
        verify=verify,
        cert=cert_pair,
        authorization=authorization,
    )


def _collect_hubject_certificate_state() -> dict[str, dict[str, Any]]:
    certs = {}
    for kind in ("client_cert", "client_key", "ca_bundle"):
        path = _hubject_cert_path(kind)
        metadata = _certificate_metadata(path)
        certs[kind] = {
            **metadata,
            "filename": path.name,
            "directory": str(path.parent),
        }
    return certs


def _empty_cp_metadata_form(chargepoint_id: str) -> Dict[str, Any]:
    return {
        'chargepoint_id': chargepoint_id,
        'operator_id': '',
        'operator_name': '',
        'evse_id': '',
        'latitude': '',
        'longitude': '',
        'address_country': '',
        'address_city': '',
        'address_street': '',
        'address_house_number': '',
        'address_postal_code': '',
        'charging_station_name_de': '',
        'charging_station_name_en': '',
        'charging_station_image': '',
        'additional_data': '',
        'default_action_type': 'update',
        'accessibility': DEFAULT_ACCESSIBILITY,
        'accessibility_location': DEFAULT_ACCESSIBILITY_LOCATION,
        'authentication_modes': list(DEFAULT_AUTHENTICATION_MODES),
        'plugs': DEFAULT_PLUGS,
        'value_added_services': list(DEFAULT_HUBJECT_VALUE_ADDED_SERVICES),
        'charging_facilities': json.dumps(
            list(DEFAULT_HUBJECT_CHARGING_FACILITIES),
            ensure_ascii=False,
            indent=2,
            sort_keys=True,
        ),
        'last_data_push_at': '-',
        'last_data_push_action': '',
        'last_data_push_status': '',
        'last_data_push_success': None,
        'last_status_push_at': '-',
        'last_status_push_action': '',
        'last_status_push_status': '',
        'last_status_push_success': None,
        'last_status_value': '',
    }


def _row_to_cp_metadata_form(
    chargepoint_id: str, row: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
    form = _empty_cp_metadata_form(chargepoint_id)
    if not row:
        return form

    row_dict = row if isinstance(row, dict) else {}

    def _clean(value: Any, fallback: str = "") -> str:
        if value is None:
            return fallback
        cleaned = str(value).strip()
        return cleaned or fallback

    existing_modes = _deserialize_authentication_modes(
        row_dict.get('authentication_modes')
    )
    if not existing_modes:
        existing_modes = list(DEFAULT_AUTHENTICATION_MODES)

    form.update(
        {
            'operator_id': _clean(row_dict.get('operator_id')),
            'operator_name': _clean(row_dict.get('operator_name')),
            'evse_id': _clean(row_dict.get('evse_id')),
            'latitude': _format_decimal_for_input(row_dict.get('latitude')),
            'longitude': _format_decimal_for_input(row_dict.get('longitude')),
            'address_country': _clean(row_dict.get('address_country')),
            'address_city': _clean(row_dict.get('address_city')),
            'address_street': _clean(row_dict.get('address_street')),
            'address_house_number': _clean(row_dict.get('address_house_number')),
            'address_postal_code': _clean(row_dict.get('address_postal_code')),
            'charging_station_name_de': _clean(row_dict.get('charging_station_name_de')),
            'charging_station_name_en': _clean(row_dict.get('charging_station_name_en')),
            'charging_station_image': _clean(row_dict.get('charging_station_image')),
            'additional_data': _format_additional_metadata_text(
                row_dict.get('additional_data')
            ),
            'default_action_type': _clean(
                row_dict.get('default_action_type'), 'update'
            )
            or 'update',
            'accessibility': _normalize_accessibility_value(
                row_dict.get('accessibility')
            ),
            'accessibility_location': _normalize_accessibility_location_value(
                row_dict.get('accessibility_location')
            ),
            'authentication_modes': existing_modes,
            'plugs': _normalize_plug_type(row_dict.get('plugs')),
            'value_added_services': _deserialize_value_added_services(
                row_dict.get('value_added_services')
            )
            or list(DEFAULT_HUBJECT_VALUE_ADDED_SERVICES),
            'charging_facilities': _format_charging_facilities_text(
                row_dict.get('charging_facilities')
            ),
            'last_data_push_at': _format_timestamp(
                row_dict.get('last_data_push_at')
            ),
            'last_data_push_action': _clean(row_dict.get('last_data_push_action')),
            'last_data_push_status': _clean(row_dict.get('last_data_push_status')),
            'last_data_push_success': row_dict.get('last_data_push_success'),
            'last_status_push_at': _format_timestamp(
                row_dict.get('last_status_push_at')
            ),
            'last_status_push_action': _clean(row_dict.get('last_status_push_action')),
            'last_status_push_status': _clean(
                row_dict.get('last_status_push_status')
            ),
            'last_status_push_success': row_dict.get('last_status_push_success'),
            'last_status_value': _clean(row_dict.get('last_status_value')),
        }
    )
    return form


def _parse_cp_metadata_form(
    form: Mapping[str, Optional[Any]],
    existing_default_action_type: Optional[str] = None,
) -> Tuple[Dict[str, Optional[Any]], Dict[str, Any]]:
    operator_id = (form.get('operator_id') or '').strip() or None
    operator_name = (form.get('operator_name') or '').strip() or None
    evse_id = (form.get('evse_id') or '').strip() or None

    latitude_raw = (form.get('latitude') or '').strip()
    latitude_value = None
    if latitude_raw:
        latitude_value = _safe_decimal(latitude_raw)
        if latitude_value is None:
            raise ValueError('Latitude muss eine gültige Zahl sein.')
        if latitude_value < Decimal('-90') or latitude_value > Decimal('90'):
            raise ValueError('Latitude muss zwischen -90 und 90 liegen.')

    longitude_raw = (form.get('longitude') or '').strip()
    longitude_value = None
    if longitude_raw:
        longitude_value = _safe_decimal(longitude_raw)
        if longitude_value is None:
            raise ValueError('Longitude muss eine gültige Zahl sein.')
        if longitude_value < Decimal('-180') or longitude_value > Decimal('180'):
            raise ValueError('Longitude muss zwischen -180 und 180 liegen.')

    address_country_raw = (form.get('address_country') or '').strip()
    if not address_country_raw:
        raise ValueError('address_country muss angegeben werden.')
    address_country = _normalize_country_code(address_country_raw)

    address_city = (form.get('address_city') or '').strip() or None
    address_street = (form.get('address_street') or '').strip() or None
    address_house_number = (form.get('address_house_number') or '').strip() or None
    address_postal_code = (form.get('address_postal_code') or '').strip() or None
    charging_station_name_de = (form.get('charging_station_name_de') or '').strip()
    if not charging_station_name_de:
        raise ValueError('Charging Station Name (DE) muss ausgefüllt werden.')
    charging_station_name_en = (form.get('charging_station_name_en') or '').strip()
    if not charging_station_name_en:
        raise ValueError('Charging Station Name (EN) muss ausgefüllt werden.')
    charging_station_name_de = charging_station_name_de or None
    charging_station_name_en = charging_station_name_en or None
    charging_station_image = (form.get('charging_station_image') or '').strip() or None

    accessibility_raw = str(form.get('accessibility') or '').strip()
    if accessibility_raw:
        normalized_accessibility = _ACCESSIBILITY_NORMALIZATION.get(
            accessibility_raw.lower()
        )
        if not normalized_accessibility:
            raise ValueError('Ungültige Accessibility-Auswahl.')
        accessibility = normalized_accessibility
    else:
        accessibility = DEFAULT_ACCESSIBILITY

    accessibility_location_raw = str(
        form.get('accessibility_location') or ''
    ).strip()
    if accessibility_location_raw:
        normalized_location = _ACCESSIBILITY_LOCATION_NORMALIZATION.get(
            accessibility_location_raw.lower()
        )
        if not normalized_location:
            raise ValueError('Ungültige Accessibility Location-Auswahl.')
        accessibility_location = normalized_location
    else:
        accessibility_location = DEFAULT_ACCESSIBILITY_LOCATION
    plugs = _normalize_plug_type(form.get('plugs'), strict=True)

    authentication_modes = _extract_authentication_modes(form)
    authentication_modes_db = json.dumps(authentication_modes, ensure_ascii=False)

    value_added_services = _extract_value_added_services(form)
    if not value_added_services:
        raise ValueError('Mindestens ein Value Added Service muss ausgewählt werden.')
    value_added_services_db = json.dumps(value_added_services, ensure_ascii=False)

    additional_data_raw = (form.get('additional_data') or '').strip()
    additional_data_db = None
    additional_data_display = ''
    if additional_data_raw:
        try:
            parsed = json.loads(additional_data_raw)
        except json.JSONDecodeError as exc:
            raise ValueError(f'Additional Data ist kein gültiges JSON: {exc}')
        additional_data_db = json.dumps(parsed, ensure_ascii=False)
        additional_data_display = json.dumps(
            parsed, ensure_ascii=False, indent=2, sort_keys=True
        )

    charging_facilities_raw = (form.get('charging_facilities') or '').strip()
    if not charging_facilities_raw:
        raise ValueError('Charging Facilities müssen angegeben werden.')
    try:
        charging_facilities_parsed = json.loads(charging_facilities_raw)
    except json.JSONDecodeError as exc:
        raise ValueError(f'Charging Facilities ist kein gültiges JSON: {exc}')
    if not isinstance(charging_facilities_parsed, list) or not charging_facilities_parsed:
        raise ValueError('Charging Facilities müssen eine nicht-leere Liste sein.')
    required_facility_keys = (
        'ChargingFacilityStatus',
        'PowerType',
        'Power',
    )
    validated_facilities: list[dict[str, Any]] = []
    for index, facility in enumerate(charging_facilities_parsed, start=1):
        if not isinstance(facility, Mapping):
            raise ValueError(
                f'Charging Facility #{index} muss ein JSON-Objekt sein.'
            )
        missing_keys = [
            key
            for key in required_facility_keys
            if not str(facility.get(key) or '').strip()
        ]
        if missing_keys:
            missing_list = ', '.join(missing_keys)
            raise ValueError(
                f'Charging Facility #{index} fehlt die Angabe für: {missing_list}.'
            )
        validated_facilities.append(dict(facility))
    charging_facilities_db = json.dumps(validated_facilities, ensure_ascii=False)
    charging_facilities_display = json.dumps(
        validated_facilities, ensure_ascii=False, indent=2, sort_keys=True
    )

    default_action_raw = str(form.get('default_action_type') or '').strip()
    if not default_action_raw and existing_default_action_type:
        default_action_raw = str(existing_default_action_type).strip()
    if not default_action_raw:
        default_action_raw = 'update'
    action_map = {
        'fullload': 'fullLoad',
        'fullloads': 'fullLoad',
        'fullload ': 'fullLoad',
        'full_load': 'fullLoad',
        'full load': 'fullLoad',
        'full': 'fullLoad',
        'update': 'update',
        'delta': 'delta',
    }
    normalized_action_key = default_action_raw.lower()
    default_action_type = action_map.get(normalized_action_key)
    if not default_action_type:
        if normalized_action_key in {'fullload', 'full'}:
            default_action_type = 'fullLoad'
        else:
            # Accept already proper camelCase values
            normalized_exact = default_action_raw
            if normalized_exact in {'fullLoad', 'update', 'delta'}:
                default_action_type = normalized_exact
            else:
                raise ValueError('Ungültiger Action Type für OICP Sync.')

    persisted: Dict[str, Optional[Any]] = {
        'operator_id': operator_id,
        'operator_name': operator_name,
        'evse_id': evse_id,
        'latitude': str(latitude_value) if latitude_value is not None else None,
        'longitude': str(longitude_value) if longitude_value is not None else None,
        'address_country': address_country,
        'address_city': address_city,
        'address_street': address_street,
        'address_house_number': address_house_number,
        'address_postal_code': address_postal_code,
        'charging_station_name_de': charging_station_name_de,
        'charging_station_name_en': charging_station_name_en,
        'charging_station_image': charging_station_image,
        'additional_data': additional_data_db,
        'default_action_type': default_action_type,
        'accessibility': accessibility,
        'accessibility_location': accessibility_location,
        'authentication_modes': authentication_modes_db,
        'plugs': plugs,
        'value_added_services': value_added_services_db,
        'charging_facilities': charging_facilities_db,
    }

    formatted: Dict[str, Any] = {
        'chargepoint_id': '',  # filled by caller
        'operator_id': operator_id or '',
        'operator_name': operator_name or '',
        'evse_id': evse_id or '',
        'latitude': _format_decimal_for_input(latitude_value),
        'longitude': _format_decimal_for_input(longitude_value),
        'address_country': address_country or '',
        'address_city': address_city or '',
        'address_street': address_street or '',
        'address_house_number': address_house_number or '',
        'address_postal_code': address_postal_code or '',
        'charging_station_name_de': charging_station_name_de or '',
        'charging_station_name_en': charging_station_name_en or '',
        'charging_station_image': charging_station_image or '',
        'additional_data': additional_data_display,
        'default_action_type': default_action_type,
        'accessibility': accessibility,
        'accessibility_location': accessibility_location,
        'authentication_modes': authentication_modes,
        'plugs': plugs,
        'value_added_services': value_added_services,
        'charging_facilities': charging_facilities_display,
    }

    return persisted, formatted


@app.route('/op_mini_cpms_cp_metadata/<path:chargepoint_id>', methods=['GET', 'POST'])
def mini_cpms_cp_metadata(chargepoint_id: str):
    normalized_id = normalize_station_id(chargepoint_id)
    success_message = None
    error_message = None
    sync_success_message = None
    sync_error_message = None
    sync_response: object | None = None
    sync_status_text: str | None = None

    form_values = _empty_cp_metadata_form(normalized_id)
    form_values['chargepoint_id'] = normalized_id

    conn = get_db_conn()
    try:
        ensure_cp_metadata_table(conn)

        existing_default_action_type = None
        existing_metadata_row: Mapping[str, Any] | None = None
        if request.method == 'POST':
            with conn.cursor() as cur:
                cur.execute(
                    "SELECT * FROM cp_server_cp_metadata WHERE chargepoint_id=%s",
                    (normalized_id,),
                )
                existing_metadata_row = cur.fetchone()
            if isinstance(existing_metadata_row, Mapping):
                existing_default_action_type = existing_metadata_row.get(
                    'default_action_type'
                )

        if request.method == 'POST':
            action = (request.form.get('action') or 'save').strip().lower()
            status_actions = {'status_update', 'status_fullload'}
            if action in status_actions:
                selected_status = (request.form.get('evse_status') or '').strip()
                hubject_action = 'fullLoad' if action == 'status_fullload' else 'update'
                action_display = _format_hubject_action_display(hubject_action)
                if not selected_status:
                    error_message = 'Bitte wählen Sie einen EVSE-Status aus.'
                elif not isinstance(existing_metadata_row, Mapping):
                    error_message = 'Für diesen Ladepunkt sind keine Metadaten gespeichert.'
                else:
                    try:
                        payload, operator_for_push = _build_hubject_evse_status_payload(
                            existing_metadata_row,
                            chargepoint_id=normalized_id,
                            status=selected_status,
                            action=hubject_action,
                        )
                        response_payload = _push_hubject_payload(
                            operator_for_push,
                            payload,
                            record_type='status',
                        )
                    except (ValueError, RuntimeError) as exc:
                        sync_error_message = (
                            f'Hubject-Aktion "{action_display}" fehlgeschlagen: {exc}'
                        )
                    else:
                        sync_response = response_payload
                        sync_status_text, response_timestamp, success_flag = _extract_push_status(
                            response_payload
                        )
                        if success_flag is False:
                            sync_error_message = (
                                f'Hubject-Aktion "{action_display}" fehlgeschlagen '
                                f'({sync_status_text or "ohne Status"}).'
                            )
                        else:
                            sync_success_message = (
                                f'Hubject-Aktion "{action_display}" erfolgreich durchgeführt'
                                f' ({sync_status_text or "Status unbekannt"}).'
                            )
                        try:
                            _persist_cp_push_result(
                                conn,
                                normalized_id,
                                record_type='status',
                                action=hubject_action,
                                status_value=selected_status,
                                status_text=sync_status_text,
                                success=success_flag,
                                timestamp=response_timestamp,
                            )
                        except Exception:
                            app.logger.warning(
                                'Failed to persist EVSE status push metadata',
                                exc_info=True,
                            )

                        with conn.cursor() as cur:
                            cur.execute(
                                "SELECT * FROM cp_server_cp_metadata WHERE chargepoint_id=%s",
                                (normalized_id,),
                            )
                            existing_metadata_row = cur.fetchone()

                        if isinstance(existing_metadata_row, Mapping):
                            form_values = _row_to_cp_metadata_form(
                                normalized_id, existing_metadata_row
                            )
                            form_values['chargepoint_id'] = normalized_id
                form_values['evse_status'] = selected_status or form_values.get(
                    'evse_status',
                    DEFAULT_HUBJECT_EVSE_STATUS,
                )
            elif action not in {'save', 'sync', 'insert', 'update'}:
                error_message = 'Unbekannte Aktion.'
            else:
                try:
                    persisted, formatted = _parse_cp_metadata_form(
                        request.form,
                        existing_default_action_type=existing_default_action_type,
                    )
                    formatted['chargepoint_id'] = normalized_id
                    with conn.cursor() as cur:
                        cur.execute(
                            """
                            INSERT INTO cp_server_cp_metadata (
                                chargepoint_id,
                                operator_id,
                                operator_name,
                                evse_id,
                                latitude,
                                longitude,
                                address_country,
                                address_city,
                                address_street,
                                address_house_number,
                                address_postal_code,
                                charging_station_name_de,
                                charging_station_name_en,
                                charging_station_image,
                                additional_data,
                                default_action_type,
                                accessibility,
                                accessibility_location,
                                authentication_modes,
                                plugs,
                                value_added_services,
                                charging_facilities
                            ) VALUES (
                                %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
                                %s, %s, %s, %s, %s, %s
                            )
                            ON DUPLICATE KEY UPDATE
                                operator_id = VALUES(operator_id),
                                operator_name = VALUES(operator_name),
                                evse_id = VALUES(evse_id),
                                latitude = VALUES(latitude),
                                longitude = VALUES(longitude),
                                address_country = VALUES(address_country),
                                address_city = VALUES(address_city),
                                address_street = VALUES(address_street),
                                address_house_number = VALUES(address_house_number),
                                address_postal_code = VALUES(address_postal_code),
                                charging_station_name_de = VALUES(charging_station_name_de),
                                charging_station_name_en = VALUES(charging_station_name_en),
                                charging_station_image = VALUES(charging_station_image),
                                additional_data = VALUES(additional_data),
                                default_action_type = VALUES(default_action_type),
                                accessibility = VALUES(accessibility),
                                accessibility_location = VALUES(accessibility_location),
                                authentication_modes = VALUES(authentication_modes),
                                plugs = VALUES(plugs),
                                value_added_services = VALUES(value_added_services),
                                charging_facilities = VALUES(charging_facilities)
                            """,
                            (
                                normalized_id,
                                persisted['operator_id'],
                                persisted['operator_name'],
                                persisted['evse_id'],
                                persisted['latitude'],
                                persisted['longitude'],
                                persisted['address_country'],
                                persisted['address_city'],
                                persisted['address_street'],
                                persisted['address_house_number'],
                                persisted['address_postal_code'],
                                persisted['charging_station_name_de'],
                                persisted['charging_station_name_en'],
                                persisted['charging_station_image'],
                                persisted['additional_data'],
                                persisted['default_action_type'],
                                persisted['accessibility'],
                                persisted['accessibility_location'],
                                persisted['authentication_modes'],
                                persisted['plugs'],
                                persisted['value_added_services'],
                                persisted['charging_facilities'],
                            ),
                        )
                    conn.commit()
                except ValueError as exc:
                    conn.rollback()
                    error_message = str(exc)
                except Exception:
                    conn.rollback()
                    error_message = 'Fehler beim Speichern der Metadaten.'
                    app.logger.warning(
                        'Failed to store cp_server_cp_metadata entry', exc_info=True
                    )
                else:
                    form_values = formatted
                    form_values['chargepoint_id'] = normalized_id
                    success_message = 'Metadaten gespeichert.'

                    if action in {'insert', 'update', 'sync'}:
                        hubject_action = action
                        if hubject_action == 'sync':
                            hubject_action = (
                                str(persisted.get('default_action_type') or '').strip()
                                or 'update'
                            )
                        action_display = _format_hubject_action_display(hubject_action)
                        try:
                            payload, operator_for_push = _build_hubject_evse_payload(
                                persisted,
                                action=hubject_action,
                            )
                            response_payload = _push_hubject_payload(
                                operator_for_push,
                                payload,
                                record_type='data',
                            )
                        except (ValueError, RuntimeError) as exc:
                            sync_error_message = (
                                f'Hubject-Aktion "{action_display}" fehlgeschlagen: {exc}'
                            )
                        else:
                            sync_response = response_payload
                            (
                                sync_status_text,
                                response_timestamp,
                                success_flag,
                            ) = _extract_push_status(response_payload)
                            if success_flag is False:
                                sync_error_message = (
                                    f'Hubject-Aktion "{action_display}" fehlgeschlagen '
                                    f'({sync_status_text or "ohne Status"}).'
                                )
                            else:
                                sync_success_message = (
                                    f'Hubject-Aktion "{action_display}" erfolgreich durchgeführt'
                                    f' ({sync_status_text or "Status unbekannt"}).'
                                )
                            try:
                                _persist_cp_push_result(
                                    conn,
                                    normalized_id,
                                    record_type='data',
                                    action=hubject_action,
                                    status_text=sync_status_text,
                                    success=success_flag,
                                    timestamp=response_timestamp,
                                )
                            except Exception:
                                app.logger.warning(
                                    'Failed to persist EVSE data push metadata',
                                    exc_info=True,
                                )

                            with conn.cursor() as cur:
                                cur.execute(
                                    "SELECT * FROM cp_server_cp_metadata WHERE chargepoint_id=%s",
                                    (normalized_id,),
                                )
                                reloaded_row = cur.fetchone()
                            if reloaded_row:
                                form_values = _row_to_cp_metadata_form(
                                    normalized_id, reloaded_row
                                )
                                form_values['chargepoint_id'] = normalized_id

        if request.method != 'POST' or error_message:
            with conn.cursor() as cur:
                cur.execute(
                    "SELECT * FROM cp_server_cp_metadata WHERE chargepoint_id=%s",
                    (normalized_id,),
                )
                row = cur.fetchone()
            if row:
                form_values = _row_to_cp_metadata_form(normalized_id, row)
                form_values['chargepoint_id'] = normalized_id
    finally:
        conn.close()

    if request.method == 'POST':
        posted_status = (request.form.get('evse_status') or '').strip()
        if posted_status:
            form_values['evse_status'] = posted_status

    form_values.setdefault('evse_status', DEFAULT_HUBJECT_EVSE_STATUS)

    authentication_mode_choices = list(AUTHENTICATION_MODE_CHOICES)
    accessibility_choices = list(ACCESSIBILITY_CHOICES)
    accessibility_location_choices = list(ACCESSIBILITY_LOCATION_CHOICES)
    value_added_service_choices = list(VALUE_ADDED_SERVICE_CHOICES)
    plug_type_choices = list(PLUG_TYPE_CHOICES)

    return render_template(
        'op_mini_cpms_cp_metadata.html',
        metadata=form_values,
        success_message=success_message,
        error_message=error_message,
        sync_success_message=sync_success_message,
        sync_error_message=sync_error_message,
        sync_response=sync_response,
        sync_status_text=sync_status_text,
        authentication_mode_choices=authentication_mode_choices,
        accessibility_choices=accessibility_choices,
        accessibility_location_choices=accessibility_location_choices,
        plug_type_choices=plug_type_choices,
        value_added_service_choices=value_added_service_choices,
        evse_status_choices=HUBJECT_EVSE_STATUS_CHOICES,
        aside='miniCpmsMenu',
    )


@app.route('/op_mini_cpms_status', methods=['GET', 'POST'])
def mini_cpms_status():
    success_message = None
    error_message = None
    response_payload = None

    if request.method == 'POST':
        action = request.form.get('action')
        if action == 'status_round_call':
            try:
                response_payload = _post_ocpp_command('/api/statusRoundCall', {})
                if isinstance(response_payload, dict):
                    triggered = response_payload.get('triggered') or []
                    if triggered:
                        success_message = (
                            f"Status-Rundruf für {len(triggered)} verbundene Station(en) gestartet."
                        )
                    else:
                        success_message = (
                            "Status-Rundruf gesendet – keine Stationen waren verbunden."
                        )
                    errors = response_payload.get('errors')
                    if isinstance(errors, dict) and errors:
                        error_details = [
                            f"{station}: {message}" for station, message in errors.items()
                        ]
                        error_message = (
                            "Fehler beim Status-Rundruf: " + "; ".join(error_details)
                        )
                else:
                    success_message = "Status-Rundruf gesendet."
            except RuntimeError as exc:
                error_message = str(exc)
        else:
            error_message = "Unbekannte Aktion."

    statuses, fetch_error = fetch_status_notifications()
    return render_template(
        'op_mini_cpms_status.html',
        statuses=statuses,
        fetch_error=fetch_error,
        success_message=success_message,
        error_message=error_message,
        response_payload=response_payload,
        aside='miniCpmsMenu',
    )


@app.route('/op_mini_cpms_charging_sessions')
def mini_cpms_charging_sessions():
    station_query = request.args.get('station_id', '').strip()
    id_tag_query = request.args.get('id_tag', '').strip()
    session_date_query = request.args.get('session_date', '').strip()
    sessions = []
    error_message = None
    date_start = date_end = None

    if session_date_query:
        try:
            parsed_date = datetime.datetime.strptime(
                session_date_query, "%Y-%m-%d"
            ).date()
            date_start = datetime.datetime.combine(
                parsed_date, datetime.time.min
            )
            date_end = date_start + datetime.timedelta(days=1)
        except ValueError:
            error_message = "Ungültiges Datum. Bitte im Format YYYY-MM-DD eingeben."

    conn = None
    if error_message is None:
        conn = get_db_conn()
        try:
            with conn.cursor() as cur:
                query = (
                    "SELECT station_id, id_tag, session_start, session_end,"
                    " energyChargedWh FROM op_charging_sessions"
                )
                conditions = []
                params = []
                if station_query:
                    conditions.append("station_id LIKE %s")
                    params.append(f"%{station_query}%")
                if id_tag_query:
                    conditions.append("id_tag LIKE %s")
                    params.append(f"%{id_tag_query}%")
                if date_start and date_end:
                    conditions.append(
                        "session_start >= %s AND session_start < %s"
                    )
                    params.extend([date_start, date_end])
                if conditions:
                    query += " WHERE " + " AND ".join(conditions)
                query += " ORDER BY session_start DESC"
                if not conditions:
                    query += " LIMIT 75"
                cur.execute(query, params)
                rows = cur.fetchall()
                def _format_session_timestamp(value):
                    if isinstance(value, datetime.datetime):
                        return value.strftime("%Y-%m-%d %H:%M:%S")
                    parsed = _parse_iso_datetime(value)
                    if parsed:
                        return parsed.strftime("%Y-%m-%d %H:%M:%S")
                    return value or ''
                for row in rows:
                    energy_wh = row.get('energyChargedWh')
                    energy_kwh = (
                        round(energy_wh / 1000, 3)
                        if isinstance(energy_wh, (int, float))
                        else None
                    )
                    start = row.get('session_start')
                    end = row.get('session_end')
                    sessions.append(
                        {
                            'station_id': row.get('station_id'),
                            'id_tag': row.get('id_tag'),
                            'session_start': _format_session_timestamp(start),
                            'session_end': _format_session_timestamp(end),
                            'energy_kwh': energy_kwh,
                        }
                    )
        finally:
            if conn is not None:
                conn.close()

    limit_applied = not any([station_query, id_tag_query, session_date_query])
    return render_template(
        'op_mini_cpms_charging_sessions.html',
        sessions=sessions,
        station_query=station_query,
        id_tag_query=id_tag_query,
        session_date_query=session_date_query,
        error_message=error_message,
        limit_applied=limit_applied,
        aside='miniCpmsMenu',
    )


@app.route('/op_mini_cpms_commands', methods=['GET', 'POST'])
def mini_cpms_commands():
    success_message = None
    error_message = None
    response_payload = None
    selected_station = (request.args.get('station_id') or '').strip() or None
    datatransfer_example = json.dumps(
        {
            "station_id": "WalthersVogelweide",
            "action": "custom_action",
            "ocpp_action": "DataTransfer",
            "payload": {
                "vendorId": "org.openchargealliance.iso15118pnc",
                "messageId": "TriggerMessage",
                "data": "{\"requestedMessage\":\"SignV2GCertificate\"}",
            },
        },
        indent=2,
        ensure_ascii=False,
    )
    allowed_certificate_types = {
        'V2GRootCertificate',
        'MORootCertificate',
        'CSMSRootCertificate',
    }
    allowed_csr_certificate_types = {
        'V2GCertificateChain',
        'V2GRootCertificate',
        'MORootCertificate',
        'CSMSRootCertificate',
    }
    allowed_hash_algorithms = {'SHA256', 'SHA384', 'SHA512'}
    allowed_report_bases = {'FullInventory', 'ConfigurationInventory', 'SummaryInventory'}

    if request.method == 'POST':
        action = request.form.get('action')
        selected_station = request.form.get('station_id', '').strip()
        if not selected_station:
            error_message = "Bitte eine Chargepoint ID auswählen."
        else:
            try:
                if action == 'change_availability':
                    availability_type = (
                        request.form.get('availability_type', 'Operative').strip()
                    )
                    connector_raw = request.form.get(
                        'availability_connector_id', '0'
                    ).strip()
                    connector_id = int(connector_raw or 0)
                    payload = {
                        'station_id': selected_station,
                        'type': availability_type,
                        'connectorId': connector_id,
                    }
                    response_payload = _post_ocpp_command(
                        '/api/changeAvailability', payload
                    )
                    success_message = (
                        f"ChangeAvailability ({availability_type}) ausgelöst."
                    )
                elif action == 'change_configuration':
                    config_key = request.form.get('config_key', '').strip()
                    config_value = request.form.get('config_value', '').strip()
                    if not config_key:
                        raise ValueError(
                            "Der Konfigurationsschlüssel darf nicht leer sein."
                        )
                    payload = {
                        'station_id': selected_station,
                        'key': config_key,
                        'value': config_value,
                    }
                    response_payload = _post_ocpp_command(
                        '/api/setConfiguration', payload
                    )
                    success_message = (
                        f"ChangeConfiguration für '{config_key}' gesendet."
                    )
                elif action == 'get_configuration':
                    response_payload = _get_ocpp_command(
                        '/api/getConfiguration', {'station_id': selected_station}
                    )
                    success_message = 'GetConfiguration angefordert.'
                elif action == 'reboot':
                    reset_type = request.form.get('reset_type', 'Hard').strip()
                    payload = {
                        'station_id': selected_station,
                        'type': reset_type,
                    }
                    response_payload = _post_ocpp_command(
                        '/api/reset', payload
                    )
                    success_message = f"Reset ({reset_type}) ausgelöst."
                elif action == 'unlock_connector':
                    connector_raw = request.form.get(
                        'unlock_connector_id', ''
                    ).strip()
                    if not connector_raw:
                        raise ValueError(
                            "Bitte einen Connector für UnlockConnector angeben."
                        )
                    connector_id = int(connector_raw)
                    payload = {
                        'station_id': selected_station,
                        'connectorId': connector_id,
                    }
                    response_payload = _post_ocpp_command(
                        '/api/unlockConnector', payload
                    )
                    success_message = "UnlockConnector ausgelöst."
                elif action == 'remote_start':
                    connector_raw = request.form.get(
                        'remote_start_connector_id', ''
                    ).strip()
                    if not connector_raw:
                        raise ValueError(
                            "Bitte einen Connector für RemoteStart angeben."
                        )
                    connector_id = int(connector_raw)
                    id_tag = request.form.get(
                        'remote_start_id_tag', 'remote'
                    ).strip() or 'remote'
                    payload = {
                        'station_id': selected_station,
                        'connector_id': connector_id,
                        'id_tag': id_tag,
                    }
                    response_payload = _post_ocpp_command(
                        '/api/startTransaction', payload
                    )
                    success_message = "RemoteStartTransaction ausgelöst."
                elif action == 'remote_stop':
                    transaction_raw = request.form.get(
                        'remote_stop_transaction_id', ''
                    ).strip()
                    if not transaction_raw:
                        raise ValueError(
                            "Bitte eine Transaction ID für RemoteStop angeben."
                        )
                    transaction_id = int(transaction_raw)
                    payload = {
                        'station_id': selected_station,
                        'transaction_id': transaction_id,
                    }
                    response_payload = _post_ocpp_command(
                        '/api/stopTransaction', payload
                    )
                    success_message = "RemoteStopTransaction ausgelöst."
                elif action == 'trigger_message':
                    message = request.form.get(
                        'trigger_message_type', ''
                    ).strip()
                    if not message:
                        raise ValueError(
                            "Bitte einen TriggerMessage-Typ auswählen."
                        )
                    connector_raw = request.form.get(
                        'trigger_message_connector_id', ''
                    ).strip()
                    payload = {
                        'station_id': selected_station,
                        'requestedMessage': message,
                    }
                    if connector_raw:
                        payload['connectorId'] = int(connector_raw)
                    response_payload = _post_ocpp_command(
                        '/api/triggerMessage', payload
                    )
                    success_message = f"TriggerMessage ({message}) gesendet."
                elif action == 'trigger_diagnostic_upload':
                    location = request.form.get(
                        'diagnostic_location', ''
                    ).strip()
                    if not location:
                        raise ValueError(
                            "Bitte ein Ziel für den Diagnose-Upload angeben."
                        )
                    payload = {
                        'station_id': selected_station,
                        'location': location,
                    }
                    response_payload = _post_ocpp_command(
                        '/api/getDiagnostics', payload
                    )
                    target_location = location
                    if isinstance(response_payload, dict):
                        target_location = (
                            response_payload.get('location') or target_location
                        )
                    success_message = (
                        "Diagnose-Upload ausgelöst. Ziel: "
                        f"{target_location}"
                    )
                elif action == 'install_certificate':
                    certificate_type = (request.form.get('certificate_type') or '').strip()
                    certificate_content = (request.form.get('certificate_content') or '').strip()
                    if certificate_type not in allowed_certificate_types:
                        raise ValueError('certificateType muss gewählt werden.')
                    if not certificate_content:
                        raise ValueError('Zertifikatsinhalt fehlt.')
                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-certificates',
                        {
                            'station_id': selected_station,
                            'action': 'install_certificate',
                            'certificateType': certificate_type,
                            'certificate': certificate_content,
                        },
                    )
                    success_message = 'InstallCertificate gesendet.'
                elif action == 'certificate_signed':
                    certificate_chain = (request.form.get('certificate_chain') or '').strip()
                    certificate_type = (request.form.get('certificate_type') or '').strip()
                    if not certificate_chain:
                        raise ValueError('CertificateChain fehlt.')
                    payload = {
                        'station_id': selected_station,
                        'action': 'certificate_signed',
                        'certificateChain': certificate_chain,
                    }
                    if certificate_type:
                        if certificate_type not in allowed_certificate_types:
                            raise ValueError('certificateType ist ungültig.')
                        payload['certificateType'] = certificate_type
                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-certificates',
                        payload,
                    )
                    success_message = 'CertificateSigned gesendet.'
                elif action == 'get_installed_certificate_ids':
                    certificate_type = (request.form.get('certificate_type') or '').strip()
                    payload = {
                        'station_id': selected_station,
                        'action': 'get_installed_certificate_ids',
                    }
                    if certificate_type:
                        if certificate_type not in allowed_certificate_types:
                            raise ValueError('certificateType ist ungültig.')
                        payload['certificateType'] = certificate_type
                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-certificates', payload
                    )
                    success_message = 'GetInstalledCertificateIds gesendet.'
                elif action == 'get_certificate_csr':
                    certificate_type = (request.form.get('certificate_type') or '').strip()
                    if certificate_type not in allowed_csr_certificate_types:
                        raise ValueError('certificateType für CSR ist ungültig.')
                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-command',
                        {
                            'station_id': selected_station,
                            'action': 'custom_action',
                            'ocpp_action': 'GetCertificate',
                            'payload': {
                                'certificateType': certificate_type,
                            },
                        },
                    )
                    success_message = f"GetCertificate ({certificate_type}) gesendet."
                elif action == 'delete_certificate':
                    hash_algorithm = (request.form.get('hash_algorithm') or '').strip()
                    issuer_name_hash = (request.form.get('issuer_name_hash') or '').strip()
                    issuer_key_hash = (request.form.get('issuer_key_hash') or '').strip()
                    serial_number = (request.form.get('serial_number') or '').strip()
                    if hash_algorithm not in allowed_hash_algorithms:
                        raise ValueError('hashAlgorithm muss SHA256, SHA384 oder SHA512 sein.')
                    if not issuer_name_hash or not issuer_key_hash or not serial_number:
                        raise ValueError(
                            'issuerNameHash, issuerKeyHash und serialNumber sind erforderlich.'
                        )
                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-certificates',
                        {
                            'station_id': selected_station,
                            'action': 'delete_certificate',
                            'hashAlgorithm': hash_algorithm,
                            'issuerNameHash': issuer_name_hash,
                            'issuerKeyHash': issuer_key_hash,
                            'serialNumber': serial_number,
                        },
                    )
                    success_message = 'DeleteCertificate gesendet.'
                elif action == 'get_certificate_status':
                    hash_algorithm = (request.form.get('hash_algorithm') or '').strip()
                    issuer_name_hash = (request.form.get('issuer_name_hash') or '').strip()
                    issuer_key_hash = (request.form.get('issuer_key_hash') or '').strip()
                    serial_number = (request.form.get('serial_number') or '').strip()
                    if hash_algorithm not in allowed_hash_algorithms:
                        raise ValueError('hashAlgorithm muss SHA256, SHA384 oder SHA512 sein.')
                    if not issuer_name_hash or not issuer_key_hash or not serial_number:
                        raise ValueError(
                            'issuerNameHash, issuerKeyHash und serialNumber sind erforderlich.'
                        )
                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-certificates',
                        {
                            'station_id': selected_station,
                            'action': 'get_certificate_status',
                            'hashAlgorithm': hash_algorithm,
                            'issuerNameHash': issuer_name_hash,
                            'issuerKeyHash': issuer_key_hash,
                            'serialNumber': serial_number,
                        },
                    )
                    success_message = 'GetCertificateStatus gesendet.'
                elif action == 'get_15118_ev_certificate':
                    exi_request = (request.form.get('exi_request') or '').strip()
                    iso_schema_version = (
                        request.form.get('iso15118_schema_version') or ''
                    ).strip()
                    if not exi_request:
                        raise ValueError(
                            "Bitte einen exiRequest angeben."
                        )
                    if not iso_schema_version:
                        raise ValueError(
                            "Bitte eine iso15118SchemaVersion angeben."
                        )
                    payload = {
                        'exiRequest': exi_request,
                        'iso15118SchemaVersion': iso_schema_version,
                    }
                    optional_fields = {
                        'contract_signature_cert_chain': 'contractSignatureCertChain',
                        'emaid': 'emaid',
                        'dh_public_key': 'dhPublicKey',
                        'manufacturer_id': 'manufacturerId',
                        'serial_number': 'serialNumber',
                    }
                    for form_key, payload_key in optional_fields.items():
                        value = (request.form.get(form_key) or '').strip()
                        if value:
                            payload[payload_key] = value
                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-command',
                        {
                            'station_id': selected_station,
                            'action': 'get_15118_ev_certificate',
                            'payload': payload,
                        },
                    )
                    success_message = "Get15118EVCertificate gesendet."
                elif action == 'authorize':
                    id_tag = (request.form.get('authorize_id_tag') or '').strip()
                    if not id_tag:
                        raise ValueError(
                            "Bitte einen ID Tag angeben."
                        )
                    iso_hash_raw = (
                        request.form.get('authorize_iso15118_hash_data') or ''
                    ).strip()
                    iso_hash_data = None
                    if iso_hash_raw:
                        try:
                            iso_hash_data = json.loads(iso_hash_raw)
                        except json.JSONDecodeError as exc:
                            raise ValueError(
                                "Ungültiges JSON für ISO 15118 Certificate Hash Data: "
                                f"{exc.msg}"
                            ) from exc
                        if not isinstance(iso_hash_data, dict):
                            raise ValueError(
                                "ISO 15118 Certificate Hash Data muss ein JSON-Objekt sein."
                            )
                    payload = {'idTag': id_tag}
                    if iso_hash_data is not None:
                        payload['iso15118CertificateHashData'] = iso_hash_data
                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-command',
                        {
                            'station_id': selected_station,
                            'action': 'authorize',
                            'payload': payload,
                        },
                    )
                    success_message = "Authorize gesendet."
                elif action == 'custom_datatransfer':
                    payload_raw = (request.form.get('datatransfer_payload') or '').strip()
                    if not payload_raw:
                        raise ValueError("Bitte einen Payload angeben.")
                    try:
                        payload_data = json.loads(payload_raw)
                    except json.JSONDecodeError as exc:
                        raise ValueError(f"Ungültiges JSON: {exc.msg}") from exc
                    if not isinstance(payload_data, dict):
                        raise ValueError("Der Payload muss ein JSON-Objekt sein.")
                    existing_station = payload_data.get('station_id')
                    if existing_station and existing_station != selected_station:
                        raise ValueError(
                            "Die Station im Payload entspricht nicht der ausgewählten Station."
                        )
                    payload_data['station_id'] = selected_station
                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-command', payload_data
                    )
                    success_message = "Custom DataTransfer gesendet."
                elif action == 'get_base_report':
                    request_id_raw = (request.form.get('get_base_report_request_id') or '').strip()
                    report_base = (request.form.get('get_base_report_report_base') or '').strip()
                    evse_id_raw = (request.form.get('get_base_report_evse_id') or '').strip()
                    component_criteria = [
                        value.strip()
                        for value in request.form.getlist('get_base_report_component_criteria')
                        if value and value.strip()
                    ]
                    if not request_id_raw:
                        raise ValueError('Bitte eine Request ID angeben.')
                    try:
                        request_id = int(request_id_raw)
                    except (TypeError, ValueError) as exc:
                        raise ValueError('Request ID muss eine Zahl sein.') from exc
                    if report_base not in allowed_report_bases:
                        raise ValueError('Report Base ist ungültig.')
                    payload: dict[str, Any] = {
                        'station_id': selected_station,
                        'action': 'custom_action',
                        'ocpp_action': 'GetBaseReport',
                        'payload': {
                            'requestId': request_id,
                            'reportBase': report_base,
                        },
                    }
                    if component_criteria:
                        payload['payload']['componentCriteria'] = component_criteria
                    if evse_id_raw:
                        try:
                            payload['payload']['evseId'] = int(evse_id_raw)
                        except (TypeError, ValueError) as exc:
                            raise ValueError('EVSE ID muss eine Zahl sein.') from exc
                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-command', payload
                    )
                    success_message = f"GetBaseReport ({report_base}) gesendet."
                elif action == 'get_variables':
                    component_names = [
                        (value or '').strip()
                        for value in request.form.getlist('get_variables_component_name[]')
                    ]
                    variable_names = [
                        (value or '').strip()
                        for value in request.form.getlist('get_variables_variable_name[]')
                    ]
                    attribute_types = [
                        (value or '').strip()
                        for value in request.form.getlist('get_variables_attribute_type[]')
                    ]
                    component_instances = [
                        (value or '').strip()
                        for value in request.form.getlist('get_variables_component_instance[]')
                    ]
                    variable_instances = [
                        (value or '').strip()
                        for value in request.form.getlist('get_variables_variable_instance[]')
                    ]
                    evse_ids_raw = request.form.getlist('get_variables_evse_id[]')
                    connector_ids_raw = request.form.getlist('get_variables_connector_id[]')

                    max_len = max(
                        len(component_names),
                        len(variable_names),
                        len(attribute_types),
                        len(component_instances),
                        len(variable_instances),
                        len(evse_ids_raw),
                        len(connector_ids_raw),
                    )
                    get_variable_data: list[dict[str, Any]] = []
                    for index in range(max_len):
                        component_name = component_names[index] if index < len(component_names) else ''
                        variable_name = variable_names[index] if index < len(variable_names) else ''
                        attribute_type = attribute_types[index] if index < len(attribute_types) else ''
                        component_instance = (
                            component_instances[index] if index < len(component_instances) else ''
                        )
                        variable_instance = (
                            variable_instances[index] if index < len(variable_instances) else ''
                        )
                        evse_id_raw = evse_ids_raw[index] if index < len(evse_ids_raw) else ''
                        connector_id_raw = (
                            connector_ids_raw[index] if index < len(connector_ids_raw) else ''
                        )

                        if not component_name and not variable_name:
                            continue
                        if not component_name or not variable_name:
                            raise ValueError('Jeder Eintrag benötigt Component Name und Variable Name.')

                        entry: dict[str, Any] = {
                            'component': {'name': component_name},
                            'variable': {'name': variable_name},
                        }
                        if component_instance:
                            entry['component']['instance'] = component_instance
                        if variable_instance:
                            entry['variable']['instance'] = variable_instance
                        if attribute_type:
                            entry['attributeType'] = attribute_type
                        if evse_id_raw:
                            try:
                                entry['component']['evseId'] = int(evse_id_raw)
                            except (TypeError, ValueError) as exc:
                                raise ValueError('EVSE ID muss eine Zahl sein.') from exc
                        if connector_id_raw:
                            try:
                                entry['component']['connectorId'] = int(connector_id_raw)
                            except (TypeError, ValueError) as exc:
                                raise ValueError('Connector ID muss eine Zahl sein.') from exc

                        get_variable_data.append(entry)

                    if not get_variable_data:
                        raise ValueError('Bitte mindestens einen Variablen-Eintrag ausfüllen.')

                    response_payload = _post_ocpp_command(
                        '/api/pnc/ocpp-command',
                        {
                            'station_id': selected_station,
                            'action': 'custom_action',
                            'ocpp_action': 'GetVariables',
                            'payload': {'getVariableData': get_variable_data},
                        },
                    )
                    success_message = f"GetVariables gesendet ({len(get_variable_data)} Einträge)."
                else:
                    raise ValueError("Unbekannte Aktion.")
            except ValueError as exc:
                error_message = str(exc)
            except RuntimeError as exc:
                error_message = str(exc)

    stations, fetch_error = fetch_connected_stations(include_protocols=True)
    selected_station_version = None
    if selected_station:
        for station in stations:
            if station.get("station_id") == selected_station:
                selected_station_version = station.get("ocpp_version")
                break
    return render_template(
        'op_mini_cpms_commands.html',
        stations=stations,
        fetch_error=fetch_error,
        success_message=success_message,
        error_message=error_message,
        response_payload=response_payload,
        selected_station=selected_station,
        selected_station_version=selected_station_version,
        datatransfer_example=datatransfer_example,
        aside='miniCpmsMenu',
    )


@app.route('/op_mini_cpms_local_rfid', methods=['GET', 'POST'])
def mini_cpms_local_rfid():
    if request.method == 'GET' and request.args.get('download') == 'csv':
        rows = _load_all_rfid_entries()
        output = StringIO()
        writer = csv.writer(output)
        writer.writerow(['station_id', 'id_tag', 'status', 'expiry_date', 'parent_id_tag'])
        for row in rows:
            expiry = ''
            expiry_value = row.get('expiry_date')
            if isinstance(expiry_value, datetime.datetime):
                expiry = expiry_value.replace(microsecond=0).isoformat()
            elif expiry_value:
                expiry = str(expiry_value)
            parent_id = row.get('parent_id_tag') or ''
            writer.writerow(
                [
                    row.get('station_id') or '',
                    row.get('id_tag') or '',
                    row.get('status') or 'Accepted',
                    expiry,
                    parent_id,
                ]
            )
        output.seek(0)
        response = Response(output.getvalue(), mimetype='text/csv')
        response.headers['Content-Disposition'] = 'attachment; filename=op_server_rfid.csv'
        return response

    success_messages: list[str] = []
    error_messages: list[str] = []

    if request.method == 'POST':
        action = (request.form.get('action') or '').strip()
        try:
            if action == 'add':
                station_id = (request.form.get('station_id') or '').strip()
                id_tag = (request.form.get('id_tag') or '').strip()
                status = (request.form.get('status') or 'Accepted').strip() or 'Accepted'
                if status not in _LOCAL_AUTH_STATUS_VALUES:
                    status = 'Accepted'
                expiry_raw = request.form.get('expiry_date')
                expiry_dt = _parse_local_list_expiry(expiry_raw)
                parent_id_tag = (request.form.get('parent_id_tag') or '').strip() or None
                if not station_id:
                    raise ValueError('Bitte eine Station ID angeben.')
                if not id_tag:
                    raise ValueError('Bitte eine RFID (idTag) angeben.')
                conn = get_db_conn()
                try:
                    with conn.cursor() as cur:
                        cur.execute(
                            """
                            INSERT INTO op_server_rfid (station_id, id_tag, status, expiry_date, parent_id_tag)
                            VALUES (%s, %s, %s, %s, %s)
                            """,
                            (station_id, id_tag, status, expiry_dt, parent_id_tag),
                        )
                    conn.commit()
                except pymysql.err.IntegrityError as exc:
                    conn.rollback()
                    raise ValueError('RFID existiert bereits für diese Station.') from exc
                finally:
                    conn.close()
                success_messages.append(f'RFID {id_tag} für {station_id} hinzugefügt.')
            elif action == 'update':
                entry_id_raw = request.form.get('entry_id')
                if not entry_id_raw:
                    raise ValueError('Ungültige Eintrags-ID.')
                try:
                    entry_id = int(entry_id_raw)
                except (TypeError, ValueError) as exc:
                    raise ValueError('Ungültige Eintrags-ID.') from exc
                station_id = (request.form.get('station_id') or '').strip()
                id_tag = (request.form.get('id_tag') or '').strip()
                if not station_id:
                    raise ValueError('Bitte eine Station ID angeben.')
                if not id_tag:
                    raise ValueError('Bitte eine RFID (idTag) angeben.')
                status = (request.form.get('status') or 'Accepted').strip() or 'Accepted'
                if status not in _LOCAL_AUTH_STATUS_VALUES:
                    status = 'Accepted'
                expiry_raw = request.form.get('expiry_date')
                expiry_dt = _parse_local_list_expiry(expiry_raw)
                parent_id_tag = (request.form.get('parent_id_tag') or '').strip() or None
                conn = get_db_conn()
                try:
                    with conn.cursor() as cur:
                        cur.execute(
                            """
                            UPDATE op_server_rfid
                            SET station_id=%s, id_tag=%s, status=%s, expiry_date=%s, parent_id_tag=%s
                            WHERE id=%s
                            """,
                            (
                                station_id,
                                id_tag,
                                status,
                                expiry_dt,
                                parent_id_tag,
                                entry_id,
                            ),
                        )
                        if cur.rowcount == 0:
                            conn.rollback()
                            raise ValueError('RFID-Eintrag wurde nicht gefunden.')
                    conn.commit()
                except pymysql.err.IntegrityError as exc:
                    conn.rollback()
                    raise ValueError('RFID existiert bereits für diese Station.') from exc
                finally:
                    conn.close()
                success_messages.append(f'RFID {id_tag} aktualisiert.')
            elif action == 'delete':
                entry_id_raw = request.form.get('entry_id')
                if not entry_id_raw:
                    raise ValueError('Ungültige Eintrags-ID.')
                try:
                    entry_id = int(entry_id_raw)
                except (TypeError, ValueError) as exc:
                    raise ValueError('Ungültige Eintrags-ID.') from exc
                conn = get_db_conn()
                try:
                    with conn.cursor() as cur:
                        cur.execute(
                            "DELETE FROM op_server_rfid WHERE id=%s",
                            (entry_id,),
                        )
                        if cur.rowcount == 0:
                            conn.rollback()
                            raise ValueError('RFID-Eintrag wurde nicht gefunden.')
                    conn.commit()
                finally:
                    conn.close()
                success_messages.append('RFID-Eintrag gelöscht.')
            elif action == 'upload_csv':
                upload_file = request.files.get('csv_file')
                if upload_file is None or not upload_file.filename:
                    raise ValueError('Bitte eine CSV-Datei auswählen.')
                file_content = upload_file.read()
                if not file_content:
                    raise ValueError('Die hochgeladene Datei ist leer.')
                try:
                    text_content = file_content.decode('utf-8-sig')
                except UnicodeDecodeError as exc:
                    raise ValueError('Die CSV-Datei muss im UTF-8-Format vorliegen.') from exc
                reader = csv.DictReader(StringIO(text_content))
                if not reader.fieldnames:
                    raise ValueError('Die CSV-Datei enthält keine Kopfzeile.')
                normalized_fieldnames = {name.strip() for name in reader.fieldnames if name}
                missing_columns = {'station_id', 'id_tag'} - normalized_fieldnames
                if missing_columns:
                    raise ValueError('Die CSV-Datei muss die Spalten station_id und id_tag enthalten.')
                entries_to_insert = []
                seen_keys = set()
                for line_number, row in enumerate(reader, start=2):
                    station_id = (row.get('station_id') or '').strip()
                    id_tag = (row.get('id_tag') or '').strip()
                    if not station_id:
                        raise ValueError(f'Zeile {line_number}: Station ID fehlt.')
                    if not id_tag:
                        raise ValueError(f'Zeile {line_number}: RFID (idTag) fehlt.')
                    key = (station_id, id_tag)
                    if key in seen_keys:
                        raise ValueError(
                            f'Zeile {line_number}: Duplikat für Station {station_id} und RFID {id_tag}.'
                        )
                    seen_keys.add(key)
                    status = (row.get('status') or 'Accepted').strip() or 'Accepted'
                    if status not in _LOCAL_AUTH_STATUS_VALUES:
                        status = 'Accepted'
                    expiry_raw = row.get('expiry_date')
                    try:
                        expiry_dt = _parse_local_list_expiry(expiry_raw)
                    except ValueError as exc:
                        raise ValueError(f'Zeile {line_number}: {exc}') from exc
                    parent_id_tag = (row.get('parent_id_tag') or '').strip() or None
                    entries_to_insert.append(
                        (station_id, id_tag, status, expiry_dt, parent_id_tag)
                    )
                conn = get_db_conn()
                try:
                    with conn.cursor() as cur:
                        cur.execute('DELETE FROM op_server_rfid')
                        if entries_to_insert:
                            cur.executemany(
                                """
                                INSERT INTO op_server_rfid (station_id, id_tag, status, expiry_date, parent_id_tag)
                                VALUES (%s, %s, %s, %s, %s)
                                """,
                                entries_to_insert,
                            )
                    conn.commit()
                finally:
                    conn.close()
                success_messages.append(
                    f'CSV-Import abgeschlossen. {len(entries_to_insert)} Einträge wurden übernommen.'
                )
            else:
                raise ValueError('Unbekannte Aktion.')
        except ValueError as exc:
            error_messages.append(str(exc))
        except Exception as exc:
            logging.exception('Fehler bei der Verwaltung der lokalen RFID-Liste')
            error_messages.append('Unerwarteter Fehler beim Verarbeiten der Anfrage.')

    rows = _load_all_rfid_entries()
    entries = []
    for row in rows:
        entry = dict(row)
        entry['expiry_input'] = _format_local_list_expiry_input(row.get('expiry_date'))
        entry['expiry_display'] = _format_local_list_expiry_display(row.get('expiry_date'))
        entries.append(entry)

    return render_template(
        'op_mini_cpms_local_rfid.html',
        entries=entries,
        status_choices=_LOCAL_AUTH_STATUS_CHOICES,
        success_messages=success_messages,
        error_messages=error_messages,
        aside='miniCpmsMenu',
    )


@app.route('/op_mini_cpms_local_lists', methods=['GET', 'POST'])
def mini_cpms_local_lists():
    success_messages = []
    error_messages = []
    config_update_response = None
    send_local_list_response = None

    if request.method == 'POST':
        selected_station = (request.form.get('station_id') or '').strip()
    else:
        selected_station = (request.args.get('station_id') or '').strip()

    if request.method == 'POST':
        action = request.form.get('action', '')
        try:
            if action in {'add_entry', 'update_entry', 'delete_entry'}:
                if not selected_station:
                    raise ValueError('Bitte zuerst eine Ladestation auswählen.')
                id_tag = (request.form.get('id_tag') or '').strip()
                status = (request.form.get('status') or 'Accepted').strip() or 'Accepted'
                if status not in _LOCAL_AUTH_STATUS_VALUES:
                    status = 'Accepted'
                expiry_raw = request.form.get('expiry_date')
                expiry_dt = _parse_local_list_expiry(expiry_raw)
                parent_id_tag = (request.form.get('parent_id_tag') or '').strip() or None
                conn = get_db_conn()
                try:
                    with conn.cursor() as cur:
                        try:
                            if action == 'add_entry':
                                if not id_tag:
                                    raise ValueError('Bitte eine RFID (idTag) angeben.')
                                cur.execute(
                                    """
                                    INSERT INTO op_server_rfid (station_id, id_tag, status, expiry_date, parent_id_tag)
                                    VALUES (%s, %s, %s, %s, %s)
                                    ON DUPLICATE KEY UPDATE
                                        status=VALUES(status),
                                        expiry_date=VALUES(expiry_date),
                                        parent_id_tag=VALUES(parent_id_tag)
                                    """,
                                    (selected_station, id_tag, status, expiry_dt, parent_id_tag),
                                )
                                success_messages.append(f"RFID {id_tag} gespeichert.")
                            elif action == 'update_entry':
                                entry_id_raw = request.form.get('entry_id')
                                if not entry_id_raw:
                                    raise ValueError('Ungültiger Eintrag – ID fehlt.')
                                try:
                                    entry_id = int(entry_id_raw)
                                except (TypeError, ValueError) as exc:
                                    raise ValueError('Ungültige Eintrags-ID.') from exc
                                if not id_tag:
                                    raise ValueError('Bitte eine RFID (idTag) angeben.')
                                cur.execute(
                                    """
                                    UPDATE op_server_rfid
                                    SET id_tag=%s, status=%s, expiry_date=%s, parent_id_tag=%s
                                    WHERE id=%s AND station_id=%s
                                    """,
                                    (
                                        id_tag,
                                        status,
                                        expiry_dt,
                                        parent_id_tag,
                                        entry_id,
                                        selected_station,
                                    ),
                                )
                                if cur.rowcount == 0:
                                    raise ValueError('RFID-Eintrag konnte nicht aktualisiert werden.')
                                success_messages.append(f"RFID {id_tag} aktualisiert.")
                            elif action == 'delete_entry':
                                entry_id_raw = request.form.get('entry_id')
                                if not entry_id_raw:
                                    raise ValueError('Ungültiger Eintrag – ID fehlt.')
                                try:
                                    entry_id = int(entry_id_raw)
                                except (TypeError, ValueError) as exc:
                                    raise ValueError('Ungültige Eintrags-ID.') from exc
                                cur.execute(
                                    "DELETE FROM op_server_rfid WHERE id=%s AND station_id=%s",
                                    (entry_id, selected_station),
                                )
                                if cur.rowcount == 0:
                                    raise ValueError('RFID-Eintrag wurde nicht gefunden.')
                                success_messages.append('RFID-Eintrag gelöscht.')
                        except pymysql.err.IntegrityError as exc:
                            raise ValueError('RFID existiert bereits für diese Station.') from exc
                    conn.commit()
                finally:
                    conn.close()
            elif action == 'update_config':
                if not selected_station:
                    raise ValueError('Bitte zuerst eine Ladestation auswählen.')
                payload = {'station_id': selected_station}
                payload['LocalAuthListEnabled'] = (
                    request.form.get('LocalAuthListEnabled') == '1'
                )
                payload['LocalAuthorizeOffline'] = (
                    request.form.get('LocalAuthorizeOffline') == '1'
                )
                max_length_raw = (request.form.get('SendLocalListMaxLength') or '').strip()
                if max_length_raw:
                    try:
                        payload['SendLocalListMaxLength'] = int(max_length_raw)
                    except ValueError as exc:
                        raise ValueError('Bitte eine gültige Zahl für SendLocalListMaxLength angeben.') from exc
                config_update_response = _post_ocpp_command(
                    '/api/localAuth/configuration', payload
                )
                success_messages.append('Konfiguration aktualisiert.')
            elif action == 'send_local_list':
                if not selected_station:
                    raise ValueError('Bitte zuerst eine Ladestation auswählen.')
                update_type_raw = (request.form.get('update_type') or 'Full').strip()
                update_type_normalized = update_type_raw.lower()
                if update_type_normalized not in {'full', 'differential'}:
                    raise ValueError('Update-Typ muss "Full" oder "Differential" sein.')
                update_type = 'Full' if update_type_normalized == 'full' else 'Differential'
                list_version_raw = (request.form.get('list_version') or '').strip()
                if not list_version_raw:
                    raise ValueError('Bitte eine List Version angeben.')
                try:
                    list_version = int(list_version_raw)
                except ValueError as exc:
                    raise ValueError('List Version muss eine Ganzzahl sein.') from exc
                entries = _load_local_rfid_entries(selected_station)
                local_authorization_list = []
                for entry in entries:
                    id_tag = entry.get('id_tag') or ''
                    if not id_tag:
                        continue
                    status_value = entry.get('status') or 'Accepted'
                    if status_value not in _LOCAL_AUTH_STATUS_VALUES:
                        status_value = 'Accepted'
                    info = {'status': status_value}
                    expiry_payload = _format_local_list_expiry_payload(entry.get('expiry_date'))
                    if expiry_payload:
                        info['expiryDate'] = expiry_payload
                    parent = entry.get('parent_id_tag')
                    if parent:
                        info['parentIdTag'] = parent
                    local_authorization_list.append(
                        {'idTag': id_tag, 'idTagInfo': info}
                    )
                payload = {
                    'station_id': selected_station,
                    'listVersion': list_version,
                    'updateType': update_type,
                    'localAuthorizationList': local_authorization_list,
                }
                send_local_list_response = _post_ocpp_command(
                    '/api/localAuth/sendLocalList', payload
                )
                success_messages.append(
                    f'SendLocalList ({update_type}) mit {len(local_authorization_list)} Einträgen gesendet.'
                )
        except ValueError as exc:
            error_messages.append(str(exc))
        except RuntimeError as exc:
            error_messages.append(str(exc))

    stations, fetch_error = fetch_connected_stations()

    entries_raw = _load_local_rfid_entries(selected_station)
    entries = [
        {
            'id': row.get('id'),
            'id_tag': row.get('id_tag', ''),
            'status': row.get('status') or 'Accepted',
            'expiry_display': _format_local_list_expiry_display(row.get('expiry_date')),
            'expiry_input': _format_local_list_expiry_input(row.get('expiry_date')),
            'parent_id_tag': row.get('parent_id_tag') or '',
        }
        for row in entries_raw
    ]

    config_payload = None
    config_settings = {}
    config_error = None
    if selected_station:
        try:
            config_payload = _get_ocpp_command(
                '/api/localAuth/configuration', {'station_id': selected_station}
            )
            config_settings = config_payload.get('settings') or {}
        except RuntimeError as exc:
            config_error = str(exc)

    list_version_payload = None
    list_version_error = None
    current_list_version = None
    if selected_station:
        try:
            list_version_payload = _get_ocpp_command(
                '/api/localAuth/listVersion', {'station_id': selected_station}
            )
        except RuntimeError as exc:
            list_version_error = str(exc)
        else:
            if isinstance(list_version_payload, dict):
                result = list_version_payload.get('result')
                if isinstance(result, dict):
                    version_raw = result.get('listVersion')
                    if isinstance(version_raw, int):
                        current_list_version = version_raw
                    else:
                        try:
                            current_list_version = int(str(version_raw))
                        except (TypeError, ValueError):
                            current_list_version = None

    success_message = ' '.join(success_messages) if success_messages else None
    error_message = ' '.join(error_messages) if error_messages else None

    return render_template(
        'op_mini_cpms_local_lists.html',
        stations=stations,
        fetch_error=fetch_error,
        selected_station=selected_station,
        success_message=success_message,
        error_message=error_message,
        config_settings=config_settings,
        config_payload=config_payload,
        config_error=config_error,
        config_update_response=config_update_response,
        list_version_payload=list_version_payload,
        list_version_error=list_version_error,
        current_list_version=current_list_version,
        send_local_list_response=send_local_list_response,
        entries=entries,
        status_choices=_LOCAL_AUTH_STATUS_CHOICES,
        aside='miniCpmsMenu',
    )


@app.route('/op_workflows', methods=['GET', 'POST'])
def manage_workflows():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            if request.method == 'POST':
                action = request.form.get('action')
                if action == 'add':
                    cur.execute(
                        "INSERT INTO op_broker_workflows (name, definition, active) VALUES (%s, %s, %s)",
                        (
                            request.form.get('name', ''),
                            '{}',
                            1 if request.form.get('active') in ['1', 'on'] else 0,
                        ),
                    )
                elif action == 'delete':
                    cur.execute(
                        "DELETE FROM op_broker_workflows WHERE id=%s",
                        (request.form.get('id'),),
                    )
                elif action == 'rename':
                    cur.execute(
                        "UPDATE op_broker_workflows SET name=%s WHERE id=%s",
                        (request.form.get('new_name', ''), request.form.get('id')),
                    )
                elif action == 'toggle':
                    cur.execute(
                        "UPDATE op_broker_workflows SET active=%s WHERE id=%s",
                        (
                            1 if request.form.get('active') in ['1', 'on'] else 0,
                            request.form.get('id'),
                        ),
                    )
                conn.commit()
            cur.execute("SELECT id, name, active FROM op_broker_workflows ORDER BY id")
            rows = cur.fetchall()
    finally:
        conn.close()

    return render_template('op_workflows.html', rows=rows, aside='workflowMenu')


@app.route('/op_workflow_designer', methods=['GET', 'POST'])
def workflow_designer():
    workflow_id = request.values.get('id')
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            if request.method == 'POST':
                definition = request.form.get('definition', '{}')
                cur.execute(
                    "UPDATE op_broker_workflows SET definition=%s WHERE id=%s",
                    (definition, workflow_id),
                )
                conn.commit()
            cur.execute(
                "SELECT name, definition FROM op_broker_workflows WHERE id=%s",
                (workflow_id,),
            )
            row = cur.fetchone()
    finally:
        conn.close()

    name = row['name'] if row else ''
    definition = row['definition'] if row else ''
    return render_template(
        'op_workflow_designer.html',
        id=workflow_id,
        name=name,
        definition=definition,
        aside='workflowMenu',
    )


@app.route('/op_charging_sessions')
def charging_sessions():
    """Display charging sessions for a selectable month."""

    requested_month = request.args.get('month')
    starred_only = request.args.get('starred_only') == '1'
    import_status = request.args.get('import_status')
    import_message = request.args.get('import_message')

    conn = get_db_conn()
    sessions: List[Dict[str, Any]] = []
    available_months: List[str] = []
    try:
        ensure_charging_session_stars_table(conn)
        available_months = _available_message_months(conn)

        now_month = datetime.datetime.now().strftime("%Y-%m")
        default_month = available_months[0] if available_months else now_month

        selected_month = default_month
        if requested_month:
            try:
                datetime.datetime.strptime(requested_month, "%Y-%m")
            except ValueError:
                selected_month = default_month
            else:
                if not available_months or requested_month in available_months:
                    selected_month = requested_month
        elif requested_month is None and now_month in available_months:
            selected_month = now_month

        try:
            start = datetime.datetime.strptime(selected_month, "%Y-%m")
        except ValueError:
            start = datetime.datetime.now().replace(day=1)
            selected_month = start.strftime("%Y-%m")

        if start.month == 12:
            end = start.replace(year=start.year + 1, month=1)
        else:
            end = start.replace(month=start.month + 1)

        ongoing_label = translate_text("Ongoing")
        params: list[Any] = [start, end]
        star_filter_sql = ""
        if starred_only:
            star_filter_sql = " AND COALESCE(st.starred, 0) = 1"

        with conn.cursor() as cur:
            cur.execute(
                f"""
                SELECT s.id, s.station_id, s.connector_id, s.transaction_id, s.id_tag,
                       s.session_start, s.session_end, s.energyChargedWh,
                       s.ocmf_energy_wh,
                       COALESCE(st.starred, 0) AS starred
                FROM op_charging_sessions AS s
                LEFT JOIN op_charging_session_stars AS st ON st.session_id = s.id
                WHERE s.session_start >= %s AND s.session_start < %s
                {star_filter_sql}
                ORDER BY s.session_start DESC
                """,
                params,
            )
            rows = cur.fetchall()
            def _format_session_timestamp(value):
                if isinstance(value, datetime.datetime):
                    return value.strftime("%Y-%m-%d %H:%M")
                parsed = _parse_iso_datetime(value)
                if parsed:
                    return parsed.strftime("%Y-%m-%d %H:%M")
                return str(value) if value is not None else None
            for r in rows:
                energy = r.get('energyChargedWh')
                ocmf_energy = r.get('ocmf_energy_wh')
                session_start = r.get('session_start')
                session_end = r.get('session_end')
                formatted_start = _format_session_timestamp(session_start)
                formatted_end = _format_session_timestamp(session_end)
                sessions.append(
                    {
                        'id': r.get('id'),
                        'station_id': r.get('station_id'),
                        'connector_id': r.get('connector_id'),
                        'transaction_id': r.get('transaction_id'),
                        'id_tag': r.get('id_tag'),
                        'start': formatted_start or '',
                        'end': formatted_end or ongoing_label,
                        'energy': energy / 1000 if energy is not None else None,
                        'ocmf_energy': (
                            float(ocmf_energy) / 1000
                            if isinstance(ocmf_energy, (int, float, Decimal)) and ocmf_energy > 0
                            else None
                        ),
                        'starred': bool(r.get('starred')),
                    }
                )
    finally:
        conn.close()

    if selected_month not in available_months:
        available_months = [selected_month] + available_months

    return render_template(
        'op_charging_sessions.html',
        sessions=sessions,
        selected_month=selected_month,
        available_months=available_months,
        starred_only=starred_only,
        import_feedback={'status': import_status, 'message': import_message},
    )


@app.route('/op_charging_sessions/<int:session_id>/star', methods=['POST'])
def set_charging_session_star(session_id: int):
    """Persist the star selection for a charging session."""

    data = request.get_json(silent=True) or {}
    starred = bool(data.get('starred'))

    conn = get_db_conn()
    try:
        ensure_charging_session_stars_table(conn)
        with conn.cursor() as cur:
            cur.execute(
                "SELECT 1 FROM op_charging_sessions WHERE id = %s LIMIT 1",
                (session_id,),
            )
            if not cur.fetchone():
                conn.rollback()
                return jsonify({'error': 'session not found'}), 404

            cur.execute(
                """
                INSERT INTO op_charging_session_stars (session_id, starred)
                VALUES (%s, %s)
                ON DUPLICATE KEY UPDATE
                    starred = VALUES(starred),
                    updated_at = CURRENT_TIMESTAMP
                """,
                (session_id, 1 if starred else 0),
            )
        conn.commit()
    except pymysql.MySQLError as exc:
        conn.rollback()
        _SCHEMA_LOGGER.warning(
            "Failed to update star state for charging session %s: %s", session_id, exc
        )
        return jsonify({'error': 'update failed'}), 500
    finally:
        conn.close()

    return jsonify({'starred': bool(starred)})


@app.route('/op_charging_sessions/<int:session_id>/export_sql')
def export_charging_session_sql(session_id: int):
    """Provide a SQL dump for a session and its related messages without IDs."""

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT id, station_id, connector_id, transaction_id, id_tag, vehicle_id,
                       session_start, session_end, meter_start, meter_stop, energyChargedWh,
                       ocmf_energy_wh, reason
                FROM op_charging_sessions
                WHERE id = %s
                LIMIT 1
                """,
                (session_id,),
            )
            session_row = cur.fetchone()
        if not session_row:
            abort(404)

        messages_by_table = _collect_session_messages(conn, session_row)
    finally:
        conn.close()

    session_columns = [
        'station_id',
        'connector_id',
        'transaction_id',
        'id_tag',
        'vehicle_id',
        'session_start',
        'session_end',
        'meter_start',
        'meter_stop',
        'energyChargedWh',
        'ocmf_energy_wh',
        'reason',
    ]
    session_values = ", ".join(_escape_sql_value(session_row.get(col)) for col in session_columns)
    session_insert = (
        f"INSERT INTO op_charging_sessions ({', '.join(f'`{c}`' for c in session_columns)}) "
        f"VALUES ({session_values});"
    )

    export_time = datetime.datetime.utcnow()
    export_label = f"export_{export_time.strftime('%Y%m%d%H%M%S')}"

    sql_parts = [
        f"-- Export of charging session {session_id}",
        f"-- Generated at {export_time.strftime('%Y-%m-%d %H:%M:%S')} UTC",
        session_insert,
    ]

    message_columns = [
        'source_url',
        'topic',
        'direction',
        'connection_id',
        'ocpp_endpoint',
        'message',
        'timestamp',
    ]
    for table_name, rows in messages_by_table.items():
        sql_parts.append(f"-- Messages from {table_name}")
        col_list = ', '.join(f'`{c}`' for c in message_columns)
        for row in rows:
            values = ", ".join(
                _escape_sql_value(row.get(col) if col != 'ocpp_endpoint' else export_label)
                for col in message_columns
            )
            sql_parts.append(f"INSERT INTO `{table_name}` ({col_list}) VALUES ({values});")

    sql_bytes = "\n".join(sql_parts).encode('utf-8')
    buffer = BytesIO(sql_bytes)
    buffer.seek(0)
    return send_file(
        buffer,
        mimetype='text/sql',
        as_attachment=True,
        download_name=f"session_{session_id}_export.sql",
    )


@app.route('/op_charging_sessions/import_sql', methods=['POST'])
def import_charging_session_sql():
    """Execute a SQL dump created by the session export helper."""

    uploaded_file = request.files.get('sql_file')
    if not uploaded_file or not uploaded_file.filename:
        message = translate_text('No SQL file selected.')
        return redirect(
            url_for(
                'charging_sessions',
                import_status='error',
                import_message=message,
            )
        )

    try:
        raw_sql = uploaded_file.read().decode('utf-8')
    except UnicodeDecodeError:
        message = translate_text('The file could not be read (UTF-8 expected).')
        return redirect(
            url_for(
                'charging_sessions',
                import_status='error',
                import_message=message,
            )
        )

    statements = [stmt.strip() for stmt in raw_sql.split(';') if stmt.strip()]
    if not statements:
        message = translate_text('The file does not contain executable SQL statements.')
        return redirect(
            url_for(
                'charging_sessions',
                import_status='error',
                import_message=message,
            )
        )

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            for stmt in statements:
                cur.execute(stmt)
        conn.commit()
    except pymysql.MySQLError as exc:
        conn.rollback()
        logging.warning("SQL import failed: %s", exc)
        message = translate_text('Import failed: SQL error.')
        return redirect(
            url_for(
                'charging_sessions',
                import_status='error',
                import_message=message,
            )
        )
    finally:
        conn.close()

    message = translate_text('SQL import completed successfully.')
    return redirect(
        url_for(
            'charging_sessions',
            import_status='success',
            import_message=message,
        )
    )


def _normalize_datetime(value: Any) -> Optional[datetime.datetime]:
    """Return a naive UTC datetime for display and querying purposes."""

    if isinstance(value, datetime.datetime):
        result = value
        if value.tzinfo is not None:
            try:
                result = value.astimezone(datetime.timezone.utc)
            except (OSError, ValueError):
                result = value.replace(tzinfo=None)
            else:
                result = result.replace(tzinfo=None)
        return result
    return None


def _format_timestamp(value: Optional[datetime.datetime]) -> str:
    if not isinstance(value, datetime.datetime):
        return "-"
    return value.strftime("%Y-%m-%d %H:%M:%S")


def _format_duration(delta: Optional[datetime.timedelta]) -> str:
    if not isinstance(delta, datetime.timedelta):
        return "-"

    seconds = int(delta.total_seconds())
    if seconds < 0:
        seconds = abs(seconds)

    hours, remainder = divmod(seconds, 3600)
    minutes, seconds = divmod(remainder, 60)

    parts: list[str] = []
    if hours:
        parts.append(f"{hours} h")
    if minutes:
        parts.append(f"{minutes} min")
    if not parts:
        parts.append(f"{seconds} s")
    return " ".join(parts)


def _month_sequence(start: datetime.datetime, end: datetime.datetime) -> list[str]:
    months: list[str] = []
    cursor = datetime.datetime(start.year, start.month, 1)
    limit = datetime.datetime(end.year, end.month, 1)
    while cursor <= limit:
        months.append(cursor.strftime("%y%m"))
        if cursor.month == 12:
            cursor = cursor.replace(year=cursor.year + 1, month=1)
        else:
            cursor = cursor.replace(month=cursor.month + 1)
    return months


def _available_message_months(conn) -> List[str]:
    """Return a sorted list of months (YYYY-MM) with available message tables."""

    try:
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_messages_%'")
            rows = cur.fetchall()
    except pymysql.MySQLError:
        return []

    months: set[str] = set()
    for row in rows:
        table_name = next(iter(row.values()), "")
        suffix = table_name.rsplit('_', 1)[-1]
        try:
            parsed = datetime.datetime.strptime(suffix, "%y%m")
        except ValueError:
            continue
        months.add(parsed.strftime("%Y-%m"))

    return sorted(months, reverse=True)


def _escape_sql_value(value: Any) -> str:
    """Return a SQL-ready literal, omitting IDs for portable dumps."""

    if value is None:
        return "NULL"
    if isinstance(value, bool):
        return "1" if value else "0"
    if isinstance(value, datetime.datetime):
        value = value.strftime("%Y-%m-%d %H:%M:%S")
    elif isinstance(value, Decimal):
        value = str(value)
    elif isinstance(value, (int, float)):
        return str(value)
    else:
        value = str(value)

    try:
        escaped = escape_string(value)
        if isinstance(escaped, bytes):
            escaped = escaped.decode()
    except Exception:
        escaped = value.replace("\\", "\\\\").replace("'", "\\'")
    return f"'{escaped}'"


def _session_message_window(session_row: Mapping[str, Any]) -> tuple[datetime.datetime, datetime.datetime]:
    """Return the start/end window for exporting session messages."""

    session_start = _normalize_datetime(session_row.get('session_start'))
    session_end = _normalize_datetime(session_row.get('session_end'))

    margin = datetime.timedelta(minutes=30)
    if session_start and session_end:
        range_start = session_start - margin
        range_end = session_end + margin
    elif session_start:
        range_start = session_start - margin
        range_end = session_start + datetime.timedelta(hours=6)
    elif session_end:
        range_start = session_end - datetime.timedelta(hours=6)
        range_end = session_end + margin
    else:
        now = datetime.datetime.utcnow()
        range_start = now - datetime.timedelta(hours=1)
        range_end = now + datetime.timedelta(hours=1)

    if range_end <= range_start:
        range_end = range_start + datetime.timedelta(minutes=30)
    return range_start, range_end


def _collect_session_messages(conn, session_row: Mapping[str, Any]) -> dict[str, list[dict[str, Any]]]:
    """Load OCPP messages around the session for SQL export."""

    station_id = session_row.get('station_id')
    if not station_id:
        return {}

    with conn.cursor() as cur:
        cur.execute("SHOW TABLES LIKE 'op_messages_%';")
        available_tables = {list(row.values())[0] for row in cur.fetchall()}

    range_start, range_end = _session_message_window(session_row)
    months = _month_sequence(range_start, range_end)
    messages_by_table: dict[str, list[dict[str, Any]]] = {}

    for month in months:
        table_name = f"op_messages_{month}"
        if table_name not in available_tables:
            continue
        sql = (
            f"""
            SELECT source_url, topic, direction, connection_id, ocpp_endpoint, message, timestamp
            FROM {table_name}
            WHERE source_url = %s
              AND timestamp >= %s
              AND timestamp <= %s
            ORDER BY timestamp ASC
            """
        )
        with conn.cursor() as cur:
            cur.execute(sql, (station_id, range_start, range_end))
            rows = cur.fetchall()
        if rows:
            messages_by_table[table_name] = rows

    return messages_by_table


def _coerce_float(value: Any) -> Optional[float]:
    if isinstance(value, Decimal):
        try:
            return float(value)
        except (TypeError, ValueError):
            return None
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        return float(value)
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _display_value(value: Any) -> Any:
    if value is None or value == '':
        return '-'
    return value


def _normalize_connector_identifier(value: Any) -> Optional[int]:
    if value is None or isinstance(value, bool):
        return None
    if isinstance(value, int):
        return value
    if isinstance(value, Decimal):
        try:
            integral = value.to_integral_value()
        except InvalidOperation:
            return None
        if value != integral:
            return None
        try:
            return int(integral)
        except (OverflowError, ValueError):
            return None
    try:
        text = str(value).strip()
    except Exception:
        return None
    if not text:
        return None
    try:
        number = Decimal(text)
    except (InvalidOperation, ValueError):
        return None
    integral = number.to_integral_value()
    if number != integral:
        return None
    try:
        return int(integral)
    except (OverflowError, ValueError):
        return None


def _extract_connector_ids_from_message(raw_message: Any) -> set[int]:
    connectors: set[int] = set()
    if not raw_message:
        return connectors
    try:
        payload = json.loads(raw_message)
    except Exception:
        return connectors

    def _collect(value: Any) -> None:
        if isinstance(value, dict):
            for key, candidate in value.items():
                normalized_key = str(key).lower().replace('_', '')
                if normalized_key == 'connectorid':
                    connector_value = _normalize_connector_identifier(candidate)
                    if connector_value is not None:
                        connectors.add(connector_value)
                else:
                    _collect(candidate)
        elif isinstance(value, list):
            for item in value:
                _collect(item)

    _collect(payload)
    return connectors


def _format_sql_with_params(sql: str, params: Iterable[Any]) -> str:
    """Render a SQL statement with parameters substituted for display purposes."""

    def _format_param(value: Any) -> str:
        if value is None:
            return 'NULL'
        if isinstance(value, str):
            return "'" + value.replace("'", "''") + "'"
        if isinstance(value, (datetime.datetime, datetime.date)):
            return "'" + value.isoformat(sep=' ', timespec='seconds') + "'"
        if isinstance(value, datetime.timedelta):
            return f"'{value}'"
        return str(value)

    param_list = list(params)
    sql_parts = sql.split('%s')
    rendered = []

    for index, part in enumerate(sql_parts[:-1]):
        rendered.append(part)
        try:
            rendered.append(_format_param(param_list[index]))
        except IndexError:
            rendered.append('%s')
    rendered.append(sql_parts[-1])

    return ''.join(rendered)



def _load_charging_session_analysis_payload(session_id: int) -> dict[str, Any]:
    """Collect all data required for the charging session analysis view/export."""

    conn = get_db_conn()
    session_row: Optional[dict[str, Any]] = None
    available_tables: set[str] = set()
    messages: list[dict[str, Any]] = []
    ocpp_queries: list[str] = []

    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT id, station_id, connector_id, transaction_id, id_tag,
                       vehicle_id, session_start, session_end, meter_start,
                       meter_stop, energyChargedWh, reason
                FROM op_charging_sessions
                WHERE id = %s
                """,
                (session_id,),
            )
            session_row = cur.fetchone()

        if not session_row:
            abort(404)

        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_messages_%';")
            available_tables = {list(row.values())[0] for row in cur.fetchall()}

        station_id = session_row.get('station_id')
        session_start = _normalize_datetime(session_row.get('session_start'))
        session_end = _normalize_datetime(session_row.get('session_end'))

        margin = datetime.timedelta(minutes=30)
        if session_start and session_end:
            range_start = session_start - margin
            range_end = session_end + margin
        elif session_start:
            range_start = session_start - margin
            range_end = session_start + datetime.timedelta(hours=6)
        elif session_end:
            range_start = session_end - datetime.timedelta(hours=6)
            range_end = session_end + margin
        else:
            now = datetime.datetime.utcnow()
            range_start = now - datetime.timedelta(hours=1)
            range_end = now + datetime.timedelta(hours=1)

        if range_end <= range_start:
            range_end = range_start + datetime.timedelta(minutes=30)

        months = _month_sequence(range_start, range_end)
        consulted_tables: list[str] = []

        if station_id:
            limit_per_table = 2500
            source_urls: list[str] = []
            if isinstance(station_id, str):
                base_station_id = station_id.split('/', 1)[-1] or station_id
                for candidate in (station_id, base_station_id, f"ocpp/{base_station_id}"):
                    if candidate and candidate not in source_urls:
                        source_urls.append(candidate)
            else:
                source_urls.append(station_id)

            for month in months:
                table_name = f"op_messages_{month}"
                if table_name not in available_tables or not source_urls:
                    continue
                consulted_tables.append(table_name)

                placeholders = ", ".join(["%s"] * len(source_urls))
                sql = (
                    f"""
                    SELECT id, topic, direction, message, connection_id, timestamp
                    FROM {table_name}
                    WHERE source_url IN ({placeholders})
                      AND timestamp >= %s
                      AND timestamp <= %s
                    ORDER BY timestamp ASC
                    LIMIT %s
                    """
                )
                with conn.cursor() as cur:
                    cur.execute(
                        sql,
                        (*source_urls, range_start, range_end, limit_per_table),
                    )
                    rows = cur.fetchall()
                    ocpp_queries.append(
                        _format_sql_with_params(
                            sql, (*source_urls, range_start, range_end, limit_per_table)
                        )
                    )
                for row in rows:
                    messages.append(row)
        else:
            consulted_tables = []

    finally:
        conn.close()

    connector_filter = _normalize_connector_identifier(session_row.get('connector_id'))
    if connector_filter is not None:
        filtered_messages: list[dict[str, Any]] = []
        for message in messages:
            connectors = _extract_connector_ids_from_message(message.get('message'))
            if not connectors or connector_filter in connectors:
                filtered_messages.append(message)
        messages = filtered_messages

    messages.sort(key=lambda item: item.get('timestamp') or datetime.datetime.min)

    direction_labels = [
        ('server_to_client', 'CPMS ⇒ Broker'),
        ('client_to_server', 'CP ⇒ Broker'),
        ('server_to_extapi', 'Broker ⇒ External API'),
        ('extapi_to_server', 'External API ⇒ Broker'),
    ]

    for message in messages:
        direction_value = (message.get('direction') or '').lower()
        mapped_direction = next(
            (
                label
                for prefix, label in direction_labels
                if direction_value.startswith(prefix)
            ),
            message.get('direction'),
        )
        message['display_direction'] = mapped_direction
        message['timestamp_display'] = _format_timestamp(
            _normalize_datetime(message.get('timestamp'))
        )
        message['formatted_message'] = format_json(message.get('message'))

    meter_start_value = _coerce_float(session_row.get('meter_start'))
    meter_stop_value = _coerce_float(session_row.get('meter_stop'))
    meter_delta_text = '-'
    if meter_start_value is not None and meter_stop_value is not None:
        meter_delta = meter_stop_value - meter_start_value
        meter_delta_text = f"{meter_delta:.2f}"

    energy_wh = _coerce_float(session_row.get('energyChargedWh'))
    energy_kwh_text = '-'
    if energy_wh is not None:
        energy_kwh_text = f"{energy_wh / 1000.0:.2f}"

    session_start = _normalize_datetime(session_row.get('session_start'))
    session_end = _normalize_datetime(session_row.get('session_end'))

    session_duration = None
    if session_start and session_end:
        session_duration = session_end - session_start

    session_details = [
        {'label': 'Session ID', 'value': _display_value(session_row.get('id'))},
        {'label': 'Chargepoint', 'value': _display_value(session_row.get('station_id'))},
        {'label': 'Connector', 'value': _display_value(session_row.get('connector_id'))},
        {'label': 'Transaction ID', 'value': _display_value(session_row.get('transaction_id'))},
        {'label': 'RFID', 'value': _display_value(session_row.get('id_tag'))},
        {'label': 'Vehicle ID', 'value': _display_value(session_row.get('vehicle_id'))},
        {'label': 'Start', 'value': _format_timestamp(session_start)},
        {'label': 'Ende', 'value': _format_timestamp(session_end)},
        {'label': 'Dauer', 'value': _format_duration(session_duration)},
        {'label': 'Meter Start', 'value': _display_value(session_row.get('meter_start'))},
        {'label': 'Meter Ende', 'value': _display_value(session_row.get('meter_stop'))},
        {'label': 'Meter Δ', 'value': meter_delta_text},
        {'label': 'Energie (kWh)', 'value': energy_kwh_text},
        {'label': 'Beendigungsgrund', 'value': _display_value(session_row.get('reason'))},
    ]

    meter_samples: list[dict[str, Any]] = []
    for message in messages:
        raw_message = message.get('message')
        if not raw_message:
            continue
        fallback_ts = _normalize_datetime(message.get('timestamp'))
        samples = _extract_meter_samples_from_message(raw_message, fallback_ts)
        if samples:
            meter_samples.extend(samples)

    chart_labels, chart_datasets, chart_axes = _build_meter_chart_payload(
        meter_samples, session_start, session_end
    )
    soc_analytics = _build_soc_analytics(meter_samples, session_start)
    soc_segments = None
    soc_chart_points = None
    if isinstance(soc_analytics, dict):
        segments_candidate = soc_analytics.get('segments')
        if isinstance(segments_candidate, list):
            soc_segments = segments_candidate
        chart_points_candidate = soc_analytics.get('chart_points')
        if isinstance(chart_points_candidate, list) and chart_points_candidate:
            soc_chart_points = chart_points_candidate

    analysis_window = {
        'start': _format_timestamp(range_start),
        'end': _format_timestamp(range_end),
    }

    return {
        'session': session_row,
        'session_details': session_details,
        'messages': messages,
        'analysis_window': analysis_window,
        'consulted_tables': consulted_tables,
        'messages_query_debug': ocpp_queries,
        'chart_labels': chart_labels,
        'chart_datasets': chart_datasets,
        'chart_axes': chart_axes,
        'soc_analytics': soc_analytics,
        'soc_segments': soc_segments,
        'soc_chart_points': soc_chart_points,
        'meter_samples': meter_samples,
    }


@app.route('/op_charging_session_analysis')
def charging_session_analysis_view():
    session_id_raw = request.args.get('session_id')
    try:
        session_id = int(session_id_raw) if session_id_raw is not None else None
    except (TypeError, ValueError):
        session_id = None

    if session_id is None:
        abort(404)

    return_month = request.args.get('return_month')

    payload = _load_charging_session_analysis_payload(session_id)

    return render_template(
        'op_charging_session_analysis.html',
        return_month=return_month,
        **payload,
    )


@app.route('/op_charging_session_analysis/training_data')
def charging_session_analysis_training_data():
    session_id_raw = request.args.get('session_id')
    try:
        session_id = int(session_id_raw) if session_id_raw is not None else None
    except (TypeError, ValueError):
        session_id = None

    if session_id is None:
        abort(404)

    payload = _load_charging_session_analysis_payload(session_id)

    def _serialize_datetime(value: Any) -> Optional[str]:
        normalized = _normalize_datetime(value)
        if not isinstance(normalized, datetime.datetime):
            return None
        return normalized.strftime("%Y-%m-%d %H:%M:%S")

    def _serialize_decimal(value: Any) -> Optional[str]:
        if isinstance(value, Decimal):
            return format(value, 'f')
        return str(value) if value is not None else None

    session_row = payload.get('session') or {}
    session_export: dict[str, Any] = {}
    for key, value in session_row.items():
        if isinstance(value, datetime.datetime):
            session_export[key] = _serialize_datetime(value)
        elif isinstance(value, Decimal):
            session_export[key] = _serialize_decimal(value)
        else:
            session_export[key] = value

    ocpp_messages: list[dict[str, Any]] = []
    for message in payload.get('messages', []):
        ocpp_messages.append(
            {
                'id': message.get('id'),
                'timestamp': _serialize_datetime(message.get('timestamp')),
                'timestamp_display': message.get('timestamp_display'),
                'direction': message.get('display_direction'),
                'direction_raw': message.get('direction'),
                'topic': message.get('topic'),
                'connection_id': message.get('connection_id'),
                'message': message.get('message'),
                'formatted_message': message.get('formatted_message'),
            }
        )

    meter_samples_export: list[dict[str, Any]] = []
    for sample in payload.get('meter_samples', []):
        entry: dict[str, Any] = {}
        for key, value in sample.items():
            if key == 'timestamp':
                entry[key] = _serialize_datetime(value)
            elif key == 'value':
                entry[key] = _serialize_decimal(value)
            else:
                entry[key] = value
        meter_samples_export.append(entry)

    export_payload = {
        'session_id': session_id,
        'generated_at': datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
        'session': session_export,
        'summary': payload.get('session_details', []),
        'analysis_window': payload.get('analysis_window'),
        'consulted_tables': payload.get('consulted_tables', []),
        'soc_analytics': payload.get('soc_analytics'),
        'meter_values': {
            'samples': meter_samples_export,
            'chart': {
                'labels': payload.get('chart_labels', []),
                'datasets': payload.get('chart_datasets', []),
                'axes': payload.get('chart_axes', {}),
            },
        },
        'ocpp_messages': ocpp_messages,
    }

    json_bytes = json.dumps(export_payload, ensure_ascii=False, indent=2).encode('utf-8')
    buffer = BytesIO(json_bytes)
    filename = f"session_{session_id}_training_data.json"
    buffer.seek(0)
    return send_file(
        buffer,
        mimetype='application/json',
        as_attachment=True,
        download_name=filename,
    )


@app.route('/op_vehicle_analytics_sessions', methods=['GET', 'POST'])
def vehicle_analytics_sessions():
    """List charging sessions for analytics-enabled wallboxes with vehicle assignment."""

    message = None
    errors = []
    conn = get_db_conn()
    sessions = []
    vehicles = []
    try:
        ensure_charging_sessions_vehicle_column(conn)

        if request.method == 'POST':
            session_id_raw = (request.form.get('session_id') or '').strip()
            vehicle_id_raw = (request.form.get('vehicle_id') or '').strip()

            session_id_value = None
            try:
                session_id_value = int(session_id_raw)
            except (TypeError, ValueError):
                errors.append('Invalid session selected.')

            vehicle_id_value = 0
            if vehicle_id_raw:
                try:
                    vehicle_id_value = int(vehicle_id_raw)
                except (TypeError, ValueError):
                    errors.append('Invalid vehicle selected.')
                else:
                    if vehicle_id_value != 0:
                        with conn.cursor() as cur:
                            cur.execute(
                                "SELECT id FROM op_vehicle_fleet WHERE id=%s",
                                (vehicle_id_value,),
                            )
                            if not cur.fetchone():
                                errors.append('Selected vehicle was not found.')

            if not errors and session_id_value is not None:
                with conn.cursor() as cur:
                    cur.execute(
                        """
                        SELECT cs.id
                        FROM op_charging_sessions cs
                        JOIN op_redirects r
                          ON cs.station_id = SUBSTRING_INDEX(SUBSTRING_INDEX(r.source_url, '/', -1), '?', 1)
                        WHERE cs.id = %s AND r.charging_analytics = 1
                        """,
                        (session_id_value,),
                    )
                    if not cur.fetchone():
                        errors.append('Selected session is not eligible for assignment.')
                    else:
                        cur.execute(
                            "UPDATE op_charging_sessions SET vehicle_id=%s WHERE id=%s",
                            (vehicle_id_value, session_id_value),
                        )
                        conn.commit()
                        message = 'Vehicle assignment updated successfully.'

        with conn.cursor() as cur:
            cur.execute(
                "SELECT id, name FROM op_vehicle_fleet ORDER BY name"
            )
            vehicles = cur.fetchall()

        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT cs.id, cs.session_start, cs.session_end, cs.transaction_id,
                       cs.station_id, cs.id_tag, cs.energyChargedWh, cs.vehicle_id,
                       vf.name AS vehicle_name
                FROM op_charging_sessions cs
                JOIN op_redirects r
                  ON cs.station_id = SUBSTRING_INDEX(SUBSTRING_INDEX(r.source_url, '/', -1), '?', 1)
                LEFT JOIN op_vehicle_fleet vf ON vf.id = cs.vehicle_id
                WHERE r.charging_analytics = 1
                ORDER BY cs.session_start DESC, cs.id DESC
                """
            )
            rows = cur.fetchall()

        for row in rows:
            start = row.get('session_start')
            end = row.get('session_end')
            start_display = start.strftime("%Y-%m-%d %H:%M:%S") if start else '-'
            end_display = end.strftime("%Y-%m-%d %H:%M:%S") if end else 'Ongoing'

            duration_display = '-'
            if start and end:
                duration_seconds = int((end - start).total_seconds())
                duration_display = _format_duration_seconds(duration_seconds)

            energy_wh = row.get('energyChargedWh')
            kwh_display = '-'
            if energy_wh is not None:
                try:
                    kwh_display = f"{(Decimal(energy_wh) / Decimal(1000)).quantize(_DECIMAL_TWO_PLACES)}"
                except (InvalidOperation, TypeError):
                    kwh_display = '-'

            sessions.append(
                {
                    'id': row.get('id'),
                    'start_display': start_display,
                    'end_display': end_display,
                    'vehicle_name': row.get('vehicle_name') or '',
                    'vehicle_id': row.get('vehicle_id') or 0,
                    'duration_display': duration_display,
                    'transaction_id': row.get('transaction_id'),
                    'station_id': row.get('station_id'),
                    'id_tag': row.get('id_tag'),
                    'kwh_display': kwh_display,
                }
            )
    finally:
        conn.close()

    return render_template(
        'op_vehicle_analytics_sessions.html',
        sessions=sessions,
        vehicles=vehicles,
        message=message,
        errors=errors,
        aside='chargingAnalyticsMenu',
    )


@app.route('/op_vehicle_session_list')
def vehicle_session_list():
    """Display all charging sessions that have a vehicle assignment."""

    include_unassigned = _is_truthy(request.args.get('include_unassigned', '1'))

    conn = get_db_conn()
    sessions = []
    try:
        ensure_charging_sessions_vehicle_column(conn)
        ensure_vehicle_session_highlights_table(conn)

        where_clause = ""
        query_params: list[Any] = []
        if not include_unassigned:
            where_clause = "WHERE cs.vehicle_id IS NOT NULL AND cs.vehicle_id <> 0"

        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT cs.id,
                       cs.session_start,
                       cs.session_end,
                       cs.transaction_id,
                       cs.station_id,
                       cs.id_tag,
                       cs.energyChargedWh,
                       vf.name AS vehicle_name,
                       CASE WHEN hv.transaction_id IS NULL THEN 0 ELSE 1 END AS highlighted
                FROM op_charging_sessions cs
                LEFT JOIN op_vehicle_fleet vf ON vf.id = cs.vehicle_id
                LEFT JOIN op_vehicle_session_highlights hv ON hv.transaction_id = cs.transaction_id
                {where_clause}
                ORDER BY cs.session_start DESC, cs.id DESC
                """.format(where_clause=where_clause),
                query_params,
            )
            rows = cur.fetchall()

        for row in rows:
            start = row.get('session_start')
            end = row.get('session_end')
            start_display = start.strftime("%Y-%m-%d %H:%M:%S") if isinstance(start, datetime.datetime) else '-'
            end_display = end.strftime("%Y-%m-%d %H:%M:%S") if isinstance(end, datetime.datetime) else 'Ongoing'

            duration_display = '-'
            if isinstance(start, datetime.datetime) and isinstance(end, datetime.datetime):
                duration_seconds = int((end - start).total_seconds())
                duration_display = _format_duration_seconds(duration_seconds)

            kwh_display = '-'
            energy_wh = row.get('energyChargedWh')
            decimal_energy = _safe_decimal(energy_wh)
            if decimal_energy is not None:
                try:
                    kwh_display = f"{(decimal_energy / Decimal(1000)).quantize(_DECIMAL_TWO_PLACES)}"
                except (InvalidOperation, TypeError):
                    kwh_display = '-'

            sessions.append(
                {
                    'id': row.get('id'),
                    'start_display': start_display,
                    'end_display': end_display,
                    'vehicle_name': row.get('vehicle_name') or '',
                    'duration_display': duration_display,
                    'transaction_id': row.get('transaction_id'),
                    'station_id': row.get('station_id'),
                    'id_tag': row.get('id_tag'),
                    'kwh_display': kwh_display,
                    'highlighted': bool(row.get('highlighted')),
                }
            )
    finally:
        conn.close()

    return render_template(
        'op_vehicle_session_list.html',
        sessions=sessions,
        include_unassigned=include_unassigned,
        aside='chargingAnalyticsMenu',
    )


@app.route('/op_vehicle_session_highlight', methods=['POST'])
def vehicle_session_highlight():
    """Toggle highlight state for a charging session transaction."""

    data = request.get_json(force=True, silent=True) or {}
    transaction_id = str(data.get('transaction_id') or '').strip()
    highlight_value = data.get('highlight')

    if not transaction_id:
        return jsonify({'success': False, 'error': 'transaction_id required'}), 400

    highlight = False
    if isinstance(highlight_value, str):
        highlight = highlight_value.lower() in {'1', 'true', 'yes', 'on'}
    elif isinstance(highlight_value, (int, float)):
        highlight = bool(highlight_value)
    else:
        highlight = bool(highlight_value)

    conn = get_db_conn()
    try:
        ensure_vehicle_session_highlights_table(conn)
        with conn.cursor() as cur:
            if highlight:
                cur.execute(
                    """
                    INSERT INTO op_vehicle_session_highlights (transaction_id)
                    VALUES (%s)
                    ON DUPLICATE KEY UPDATE transaction_id = VALUES(transaction_id)
                    """,
                    (transaction_id,),
                )
            else:
                cur.execute(
                    "DELETE FROM op_vehicle_session_highlights WHERE transaction_id=%s",
                    (transaction_id,),
                )
        conn.commit()
    finally:
        conn.close()

    return jsonify({'success': True, 'highlight': highlight})


@app.route('/op_station_highlight', methods=['POST'])
def station_highlight():
    """Toggle highlight state for a wallbox displayed on the dashboard."""

    data = request.get_json(force=True, silent=True) or {}
    station_id_raw = str(data.get('station_id') or '').strip()
    highlight_value = data.get('highlight')

    if not station_id_raw:
        return jsonify({'success': False, 'error': 'station_id required'}), 400

    highlight = False
    if isinstance(highlight_value, str):
        highlight = highlight_value.lower() in {'1', 'true', 'yes', 'on'}
    elif isinstance(highlight_value, (int, float)):
        highlight = bool(highlight_value)
    else:
        highlight = bool(highlight_value)

    normalized_station_id = normalize_station_id(station_id_raw).rsplit('/', 1)[-1]
    station_id = normalized_station_id or station_id_raw

    conn = get_db_conn()
    try:
        ensure_station_highlights_table(conn)
        with conn.cursor() as cur:
            if highlight:
                cur.execute(
                    """
                    INSERT INTO op_station_highlights (station_id)
                    VALUES (%s)
                    ON DUPLICATE KEY UPDATE station_id = VALUES(station_id)
                    """,
                    (station_id,),
                )
            else:
                cur.execute(
                    "DELETE FROM op_station_highlights WHERE station_id=%s",
                    (station_id,),
                )
        conn.commit()
    finally:
        conn.close()

    response_payload = {
        'success': True,
        'highlight': highlight,
        'station_id': station_id,
        'card_target': _build_station_card_anchor(station_id),
    }

    return jsonify(response_payload)


@app.route('/op_vehicle_session_analytics')
def vehicle_session_analytics():
    """Show analytics information for a single charging session."""

    session_id_raw = (request.args.get('session_id') or '').strip()
    try:
        session_id = int(session_id_raw)
    except (TypeError, ValueError):
        return "Invalid session", 400

    conn = get_db_conn()
    try:
        ensure_charging_sessions_vehicle_column(conn)
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT cs.id,
                       cs.session_start,
                       cs.session_end,
                       cs.transaction_id,
                       cs.station_id,
                       cs.id_tag,
                       cs.energyChargedWh,
                       vf.name AS vehicle_name
                FROM op_charging_sessions cs
                LEFT JOIN op_vehicle_fleet vf ON vf.id = cs.vehicle_id
                WHERE cs.id = %s
                """,
                (session_id,),
            )
            row = cur.fetchone()
        if not row:
            return "Session not found", 404

        start = row.get('session_start')
        end = row.get('session_end')
        start_norm = _normalize_datetime(start)
        end_norm = _normalize_datetime(end)
        start_display = start.strftime("%Y-%m-%d %H:%M:%S") if isinstance(start, datetime.datetime) else '-'
        end_display = end.strftime("%Y-%m-%d %H:%M:%S") if isinstance(end, datetime.datetime) else 'Ongoing'

        duration_display = '-'
        if isinstance(start, datetime.datetime) and isinstance(end, datetime.datetime):
            duration_seconds = int((end - start).total_seconds())
            duration_display = _format_duration_seconds(duration_seconds)

        kwh_display = '-'
        energy_wh = row.get('energyChargedWh')
        decimal_energy = _safe_decimal(energy_wh)
        if decimal_energy is not None:
            try:
                kwh_display = f"{(decimal_energy / Decimal(1000)).quantize(_DECIMAL_TWO_PLACES)}"
            except (InvalidOperation, TypeError):
                kwh_display = '-'

        session_info = {
            'start_display': start_display,
            'end_display': end_display,
            'vehicle_name': row.get('vehicle_name') or '',
            'duration_display': duration_display,
            'transaction_id': row.get('transaction_id'),
            'station_id': row.get('station_id'),
            'id_tag': row.get('id_tag'),
            'kwh_display': kwh_display,
            'temperature_first_display': '—',
            'temperature_last_display': '—',
        }

        meter_samples = _collect_meter_values_for_transaction(
            conn,
            row.get('transaction_id'),
            start=start,
            end=end,
        )

        weather_series = _collect_weather_temperature_series(
            conn,
            row.get('station_id'),
            start=start,
            end=end,
        )

        if weather_series:
            first_temperature = weather_series[0][1]
            last_temperature = weather_series[-1][1]
            try:
                session_info['temperature_first_display'] = f"{first_temperature.quantize(_DECIMAL_ONE_PLACE)} °C"
            except (InvalidOperation, ValueError):
                pass
            try:
                session_info['temperature_last_display'] = f"{last_temperature.quantize(_DECIMAL_ONE_PLACE)} °C"
            except (InvalidOperation, ValueError):
                pass

        weather_samples = [
            {
                'timestamp': obs_time,
                'value': temperature,
                'unit': '°C',
                'measurand': 'weather.temperature',
            }
            for obs_time, temperature in weather_series
        ]

        combined_samples = list(meter_samples or [])
        combined_samples.extend(weather_samples)

        chart_labels, chart_datasets, chart_axes = _build_meter_chart_payload(
            combined_samples, start_norm, end_norm
        )
    finally:
        conn.close()

    return render_template(
        'op_vehicle_session_analytics.html',
        chart_labels=chart_labels,
        chart_datasets=chart_datasets,
        chart_axes=chart_axes,
        session_info=session_info,
        aside='chargingAnalyticsMenu',
    )


@app.route('/op_edit', methods=['GET', 'POST'])
@app.route('/v2/proxy_edit', methods=['GET', 'POST'])
def edit():
    if request.method == 'POST':
        # --- POST: Formular abgesendet, neues Redirect speichern ---
        orig_source = request.form['orig_source_url']
        new_source = request.form['source_url']
        new_act = request.form['activity']
        selected_base = request.form.get('target_base', '').strip()
        custom_ws = request.form['ws_url']
        mqtt_enabled = 1 if request.form.get('mqtt_enabled') else 0
        measure_ping = 1 if request.form.get('measure_ping') else 0
        strict_availability = 1 if request.form.get('strict_availability') else 0
        charging_analytics = 1 if request.form.get('charging_analytics') else 0
        extended_session_log = 1 if request.form.get('extended_session_log') else 0
        pnc_enabled = 1 if request.form.get('pnc_enabled') else 0
        disconnect_alert_enabled = 1 if request.form.get('disconnect_alert_enabled') else 0
        disconnect_alert_email = request.form.get('disconnect_alert_email', '').strip()
        ocpp_subprotocol_choice = (request.form.get('ocpp_subprotocol') or '').strip()
        backend_user = request.form.get('backend_basic_user', '').strip()
        backend_password = request.form.get('backend_basic_password', '')
        location_name = request.form.get('location_name', '').strip()
        location_link = request.form.get('location_link', '').strip()
        webui_remote_access_url = request.form.get('webui_remote_access_url', '').strip()
        load_management_remote_access_url = request.form.get('load_management_remote_access_url', '').strip()
        public_evse_id = request.form.get('public_evse_id', '').strip()
        comment = request.form.get('comment', '').strip()
        if len(comment) > 200:
            comment = comment[:200]
        tenant_choice = (request.form.get('tenant_id') or '').strip()

        backend_user_db_value = backend_user if backend_user else ''
        backend_password_db_value = backend_password if backend_password else ''
        location_name_db_value = location_name if location_name else ''
        location_link_db_value = location_link if location_link else ''
        webui_remote_access_db_value = (
            webui_remote_access_url if webui_remote_access_url else None
        )
        load_management_remote_access_db_value = (
            load_management_remote_access_url if load_management_remote_access_url else None
        )
        disconnect_alert_email_db_value = (
            disconnect_alert_email if disconnect_alert_email else None
        )
        ocpp_subprotocol_db_value: str | None
        ocpp_subprotocol_lower = ocpp_subprotocol_choice.lower()
        if not ocpp_subprotocol_choice or ocpp_subprotocol_lower == 'auto':
            ocpp_subprotocol_db_value = None
        elif ocpp_subprotocol_lower.startswith('ocpp2.0'):
            ocpp_subprotocol_db_value = 'ocpp2.0.1'
        elif ocpp_subprotocol_lower.startswith('ocpp1.6') or ocpp_subprotocol_lower == 'ocpp16':
            ocpp_subprotocol_db_value = 'ocpp1.6'
        else:
            ocpp_subprotocol_db_value = ocpp_subprotocol_choice
        comment_db_value = comment if comment else None
        tenant_id_db_value: int | None = None
        if tenant_choice and tenant_choice.lower() != 'admin':
            try:
                tenant_id_db_value = int(tenant_choice)
            except (TypeError, ValueError):
                tenant_id_db_value = None

        station_id = normalize_station_id(new_source).rsplit('/', 1)[-1]

        if selected_base:
            new_ws = f"{selected_base.rstrip('/')}/{station_id}"
        else:
            new_ws = custom_ws

        conn = get_db_conn()
        try:
            ensure_op_redirects_columns(conn)
            ensure_evse_id_mapping_table(conn)
            with conn.cursor() as cur:
                cur.execute("SELECT backend_id, name FROM op_ocpi_backends ORDER BY name")
                available_backends = cur.fetchall()
                if tenant_id_db_value is not None:
                    cur.execute(
                        "SELECT tenant_id FROM op_tenants WHERE tenant_id=%s",
                        (tenant_id_db_value,),
                    )
                    if not cur.fetchone():
                        tenant_id_db_value = None
                params_full = (
                    new_source,
                    new_ws,
                    ocpp_subprotocol_db_value,
                    new_act,
                    mqtt_enabled,
                    measure_ping,
                    strict_availability,
                    charging_analytics,
                    extended_session_log,
                    pnc_enabled,
                    disconnect_alert_enabled,
                    disconnect_alert_email_db_value,
                    backend_user_db_value,
                    backend_password_db_value,
                    location_name_db_value,
                    location_link_db_value,
                    webui_remote_access_db_value,
                    load_management_remote_access_db_value,
                    comment_db_value,
                    tenant_id_db_value,
                    orig_source,
                )
                try:
                    cur.execute(
                        """
                        UPDATE op_redirects
                        SET source_url=%s, ws_url=%s, ocpp_subprotocol=%s, activity=%s, mqtt_enabled=%s, measure_ping=%s,
                            strict_availability=%s,
                            charging_analytics=%s,
                            extended_session_log=%s,
                            pnc_enabled=%s,
                            disconnect_alert_enabled=%s, disconnect_alert_email=%s,
                            backend_basic_user=%s, backend_basic_password=%s,
                            location_name=%s, location_link=%s,
                            webui_remote_access_url=%s, load_management_remote_access_url=%s,
                            comment=%s,
                            tenant_id=%s
                        WHERE source_url=%s
                        """,
                        params_full,
                    )
                except Exception:
                    try:
                        cur.execute(
                        """
                        UPDATE op_redirects
                        SET source_url=%s, ws_url=%s, ocpp_subprotocol=%s, activity=%s, mqtt_enabled=%s, measure_ping=%s,
                            strict_availability=%s,
                            charging_analytics=%s,
                            extended_session_log=%s,
                            pnc_enabled=%s,
                            disconnect_alert_enabled=%s, disconnect_alert_email=%s,
                            location_name=%s, location_link=%s,
                            webui_remote_access_url=%s, load_management_remote_access_url=%s,
                            comment=%s,
                            tenant_id=%s
                        WHERE source_url=%s
                        """,
                            (
                                new_source,
                                new_ws,
                                ocpp_subprotocol_db_value,
                                new_act,
                                mqtt_enabled,
                                measure_ping,
                                strict_availability,
                                charging_analytics,
                                extended_session_log,
                                pnc_enabled,
                                disconnect_alert_enabled,
                                disconnect_alert_email_db_value,
                                location_name_db_value,
                                location_link_db_value,
                                webui_remote_access_db_value,
                                load_management_remote_access_db_value,
                                comment_db_value,
                                tenant_id_db_value,
                                orig_source,
                            ),
                        )
                    except Exception:
                        cur.execute(
                            """
                            UPDATE op_redirects
                            SET source_url=%s, ws_url=%s, ocpp_subprotocol=%s, activity=%s, mqtt_enabled=%s, measure_ping=%s,
                                strict_availability=%s, charging_analytics=%s, extended_session_log=%s, pnc_enabled=%s,
                                disconnect_alert_enabled=%s, disconnect_alert_email=%s,
                                tenant_id=%s
                            WHERE source_url=%s
                            """,
                            (
                                new_source,
                                new_ws,
                                ocpp_subprotocol_db_value,
                                new_act,
                                mqtt_enabled,
                                measure_ping,
                                strict_availability,
                                charging_analytics,
                                extended_session_log,
                                pnc_enabled,
                                disconnect_alert_enabled,
                                disconnect_alert_email_db_value,
                                tenant_id_db_value,
                                orig_source,
                            ),
                        )
                cur.execute(
                    "DELETE FROM op_ocpi_wallbox_backends WHERE station_id=%s",
                    (station_id,)
                )
                for backend in available_backends:
                    backend_id_value = backend.get('backend_id')
                    if backend_id_value is None:
                        continue
                    enabled_field = request.form.get(f"ocpi_backend_{backend_id_value}_enabled")
                    if not enabled_field:
                        continue
                    try:
                        priority_value = int(
                            request.form.get(f"ocpi_backend_{backend_id_value}_priority") or 100
                        )
                    except (TypeError, ValueError):
                        priority_value = 100
                    cur.execute(
                        """
                        INSERT INTO op_ocpi_wallbox_backends (station_id, backend_id, enabled, priority)
                        VALUES (%s, %s, %s, %s)
                        ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), priority = VALUES(priority)
                        """,
                        (station_id, backend_id_value, 1, priority_value),
                    )

                if public_evse_id:
                    cur.execute(
                        "DELETE FROM op_evse_id_mapping WHERE public_evse_id=%s AND chargepoint_id<>%s",
                        (public_evse_id, station_id),
                    )
                    cur.execute(
                        """
                        INSERT INTO op_evse_id_mapping (chargepoint_id, public_evse_id)
                        VALUES (%s, %s)
                        ON DUPLICATE KEY UPDATE public_evse_id = VALUES(public_evse_id)
                        """,
                        (station_id, public_evse_id),
                    )
                else:
                    cur.execute(
                        "DELETE FROM op_evse_id_mapping WHERE chargepoint_id=%s",
                        (station_id,),
                    )
            conn.commit()
        finally:
            conn.close()
        try:
            requests.get(f"{PROXY_BASE_URL}/refreshStationList", params={})
        except Exception:
            app.logger.warning("Failed to refresh station list", exc_info=True)
        return redirect(url_for('dashboard'))

    else:
        # --- GET: Formular anzeigen ---
        source_url = request.args.get('source_url')
        connection_protocol: str | None = None
        connection_protocol_error: str | None = None
        conn = get_db_conn()
        try:
            ensure_op_redirects_columns(conn)
            ensure_evse_id_mapping_table(conn)
            ocpi_assignments: list[dict] = []
            available_backends: list[dict] = []
            tenant_rows: list[dict] = []
            with conn.cursor() as cur:
                def _normalize_ocpp_choice(value: object) -> str:
                    if not isinstance(value, str):
                        return ''

                    cleaned = value.strip()
                    lowered = cleaned.lower()
                    if not cleaned or lowered in {'auto', 'any'}:
                        return ''
                    if lowered.startswith('ocpp2.0'):
                        return 'ocpp2.0.1'
                    if lowered.startswith('ocpp1.6') or lowered == 'ocpp16':
                        return 'ocpp1.6'

                    return cleaned

                row = None
                query_variants = [
                    (
                        """
                        SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, measure_ping, strict_availability,
                               charging_analytics, extended_session_log, pnc_enabled, disconnect_alert_enabled, disconnect_alert_email,
                               backend_basic_user, backend_basic_password,
                               location_name, location_link,
                               webui_remote_access_url, load_management_remote_access_url,
                               comment,
                               tenant_id
                        FROM op_redirects WHERE source_url=%s
                        """,
                    ),
                    (
                        """
                        SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, measure_ping, strict_availability,
                               charging_analytics, extended_session_log, pnc_enabled, disconnect_alert_enabled, disconnect_alert_email,
                               backend_basic_user, backend_basic_password,
                               location_name, location_link,
                               NULL AS webui_remote_access_url, NULL AS load_management_remote_access_url,
                               comment,
                               tenant_id
                        FROM op_redirects WHERE source_url=%s
                        """,
                    ),
                    (
                        """
                        SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, measure_ping, strict_availability,
                               charging_analytics, extended_session_log, pnc_enabled, disconnect_alert_enabled, disconnect_alert_email,
                               NULL AS backend_basic_user, NULL AS backend_basic_password,
                               location_name, location_link,
                               webui_remote_access_url, load_management_remote_access_url,
                               comment,
                               tenant_id
                        FROM op_redirects WHERE source_url=%s
                        """,
                    ),
                    (
                        """
                        SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, measure_ping, strict_availability,
                               charging_analytics, extended_session_log, pnc_enabled, disconnect_alert_enabled, disconnect_alert_email,
                               NULL AS backend_basic_user, NULL AS backend_basic_password,
                               location_name, location_link,
                               NULL AS webui_remote_access_url, NULL AS load_management_remote_access_url,
                               comment,
                               tenant_id
                        FROM op_redirects WHERE source_url=%s
                        """,
                    ),
                    (
                        """
                        SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, measure_ping, strict_availability,
                               charging_analytics, extended_session_log, pnc_enabled, disconnect_alert_enabled, disconnect_alert_email,
                               backend_basic_user, backend_basic_password,
                               NULL AS location_name, NULL AS location_link,
                               NULL AS webui_remote_access_url, NULL AS load_management_remote_access_url,
                               comment,
                               tenant_id
                        FROM op_redirects WHERE source_url=%s
                        """,
                    ),
                    (
                        """
                        SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, measure_ping, 0 AS strict_availability,
                               charging_analytics, extended_session_log, pnc_enabled,
                               backend_basic_user, backend_basic_password,
                               location_name, location_link,
                               webui_remote_access_url, load_management_remote_access_url,
                               comment,
                               tenant_id
                        FROM op_redirects WHERE source_url=%s
                        """,
                    ),
                    (
                        """
                        SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, measure_ping, strict_availability,
                               0 AS charging_analytics, 0 AS extended_session_log, pnc_enabled,
                               backend_basic_user, backend_basic_password,
                               location_name, location_link,
                               webui_remote_access_url, load_management_remote_access_url,
                               comment,
                               tenant_id
                        FROM op_redirects WHERE source_url=%s
                        """,
                    ),
                ]
                for query, in query_variants:
                    try:
                        cur.execute(query, (source_url,))
                        row = cur.fetchone()
                    except Exception:
                        continue
                    if row is not None:
                        break
                if row is None:
                    row = {}
                row['source_url'] = row.get('source_url') or (source_url or '')
                row['ws_url'] = row.get('ws_url') or ''
                row['activity'] = row.get('activity') or 'forward'
                row['ocpp_subprotocol'] = _normalize_ocpp_choice(row.get('ocpp_subprotocol'))
                cur.execute("SELECT ws_url, short_name FROM op_targets")
                target_rows = cur.fetchall()
                station_id = normalize_station_id(source_url).rsplit('/', 1)[-1] if source_url else ''
                public_evse_id_value = ''
                if station_id:
                    cur.execute(
                        "SELECT public_evse_id FROM op_evse_id_mapping WHERE chargepoint_id=%s",
                        (station_id,),
                    )
                    mapping_row = cur.fetchone()
                    if mapping_row:
                        public_evse_id_value = mapping_row.get('public_evse_id') or ''
                cur.execute(
                    """
                    SELECT wb.backend_id, wb.enabled, wb.priority, b.name
                    FROM op_ocpi_wallbox_backends AS wb
                    JOIN op_ocpi_backends AS b ON wb.backend_id = b.backend_id
                    WHERE wb.station_id=%s
                    ORDER BY wb.priority, b.name
                    """,
                    (station_id,)
                )
                ocpi_assignments = cur.fetchall()
                cur.execute("SELECT backend_id, name FROM op_ocpi_backends ORDER BY name")
                available_backends = cur.fetchall()
                try:
                    cur.execute(
                        "SELECT tenant_id, name FROM op_tenants ORDER BY name, tenant_id"
                    )
                    tenant_rows = cur.fetchall()
                except Exception:
                    tenant_rows = []
                connected_entries: list[Mapping[str, Any]] = []
                try:
                    response = requests.get(CONNECTED_ENDPOINT, timeout=5)
                    payload = response.json()
                    if isinstance(payload, dict):
                        raw_connected = payload.get('connected', [])
                        if isinstance(raw_connected, list):
                            connected_entries = [
                                entry for entry in raw_connected if isinstance(entry, Mapping)
                            ]
                except Exception as exc:
                    connection_protocol_error = str(exc)

                station_variants: set[str] = set()

                def _add_identifier_variants(identifier: str) -> None:
                    text = str(identifier or '').strip()
                    if not text:
                        return
                    normalized_val = normalize_station_id(text)
                    station_variants.add(text)
                    if normalized_val:
                        station_variants.add(normalized_val)
                        station_variants.add(normalized_val.lstrip('/'))
                        station_variants.add('/' + normalized_val.lstrip('/'))
                    trimmed = text.lstrip('/')
                    if trimmed:
                        station_variants.add(trimmed)
                        station_variants.add('/' + trimmed)

                _add_identifier_variants(source_url or '')

                for entry in connected_entries:
                    entry_station = (
                        entry.get('station_id')
                        or entry.get('stationId')
                        or entry.get('station')
                    )
                    if not entry_station:
                        continue
                    normalized_entry = normalize_station_id(entry_station)
                    candidates = {
                        entry_station,
                        entry_station.lstrip('/'),
                        normalized_entry,
                        normalized_entry.lstrip('/') if normalized_entry else '',
                    }
                    if any(candidate in station_variants for candidate in candidates if candidate):
                        connection_protocol = entry.get('ocpp_subprotocol')
                        break
        finally:
            conn.close()

        row['ocpi_assignments'] = ocpi_assignments
        row['available_ocpi_backends'] = available_backends
        row['ocpi_assignment_map'] = {
            assignment['backend_id']: assignment for assignment in ocpi_assignments
        }
        row['measure_ping'] = bool(row.get('measure_ping'))
        row['strict_availability'] = bool(row.get('strict_availability'))
        row['charging_analytics'] = bool(row.get('charging_analytics'))
        row['extended_session_log'] = bool(row.get('extended_session_log'))
        row['pnc_enabled'] = bool(row.get('pnc_enabled'))
        row['disconnect_alert_enabled'] = bool(row.get('disconnect_alert_enabled'))
        row['disconnect_alert_email'] = row.get('disconnect_alert_email') or ''
        row['backend_basic_user'] = row.get('backend_basic_user') or ''
        row['backend_basic_password'] = row.get('backend_basic_password') or ''
        row['location_name'] = row.get('location_name') or ''
        row['location_link'] = row.get('location_link') or ''
        row['webui_remote_access_url'] = row.get('webui_remote_access_url') or ''
        row['load_management_remote_access_url'] = row.get('load_management_remote_access_url') or ''
        row['comment'] = row.get('comment') or ''
        row['public_evse_id'] = public_evse_id_value
        tenant_id_value = row.get('tenant_id')
        try:
            row['tenant_id'] = int(tenant_id_value)
        except (TypeError, ValueError):
            row['tenant_id'] = None

        targets = [
            {
                'short_name': t['short_name'],
                'base': t['ws_url']
            }
            for t in target_rows
        ]

        tenant_options = [
            {
                'tenant_id': tenant['tenant_id'],
                'name': tenant['name'],
            }
            for tenant in tenant_rows
            if tenant.get('tenant_id') is not None
        ]

        # 'base': '/'.join(t['ws_url'].split('/')[:4])

    # Render edit template
    return render_template(
        "op_edit.html",
        row=row,
        targets=targets,
        tenants=tenant_options,
        connection_protocol=connection_protocol,
        connection_protocol_error=connection_protocol_error,
    )


@app.route('/op_disconnect_alert_test', methods=['POST'])
@app.route('/v2/disconnect_alert_test', methods=['POST'])
def send_disconnect_alert_test():
    payload = request.get_json(silent=True) or request.form or {}
    email_raw = str(payload.get('disconnect_alert_email') or '').strip()
    if not email_raw:
        return jsonify({'error': _('Please enter at least one email address.')}), 400

    recipients = [
        entry.strip()
        for entry in re.split(r'[;,]', email_raw)
        if entry and entry.strip()
    ]
    email_pattern = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
    invalid = next((entry for entry in recipients if not email_pattern.match(entry)), None)
    if invalid or not recipients:
        return jsonify({'error': _('Please enter valid email addresses separated by semicolons.')}), 400

    source_url_raw = str(
        payload.get('source_url') or payload.get('orig_source_url') or ''
    ).strip()
    station_id = normalize_station_id(source_url_raw).rsplit('/', 1)[-1] if source_url_raw else ''

    disconnect_time = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) - datetime.timedelta(minutes=30)
    subject = f"Wallbox {station_id} offline" if station_id else _("Wallbox offline")
    body = (
        f"Die Wallbox {station_id or '-'} ist seit mindestens 30 Minuten nicht mehr mit dem Broker verbunden.\n"
        f"Letzter Verbindungsversuch: {disconnect_time.isoformat()}"
    )

    try:
        send_dashboard_email(subject, body, recipients)
    except RuntimeError as exc:
        app.logger.warning("Failed to send test disconnect alert email: %s", exc)
        return jsonify({'error': str(exc)}), 500
    except Exception as exc:  # pragma: no cover - best effort diagnostics
        app.logger.exception("Unexpected error while sending test disconnect alert email")
        return jsonify({'error': f"{_('Unable to send test email.')}: {exc}"}), 500

    return jsonify({'status': 'ok', 'message': _('Test email sent.')})


# Toggle forwarding/blocking via proxy API
@app.route('/op_toggle', methods=['POST'])
def toggle():
    source_url = request.form['source_url']
    ws_url = request.form['ws_url']
    activity = request.form['activity']
    # Call proxy HTTP endpoint to update redirect
    resp = requests.get(f"{PROXY_BASE_URL}/setRedirect", params={
        'source_url': source_url,
        'activity': activity,
        'ws_url': ws_url
    })
    return redirect(url_for('dashboard'))

# Toggle forwarding/blocking via proxy API
@app.route('/op_refreshStationList', methods=['POST'])
def refreshServerDB():
    app.logger.info("%s/refreshStationList", PROXY_BASE_URL)
    resp = requests.get(f"{PROXY_BASE_URL}/refreshStationList", params={})
    return redirect(url_for('dashboard'))

# Disconnect a charge point via proxy API
@app.route('/op_disconnect', methods=['POST'])
def disconnect():
    source_url = request.form['source_url']
    # The proxy expects a canonical station_id
    station_id = normalize_station_id(source_url)
    app.logger.info("%s/disconnectChargePoint/%s", PROXY_BASE_URL, station_id)
    resp = requests.get(f"{PROXY_BASE_URL}/disconnectChargePoint/{station_id}")
    return redirect(url_for('dashboard'))

# Details: show last 200 messages for a given wallbox & month, with JSON formatting/highlighting
def format_json(message):
    def truncate_public_keys(obj):
        if isinstance(obj, dict):
            for k, v in obj.items():
                if k == "publicKey" and isinstance(v, str) and len(v) > 50:
                    obj[k] = v[:50] + "...."
                else:
                    truncate_public_keys(v)
        elif isinstance(obj, list):
            for item in obj:
                truncate_public_keys(item)

    try:
        obj = json.loads(message)
        truncate_public_keys(obj)
        return json.dumps(obj, indent=2, ensure_ascii=False)
    except Exception:
        # fallback to raw
        return message

# Details: show last 200 messages for a given wallbox & month
@app.route('/op_details')
def details():
    source_url = request.args.get('source_url')
    limit_options = [100, 250, 500, 1000, 4000]

    try:
        limit = int(request.args.get('limit', limit_options[1]))
    except (TypeError, ValueError):
        limit = limit_options[1]

    if limit not in limit_options:
        limit = limit_options[1]
    month = request.args.get('month')
    direction_filter = request.args.get('direction_filter', 'all').lower()
    # Default to current year-month if none provided
    if not month:
        now = datetime.datetime.now()
        # two-digit year and month, e.g., '2506'
        month = now.strftime('%y%m')

    conn = get_db_conn()
    # Get available month tables
    with conn.cursor() as cur:
        cur.execute("SHOW TABLES LIKE 'op_messages_%';")
        tables = [list(row.values())[0] for row in cur.fetchall()]
    # Extract YYYYMM suffixes
    months = [t.replace('op_messages_', '') for t in tables]

    # Query last 200 messages from selected month
    table_name = f"op_messages_{month}"

    filter_map = {
        'extern': '%extapi%',
        'client': 'client_to_server%',
        'server': 'server_to_client%',
    }

    sql = f"SELECT timestamp, direction, message, connection_id FROM {table_name} WHERE source_url=%s"
    params = [source_url]

    if direction_filter in filter_map:
        sql += " AND LOWER(direction) LIKE %s"
        params.append(filter_map[direction_filter])

    sql += " ORDER BY timestamp DESC LIMIT " + str(limit)

    with conn.cursor() as cur:
        cur.execute(sql, tuple(params))
        msgs = cur.fetchall()
    conn.close()

    # format each message
    direction_labels = [
        ('server_to_client', 'CPMS => Broker'),
        ('client_to_server', 'CP => Broker'),
        ('server_to_extapi', 'Broker => External API'),
        ('extapi_to_server', 'External API => Broker'),
    ]

    for m in msgs:
        direction_value = (m.get('direction') or '').lower()
        mapped_direction = next(
            (
                label
                for prefix, label in direction_labels
                if direction_value.startswith(prefix)
            ),
            m.get('direction'),
        )

        m['display_direction'] = mapped_direction
        m['formatted_message'] = format_json(m['message'])

    if request.args.get('export') == 'csv':
        output = StringIO()
        writer = csv.writer(output)
        writer.writerow(["#", "Direction", "SessionID", "Timestamp", "Message"])
        for index, message in enumerate(msgs, start=1):
            writer.writerow(
                [
                    index,
                    message.get("display_direction"),
                    message.get("connection_id"),
                    message.get("timestamp"),
                    message.get("formatted_message"),
                ]
            )

        safe_source = re.sub(r"[^A-Za-z0-9_-]+", "_", source_url or "unknown")
        response = Response(
            output.getvalue(),
            mimetype="text/csv",
            headers={
                "Content-Disposition": f'attachment; filename="messages_{safe_source}_{month}.csv"'
            },
        )
        output.close()
        return response

    return render_template(
        "op_details.html",
        source_url=source_url,
        month=month,
        months=months,
        msgs=msgs,
        direction_filter=direction_filter,
        limit=limit,
        limit_options=limit_options,
        aside="op_details",
    )


@app.route('/api/op_ocpp_log', methods=['GET'])
def api_ocpp_log():
    """Return recent OCPP log messages for a charge point.

    Access requires the dashboard token via the ``token`` query
    parameter. The response is limited to a maximum of 1000 entries to
    protect the database from heavy reads.
    """

    provided_token = request.args.get('token', '').strip()
    expected_token = get_token()
    if not provided_token or not hmac.compare_digest(provided_token, expected_token):
        return _unauthorized_response()

    raw_chargepoint_id = request.args.get('chargepoint_id', '').strip()
    normalized_chargepoint_id = normalize_station_id(raw_chargepoint_id)
    if not normalized_chargepoint_id:
        return jsonify({'error': 'chargepoint_id missing'}), 400

    limit_param = request.args.get('limit', '250')
    try:
        limit = int(limit_param)
    except (TypeError, ValueError):
        limit = 250
    if limit <= 0:
        limit = 250
    limit = min(limit, 1000)

    month = request.args.get('month')
    if not month:
        now = datetime.datetime.now()
        month = now.strftime('%y%m')
    if not re.fullmatch(r'\d{4}', month):
        return jsonify({'error': 'invalid month format, expected yymm'}), 400

    direction_param = request.args.get('direction', 'all')
    if direction_param == 'all':
        direction_param = request.args.get('direction_filter', 'all')
    direction_filter = (direction_param or 'all').lower()

    filter_map = {
        'extern': '%extapi%',
        'client': 'client_to_server%',
        'server': 'server_to_client%',
    }

    source_candidates = []
    for candidate in {
        raw_chargepoint_id,
        normalized_chargepoint_id,
        f"/{normalized_chargepoint_id}",
    }:
        text = str(candidate or '').strip()
        if text and text not in source_candidates:
            source_candidates.append(text)

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_messages_%';")
            available_months = {
                list(row.values())[0].replace('op_messages_', '') for row in cur.fetchall()
            }

        if month not in available_months:
            return jsonify({'error': 'month not found'}), 404

        params: list[Any] = []
        placeholders = ','.join(['%s'] * len(source_candidates))
        sql = (
            f"SELECT timestamp, direction, message, connection_id, source_url "
            f"FROM op_messages_{month} WHERE source_url IN ({placeholders})"
        )
        params.extend(source_candidates)

        if direction_filter in filter_map:
            sql += " AND LOWER(direction) LIKE %s"
            params.append(filter_map[direction_filter])

        sql += " ORDER BY timestamp DESC LIMIT %s"
        params.append(limit)

        with conn.cursor() as cur:
            cur.execute(sql, tuple(params))
            rows = cur.fetchall()
    finally:
        conn.close()

    def _serialize_timestamp(value: Any) -> str:
        if isinstance(value, datetime.datetime):
            return value.isoformat()
        return str(value)

    entries = [
        {
            'timestamp': _serialize_timestamp(row.get('timestamp')),
            'direction': row.get('direction'),
            'connection_id': row.get('connection_id'),
            'source_url': row.get('source_url'),
            'message': row.get('message'),
        }
        for row in rows
    ]

    return jsonify(
        {
            'chargepoint_id': normalized_chargepoint_id,
            'applied_limit': limit,
            'applied_direction': direction_filter,
            'month': month,
            'count': len(entries),
            'entries': entries,
        }
    )

def _resolve_diagnostic_file(filename: str, expected_suffix: str) -> Path:
    """Return a safe path inside the diagnostic reports directory."""

    if not filename:
        abort(400)

    sanitized = os.path.basename(filename)
    if sanitized != filename or not sanitized.endswith(expected_suffix):
        abort(400)

    candidate = DIAGNOSTIC_REPORTS_DIR / sanitized
    try:
        candidate.relative_to(DIAGNOSTIC_REPORTS_DIR)
    except ValueError:
        abort(400)

    if not candidate.is_file():
        abort(404)

    return candidate


@app.route('/op_ai_diagnostics')
def ai_diagnostics():
    diagnostics: list[dict[str, Any]] = []

    if DIAGNOSTIC_REPORTS_DIR.exists():
        archives: list[tuple[float, Path]] = []
        for archive_path in DIAGNOSTIC_REPORTS_DIR.glob('*.tar.gz'):
            try:
                mtime = archive_path.stat().st_mtime
            except OSError as exc:
                app.logger.warning(
                    "Unable to read diagnostic archive metadata for %s: %s",
                    archive_path,
                    exc,
                )
                continue
            archives.append((mtime, archive_path))

        for _, archive_path in sorted(archives, key=lambda item: item[0], reverse=True):
            archive_name = archive_path.name
            base_name = archive_name[:-7]  # remove .tar.gz
            html_name = f"{base_name}.html"
            report_path = DIAGNOSTIC_REPORTS_DIR / html_name
            summary_name = f"{base_name}_summary.html"
            summary_path = DIAGNOSTIC_REPORTS_DIR / summary_name
            chargepoint_id = archive_name.split('_', 1)[0]

            diagnostics.append(
                {
                    "chargepoint_id": chargepoint_id,
                    "archive_name": archive_name,
                    "report_name": html_name if report_path.is_file() else None,
                    "summary_name": summary_name if summary_path.is_file() else None,
                }
            )

    return render_template(
        "op_ai_diagnostics.html",
        diagnostics=diagnostics,
        aside='repo_ai_diagnostics',
    )


@app.route('/op_ai_diagnostics/download/<path:archive_name>')
def download_ai_diagnostic(archive_name: str):
    archive_path = _resolve_diagnostic_file(archive_name, '.tar.gz')
    return send_file(
        archive_path,
        as_attachment=True,
        download_name=archive_path.name,
        mimetype='application/gzip',
    )


@app.route('/op_ai_diagnostics/report/<path:report_name>')
def view_ai_diagnostic_report(report_name: str):
    report_path = _resolve_diagnostic_file(report_name, '.html')
    return send_file(
        report_path,
        as_attachment=False,
        download_name=report_path.name,
        mimetype='text/html',
    )


@app.route('/op_ai_diagnostics/delete', methods=['POST'])
def delete_ai_diagnostic():
    archive_name = request.form.get('archive_name', '')
    try:
        archive_path = _resolve_diagnostic_file(archive_name, '.tar.gz')
    except HTTPException:
        app.logger.warning(
            "Requested diagnostic archive %s could not be resolved for deletion",
            archive_name,
        )
        return redirect(url_for('ai_diagnostics'))
    base_name = archive_path.name[:-7]
    html_path = DIAGNOSTIC_REPORTS_DIR / f"{base_name}.html"

    try:
        archive_path.unlink()
    except FileNotFoundError:
        pass
    except OSError as exc:
        app.logger.error("Unable to delete diagnostic archive %s: %s", archive_path, exc)

    if html_path.exists():
        try:
            html_path.unlink()
        except OSError as exc:
            app.logger.error("Unable to delete diagnostic report %s: %s", html_path, exc)

    return redirect(url_for('ai_diagnostics'))


@app.route('/op_ftp_files')
def ftp_files():
    # Connect to FTP and list directory
    try:
        ftp = ftplib.FTP(FTP_HOST)
        ftp.login(FTP_USER, FTP_PASSWORD)
        ftp.cwd(FTP_DIR)
        files = ftp.nlst()
        ftp.quit()
    except Exception as e:
        files = []
        app.logger.error("FTP error: %s", e)
    return render_template(
        "op_ftp_files.html",
        files=files,
    )


@app.route('/op_mqtt_status')
def mqtt_status():
    cfg = load_runtime_config()
    mqtt_broker = (cfg.get('mqtt_broker') or '').strip()
    mqtt_port = (cfg.get('mqtt_port') or '').strip()
    mqtt_user = (cfg.get('mqtt_user') or '').strip()
    mqtt_password = (cfg.get('mqtt_password') or '').strip()
    mqtt_topic_prefix = (cfg.get('mqtt_topic_prefix') or '').strip()

    configured = bool(mqtt_broker and mqtt_port)
    reachability = None
    reachability_error = None
    if configured:
        reachability, reachability_error = _check_mqtt_reachability(
            mqtt_broker,
            mqtt_port,
        )
    else:
        reachability_error = "MQTT-Broker ist nicht vollständig konfiguriert."

    wallboxes: list[dict[str, str]] = []
    wallbox_error: str | None = None
    conn = None
    try:
        conn = get_db_conn()
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT source_url, ws_url, activity
                FROM op_redirects
                WHERE mqtt_enabled = 1
                ORDER BY source_url
                """
            )
            rows = cur.fetchall()

        for row in rows:
            source_url = row.get('source_url') or ''
            normalized = normalize_station_id(source_url)
            station_id = normalized.rsplit('/', 1)[-1] if normalized else ''
            wallboxes.append(
                {
                    'station_id': station_id,
                    'source_url': source_url,
                    'ws_url': row.get('ws_url') or '',
                    'activity': row.get('activity') or '',
                }
            )
    except Exception as exc:
        wallbox_error = str(exc)
        app.logger.warning("Failed to load MQTT-enabled wallboxes: %s", exc, exc_info=True)
    finally:
        if conn is not None:
            conn.close()

    include_placeholders = not configured
    broker_value = mqtt_broker or '<mqtt-host>'
    port_value = mqtt_port or '<port>'

    if mqtt_topic_prefix:
        topic_value = mqtt_topic_prefix.rstrip('/') + '/#'
    elif include_placeholders:
        topic_value = '<topic-prefix>/#'
    else:
        topic_value = '#'

    command_parts = [
        'mosquitto_sub',
        '-h',
        broker_value,
        '-p',
        port_value,
    ]

    if mqtt_user or include_placeholders:
        command_parts.extend(['-u', mqtt_user or '<user>'])

    if mqtt_password or include_placeholders:
        command_parts.extend(['-P', mqtt_password or '<password>'])

    command_parts.extend(['-t', topic_value, '-v'])

    mosquitto_example = ' '.join(shlex.quote(part) for part in command_parts)

    return render_template(
        'op_mqtt_status.html',
        aside='repo_mqtt',
        configured=configured,
        reachability=reachability,
        reachability_error=reachability_error,
        mqtt_config={
            'broker': mqtt_broker,
            'port': mqtt_port,
            'user': mqtt_user,
            'password': mqtt_password,
            'topic_prefix': mqtt_topic_prefix,
        },
        mosquitto_example=mosquitto_example,
        wallboxes=wallboxes,
        wallbox_error=wallbox_error,
    )


@app.route('/op_ftp_download')
def ftp_download():
    filename = request.args.get('filename')
    def generate():
        ftp = ftplib.FTP(FTP_HOST)
        ftp.login(FTP_USER, FTP_PASSWORD)
        ftp.cwd(FTP_DIR)
        bio = io.BytesIO()
        ftp.retrbinary(f'RETR {filename}', bio.write)
        ftp.quit()
        bio.seek(0)
        chunk = bio.read(8192)
        while chunk:
            yield chunk
            chunk = bio.read(8192)
    return Response(
        stream_with_context(generate()),
        headers={
            'Content-Disposition': f'attachment; filename="{filename}"',
            'Content-Type': 'application/octet-stream'
        },
    )


@app.route('/op_virtual_charger', methods=['GET', 'POST'])
def virtual_charger():
    stations = []
    for idx in range(1, 4):
        sid = f"virtual{idx}"
        prefix = f"vcp_{sid}"
        enabled = get_config_value(f"{prefix}_enabled") == '1'
        name = get_config_value(f"{prefix}_name") or sid
        idtags = [get_config_value(f"{prefix}_idtag{i}") or '' for i in range(1, 4)]
        meter = get_config_value(f"{prefix}_meter") or '0'
        backend_url = get_config_value(f"{prefix}_backend_url") or ''
        cp_id = get_config_value(f"{prefix}_cp_id") or sid

        if request.method == 'POST':
            form_prefix = f"{sid}_"
            enabled = request.form.get(f"{form_prefix}enabled") == 'on'
            name = request.form.get(f"{form_prefix}name") or sid
            idtags = [request.form.get(f"{form_prefix}idtag{i}", '') for i in range(1, 4)]
            backend_url = request.form.get(f"{form_prefix}backend_url", '')
            cp_id = request.form.get(f"{form_prefix}cp_id", sid)

            set_config_value(f"{prefix}_enabled", '1' if enabled else '0')
            set_config_value(f"{prefix}_name", name)
            set_config_value(f"{prefix}_backend_url", backend_url)
            set_config_value(f"{prefix}_cp_id", cp_id)
            for i, tag in enumerate(idtags, start=1):
                set_config_value(f"{prefix}_idtag{i}", tag)

        stations.append({
            'id': sid,
            'enabled': enabled,
            'name': name,
            'idtags': idtags,
            'meter': meter,
            'backend_url': backend_url,
            'cp_id': cp_id,
        })

    if request.method == 'POST':
        return redirect(url_for('virtual_charger'))

    sim_running = False
    try:
        resp = requests.get(f"{VIRTUAL_CHARGER_API}/status", timeout=1)
        sim_running = resp.json().get('running', False)
    except Exception:
        sim_running = False

    return render_template(
        'op_virtual_charger.html',
        stations=stations,
        aside='repo_virtual',
        sim_running=sim_running,
    )


@app.route('/api/virtualcharger/start', methods=['POST'])
def api_virtualcharger_start():
    data = request.get_json() or {}
    try:
        resp = requests.post(f"{VIRTUAL_CHARGER_API}/start", json=data, timeout=2)
        return jsonify(resp.json()), resp.status_code
    except Exception:
        return jsonify({'error': 'simulator not reachable'}), 500


@app.route('/api/virtualcharger/status')
def api_virtualcharger_status():
    try:
        resp = requests.get(f"{VIRTUAL_CHARGER_API}/status", timeout=2)
        return jsonify(resp.json())
    except Exception:
        return jsonify({'running': False})


@app.route('/op_energymqtt')
def energymqtt_list():
    conn = get_db_conn()
    with conn.cursor(pymysql.cursors.DictCursor) as cur:
        cur.execute(
            "SELECT topic, message, frequenz, ts FROM op_energymqtt_check ORDER BY topic"
        )
        rows = cur.fetchall()
    conn.close()

    def calc_status(freq):
        if freq == 0:
            return "", "black"
        if freq <= 59:
            return "ciritial", "red"
        if 280 <= freq <= 320:
            return "ok", "green"
        if freq >= 321:
            return "too slow", "orange"
        return "too fast", "orange"

    rows_ok = []
    rows_nok = []
    for r in rows:
        status, color = calc_status(r["frequenz"])
        r["status"] = status
        r["color"] = color
        if status == "ok":
            rows_ok.append(r)
        else:
            rows_nok.append(r)

    return render_template("op_energymqtt.html", rows_ok=rows_ok, rows_nok=rows_nok)


@app.route('/op_energymqtt_ignore', methods=['GET', 'POST'])
def energymqtt_ignore():
    conn = get_db_conn()
    cur = conn.cursor(pymysql.cursors.DictCursor)

    if request.method == 'POST':
        action = request.form.get('action')
        topic = request.form.get('topic', '').strip()
        orig = request.form.get('orig_topic', '').strip()

        if action == 'add' and topic:
            cur.execute(
                "INSERT IGNORE INTO op_energymqtt_ignore (topic) VALUES (%s)",
                (topic,),
            )
            cur.execute(
                "DELETE FROM op_energymqtt_check WHERE topic LIKE %s",
                (topic.replace('*', '%').replace('?', '_'),),
            )
            conn.commit()
        elif action == 'edit' and orig and topic:
            cur.execute(
                "UPDATE op_energymqtt_ignore SET topic=%s WHERE topic=%s",
                (topic, orig),
            )
            cur.execute(
                "DELETE FROM op_energymqtt_check WHERE topic LIKE %s",
                (topic.replace('*', '%').replace('?', '_'),),
            )
            conn.commit()
        elif action == 'delete' and orig:
            cur.execute(
                "DELETE FROM op_energymqtt_ignore WHERE topic=%s",
                (orig,),
            )
            conn.commit()
        conn.close()
        return redirect(url_for('energymqtt_ignore'))

    cur.execute("SELECT topic FROM op_energymqtt_ignore ORDER BY topic")
    rows = cur.fetchall()
    conn.close()
    return render_template('op_energymqtt_ignore.html', rows=rows)


@app.route('/op_energymqtt_disconnected')
def energymqtt_disconnected():
    """List devices that haven't published for more than 6 hours."""
    conn = get_db_conn()
    with conn.cursor(pymysql.cursors.DictCursor) as cur:
        cur.execute(
            "SELECT topic, message, frequenz, ts FROM op_energymqtt_disconnected ORDER BY ts DESC"
        )
        rows = cur.fetchall()
    conn.close()
    return render_template('op_energymqtt_disconnected.html', rows=rows)


@app.route('/op_bhi_dashboard')
def bhi_dashboard():
    conn = get_db_conn()
    with conn.cursor() as cur:
        cur.execute(
            """
            CREATE TABLE IF NOT EXISTS op_bhi_sessions (
                id INT AUTO_INCREMENT PRIMARY KEY,
                station_id VARCHAR(50) NOT NULL,
                message_count INT NOT NULL,
                start_ts TIMESTAMP NULL,
                end_ts TIMESTAMP NULL
            )
            """
        )
        cur.execute(
            "SELECT station_id, message_count, start_ts, end_ts FROM op_bhi_sessions ORDER BY end_ts DESC LIMIT 20"
        )
        sessions = cur.fetchall()
    conn.close()
    return render_template('op_bhi_dashboard.html', aside='vehicleMenu', sessions=sessions)


@app.route('/op_bhi_methodology')
def bhi_methodology():
    return render_template('op_bhi_methodology.html', aside='vehicleMenu')


@app.route('/op_bhi_degradation_tool')
def bhi_degradation_tool():
    conn = get_db_conn()
    with conn.cursor() as cur:
        cur.execute(
            "SELECT SUM(message_count) AS total_msgs, COUNT(*) AS total_sessions FROM op_bhi_sessions"
        )
        stats = cur.fetchone()
    conn.close()
    return render_template('op_bhi_degradation_tool.html', aside='vehicleMenu', stats=stats)


@app.route('/op_bhi_manual_odometer_entry', methods=['GET', 'POST'])
def bhi_manual_odometer_entry():
    conn = get_db_conn()
    with conn.cursor() as cur:
        cur.execute(
            """
            CREATE TABLE IF NOT EXISTS op_bhi_odometer (
                id INT AUTO_INCREMENT PRIMARY KEY,
                station_id VARCHAR(50) NOT NULL,
                value INT NOT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
            """
        )
        if request.method == 'POST':
            cur.execute(
                "INSERT INTO op_bhi_odometer (station_id, value) VALUES (%s, %s)",
                (request.form['station_id'], request.form['odometer'])
            )
            conn.commit()
            return redirect(url_for('bhi_manual_odometer_entry'))
        cur.execute(
            "SELECT station_id, value, created_at FROM op_bhi_odometer ORDER BY created_at DESC LIMIT 20"
        )
        entries = cur.fetchall()
    conn.close()
    return render_template('op_bhi_manual_odometer_entry.html', aside='vehicleMenu', entries=entries)


@app.route('/op_bhi_manual_soh_entry', methods=['GET', 'POST'])
def bhi_manual_soh_entry():
    conn = get_db_conn()
    with conn.cursor() as cur:
        cur.execute(
            """
            CREATE TABLE IF NOT EXISTS op_bhi_soc (
                id INT AUTO_INCREMENT PRIMARY KEY,
                station_id VARCHAR(50) NOT NULL,
                value INT NOT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
            """
        )
        if request.method == 'POST':
            cur.execute(
                "INSERT INTO op_bhi_soc (station_id, value) VALUES (%s, %s)",
                (request.form['station_id'], request.form['soc'])
            )
            conn.commit()
            return redirect(url_for('bhi_manual_soh_entry'))
        cur.execute(
            "SELECT station_id, value, created_at FROM op_bhi_soc ORDER BY created_at DESC LIMIT 20"
        )
        entries = cur.fetchall()
    conn.close()
    return render_template('op_bhi_manual_soh_entry.html', aside='vehicleMenu', entries=entries)

@app.route('/op_reboot_cp', methods=['POST'])
def reboot_cp():
    """
    Nimmt eine JSON-Nutzlast { "station_id": "<ID>" } entgegen,
    ruft den internen Proxy-Endpunkt zum Reboot auf und
    liefert JSON-Status zurück.
    """
    data = request.get_json() or {}
    station_id = data.get('station_id')
    if not station_id:
        return jsonify({'error': 'station_id fehlt'}), 400

    url = f"{PROXY_BASE_URL}/rebootChargePoint/{normalize_station_id(station_id)}"
    try:
        resp = requests.get(url, timeout=5)
        resp.raise_for_status()
    except Exception as e:
        return jsonify({'error': str(e)}), 500

    return jsonify({'status': 'ok', 'station_id': station_id}), 200


@app.route('/api/op_cp_restart', methods=['POST'])
def api_restart_cp():
    """Restart a charge point identified by ``source_url`` via the proxy API."""

    auth_header = request.headers.get('Authorization', '')
    token = get_token()
    if not auth_header.startswith('Bearer ') or auth_header.split(' ', 1)[1] != token:
        return jsonify({'error': 'unauthorized'}), 401

    data = request.get_json() or {}
    source_url = data.get('source_url')
    if not source_url:
        return jsonify({'error': 'source_url missing'}), 400

    station_id = normalize_station_id(source_url)
    if not station_id:
        return jsonify({'error': 'invalid source_url'}), 400

    conn = get_db_conn()
    try:
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            cur.execute("SELECT 1 FROM op_redirects WHERE source_url=%s", (source_url,))
            if not cur.fetchone():
                return jsonify({'error': 'not found'}), 404
    finally:
        conn.close()

    url = f"{PROXY_BASE_URL}/rebootChargePoint/{station_id}"
    try:
        resp = requests.get(url, timeout=5)
        resp.raise_for_status()
    except Exception as exc:
        return jsonify({'error': str(exc)}), 502

    return jsonify({'status': 'ok', 'source_url': source_url, 'station_id': station_id}), 200

@app.route('/op_ftp_delete', methods=['POST'])
def ftp_delete():
    filename = request.form.get('filename')
    try:
        ftp = ftplib.FTP(FTP_HOST)
        ftp.login(FTP_USER, FTP_PASSWORD)
        ftp.cwd(FTP_DIR)
        ftp.delete(filename)
        ftp.quit()
    except Exception as e:
        app.logger.error("FTP delete error: %s", e)
    return redirect(url_for('ftp_files'))

@app.route('/op_firmware', methods=['GET'])
def firmware():
    base_dir = ensure_firmware_directory()
    firmware_files: list[dict[str, str | float | int]] = []
    try:
        with os.scandir(base_dir) as entries:
            for entry in entries:
                if not entry.is_file():
                    continue
                stats = entry.stat()
                if stats.st_size < MIN_FIRMWARE_SIZE_BYTES:
                    continue
                modified_dt = datetime.datetime.fromtimestamp(stats.st_mtime)
                firmware_files.append(
                    {
                        "name": entry.name,
                        "size": stats.st_size,
                        "size_human": _format_bytes(stats.st_size),
                        "modified": modified_dt.strftime("%Y-%m-%d %H:%M:%S"),
                        "modified_iso": modified_dt.isoformat(),
                        "_sort": stats.st_mtime,
                    }
                )
    except FileNotFoundError:
        firmware_files = []

    firmware_files.sort(key=lambda item: item["_sort"], reverse=True)
    for item in firmware_files:
        item.pop("_sort", None)

    redirects: list[dict] = []
    station_ids: list[str] = []
    firmware_versions: dict[str, str | None] = {}
    conn = get_db_conn()
    try:
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            cur.execute("SELECT source_url FROM op_redirects ORDER BY source_url")
            redirects = cur.fetchall()

            station_ids = [
                normalize_station_id(r["source_url"]).rsplit('/', 1)[-1]
                for r in redirects
            ]

            unique_station_ids = list(dict.fromkeys(station_ids))
            if unique_station_ids:
                placeholders = ",".join(["%s"] * len(unique_station_ids))
                cur.execute(
                    f"""
                    SELECT station_id, json_payload
                    FROM op_broker_bootnotifications
                    WHERE station_id IN ({placeholders})
                    ORDER BY ts DESC
                    """,
                    unique_station_ids,
                )
                for row in cur.fetchall():
                    station_id = row.get("station_id")
                    if not station_id or station_id in firmware_versions:
                        continue
                    payload = row.get("json_payload")
                    version = None
                    if isinstance(payload, str):
                        try:
                            parsed = json.loads(payload)
                        except json.JSONDecodeError:
                            parsed = None
                        if isinstance(parsed, list) and len(parsed) >= 4:
                            body = parsed[3]
                            if isinstance(body, dict):
                                version = body.get("firmwareVersion")
                    firmware_versions[station_id] = version
    finally:
        conn.close()

    stations = []
    for r, station_id in zip(redirects, station_ids):
        version = firmware_versions.get(station_id)
        label = station_id if not version else f"{station_id} ({version})"
        stations.append({"source": r["source_url"], "id": station_id, "label": label})

    return render_template(
        "op_firmware.html",
        firmwares=firmware_files,
        stations=stations,
        delivery_options=FIRMWARE_DELIVERY_OPTIONS,
        default_delivery=DEFAULT_FIRMWARE_TARGET,
        protocol_options=FIRMWARE_PROTOCOL_OPTIONS,
        default_protocol=DEFAULT_FIRMWARE_PROTOCOL,
        firmware_directory=os.path.abspath(base_dir),
    )

# --- 1) Upload-Firmware ---
@app.route('/op_firmware_upload', methods=['POST'])
def firmware_upload():
    uploaded = request.files.get('firmware_file')
    if not uploaded or not uploaded.filename:
        return jsonify({'error': 'No file uploaded'}), 400

    try:
        filename, file_path = resolve_firmware_path(uploaded.filename)
    except ValueError:
        return jsonify({'error': 'Invalid filename'}), 400

    ensure_firmware_directory()
    try:
        uploaded.save(file_path)
    except OSError as exc:
        app.logger.error('Failed to save firmware %s: %s', filename, exc)
        return jsonify({'error': 'Failed to save firmware file'}), 500

    return redirect(url_for('firmware'))

# --- 2) Delete-Firmware ---
@app.route('/op_firmware_delete', methods=['POST'])
def firmware_delete():
    data = request.get_json() or {}
    filename = data.get('filename')
    if not filename:
        return jsonify({'error': 'filename required'}), 400

    try:
        safe_name, file_path = resolve_firmware_path(filename)
    except ValueError:
        return jsonify({'error': 'Invalid filename'}), 400

    if not os.path.exists(file_path):
        return jsonify({'error': 'File not found'}), 404

    try:
        os.remove(file_path)
    except OSError as exc:
        app.logger.error('Failed to delete firmware %s: %s', safe_name, exc)
        return jsonify({'error': 'Failed to delete firmware file'}), 500

    return jsonify({'status': 'ok', 'filename': safe_name}), 200

# --- 3) Download-Firmware (für Wallbox) ---
@app.route('/op_firmware_download/<int:fid>')
def firmware_download(fid):
    conn = get_db_conn()
    with conn.cursor() as cur:
        cur.execute("SELECT filename, content FROM op_firmware_files WHERE id=%s", (fid,))
        row = cur.fetchone()
    conn.close()
    if not row:
        return "Nicht gefunden", 404
    filename, blob = row
    return send_file(
        BytesIO(blob),
        attachment_filename=filename,
        as_attachment=True,
        mimetype='application/octet-stream'
    )

# --- 4) Trigger Firmware-Update via OCPP ---
@app.route('/op_firmware_send', methods=['POST'])
def firmware_send():
    data = request.get_json() or {}
    station_id = data.get('station_id')
    filename = data.get('filename')
    target = data.get('target') or DEFAULT_FIRMWARE_TARGET
    protocol = data.get('protocol') or DEFAULT_FIRMWARE_PROTOCOL
    allowed_targets = {opt['value'] for opt in FIRMWARE_DELIVERY_OPTIONS}
    if target not in allowed_targets:
        target = DEFAULT_FIRMWARE_TARGET
    if protocol not in _ALLOWED_FIRMWARE_PROTOCOLS:
        protocol = DEFAULT_FIRMWARE_PROTOCOL
    if not station_id or not filename:
        return jsonify({'error': 'station_id and filename required'}), 400

    try:
        safe_name, file_path = resolve_firmware_path(filename)
    except ValueError:
        return jsonify({'error': 'Invalid filename'}), 400

    if not os.path.exists(file_path):
        return jsonify({'error': 'Firmware file not found'}), 404

    download_url = build_firmware_download_url(safe_name, protocol=protocol)

    normalized_station = normalize_station_id(station_id)
    params = {'location': download_url, 'retries': 3, 'retryInterval': 60}
    errors: list[str] = []

    if target == 'server':
        ocpp_server_url = f"{OCPP_SERVER_API_BASE_URL}/updateFirmware/{normalized_station}"
        try:
            resp = requests.get(ocpp_server_url, params=params, timeout=5)
            resp.raise_for_status()
        except Exception as exc:
            errors.append(f"updateFirmware via server error: {exc}")
            return jsonify({'error': '; '.join(errors)}), 500

        response_payload: dict | str | None
        content_type = resp.headers.get('Content-Type', '')
        if content_type.startswith('application/json'):
            try:
                response_payload = resp.json()
            except ValueError:
                response_payload = resp.text
        else:
            response_payload = resp.text

        return jsonify({
            'status': 'ok',
            'station': normalized_station,
            'filename': safe_name,
            'location': download_url,
            'target': 'server',
            'protocol': protocol,
            'response': response_payload,
        }), 200

    api_url = f"{PROXY_BASE_URL}/api/updateFirmware"
    payload = {
        'station_id': normalized_station,
        'location': download_url,
        'retries': params['retries'],
        'retryInterval': params['retryInterval'],
    }

    try:
        resp = requests.post(api_url, json=payload, timeout=5)
        if resp.ok:
            response_payload = resp.json() if resp.headers.get('Content-Type', '').startswith('application/json') else {}
            return jsonify({
                'status': 'ok',
                'station': normalized_station,
                'filename': safe_name,
                'location': download_url,
                'target': 'broker',
                'protocol': protocol,
                'response': response_payload,
            }), 200
        errors.append(f"api_updateFirmware: {resp.status_code} {resp.text}")
    except Exception as exc:
        errors.append(f"api_updateFirmware error: {exc}")

    ocpp_url = f"{PROXY_BASE_URL}/updateFirmware/{normalized_station}"
    try:
        resp = requests.get(
            ocpp_url,
            params=params,
            timeout=5,
        )
        resp.raise_for_status()
    except Exception as exc:
        errors.append(f"updateFirmware fallback error: {exc}")
        return jsonify({'error': '; '.join(errors)}), 500

    return jsonify({
        'status': 'ok',
        'station': normalized_station,
        'filename': safe_name,
        'location': download_url,
        'target': 'broker',
        'protocol': protocol,
    }), 200

@app.route('/op_branding', methods=['GET', 'POST'])
def branding_settings():
    if not is_admin_user():
        abort(403)

    message: str | None = None
    error: str | None = None
    logo_light_path = get_dashboard_logo_path("light")
    logo_dark_path = get_dashboard_logo_path("dark")
    current_color_scheme = get_dashboard_color_scheme()

    if request.method == 'POST':
        action = request.form.get('action', 'upload')
        variant = request.form.get('variant', 'light').lower()
        if variant not in {'light', 'dark'}:
            variant = 'light'
        variant_label = translate_text('Light mode') if variant == 'light' else translate_text('Dark mode')
        current_path = logo_light_path if variant == 'light' else logo_dark_path
        if action == 'set_theme':
            selected_scheme = (request.form.get('color_scheme') or '').strip().lower()
            if selected_scheme not in COLOR_SCHEME_VARIANTS:
                error = translate_text('Unknown color theme selected.')
            else:
                set_dashboard_color_scheme(selected_scheme)
                current_color_scheme = selected_scheme
                theme_label = translate_text(COLOR_SCHEME_VARIANTS[selected_scheme]['label'])
                message = translate_text("Color theme '{theme}' activated.", theme=theme_label)
        elif action == 'delete':
            if current_path:
                _maybe_delete_logo_file(current_path, variant)
                _store_dashboard_logo_path(variant, None)
                if variant == 'light':
                    logo_light_path = None
                else:
                    logo_dark_path = None
                message = translate_text('Logo for {mode} removed.', mode=variant_label)
            else:
                message = translate_text('No logo stored for {mode}.', mode=variant_label)
        else:
            file = request.files.get('logo')
            if not file or not file.filename:
                error = translate_text('Please choose a file.')
            else:
                filename = secure_filename(file.filename)
                extension = os.path.splitext(filename)[1].lower()
                if extension not in ALLOWED_LOGO_EXTENSIONS:
                    allowed = ', '.join(sorted(ext.lstrip('.') for ext in ALLOWED_LOGO_EXTENSIONS))
                    error = translate_text('Unsupported file format. Allowed: {allowed}.', allowed=allowed)
                else:
                    file.stream.seek(0, os.SEEK_END)
                    size = file.stream.tell()
                    file.stream.seek(0)
                    if size == 0:
                        error = translate_text('The file is empty.')
                    elif size > MAX_LOGO_FILE_SIZE:
                        max_logo_size_mb = MAX_LOGO_FILE_SIZE // (1024 * 1024)
                        error = translate_text('The file is too large (maximum {max_mb} MB).', max_mb=max_logo_size_mb)
                    else:
                        new_filename = f"dashboard_logo_{uuid.uuid4().hex}{extension}"
                        target_path = os.path.join(LOGO_UPLOAD_FOLDER, new_filename)
                        try:
                            with open(target_path, 'wb') as fh:
                                fh.write(file.read())
                        except OSError:
                            error = translate_text('The file could not be saved.')
                        else:
                            previous_path = current_path
                            relative_path = f"{LOGO_UPLOAD_SUBDIR}/{new_filename}"
                            _store_dashboard_logo_path(variant, relative_path)
                            if variant == 'light':
                                logo_light_path = relative_path
                            else:
                                logo_dark_path = relative_path
                            if previous_path and previous_path != relative_path:
                                _maybe_delete_logo_file(previous_path, variant)
                            message = translate_text('Logo for {mode} updated.', mode=variant_label)

        logo_light_path = get_dashboard_logo_path('light')
        logo_dark_path = get_dashboard_logo_path('dark')
        current_color_scheme = get_dashboard_color_scheme()

    logo_light_url = url_for('static', filename=logo_light_path) if logo_light_path else None
    logo_dark_url = url_for('static', filename=logo_dark_path) if logo_dark_path else None
    logo_dark_fallback = bool(logo_light_url and not logo_dark_url)

    return render_template(
        'op_branding.html',
        message=message,
        error=error,
        logo_light_url=logo_light_url,
        logo_dark_url=logo_dark_url,
        logo_dark_fallback=logo_dark_fallback,
        max_file_size=MAX_LOGO_FILE_SIZE,
        current_color_scheme=current_color_scheme,
        color_schemes=[{"id": key, **value} for key, value in COLOR_SCHEME_VARIANTS.items()],
        aside='settings_branding',
    )


@app.route('/op_systemConfig', methods=['GET', 'POST'])
def system_config():
    global PROXY_BASE_URL, PROXY_BASE_WS, CONNECTED_ENDPOINT, STATS_ENDPOINT, ACTIVE_SESSIONS_ENDPOINT, BROKER_STATUS_ENDPOINT
    global OCPP_SERVER_API_BASE_URL, _ocpp_server_ip, _ocpp_api_port, OCPP_SERVER_FW_URL_PREFIX, _proxy_ip, _proxy_port, _proxy_api_port, PROXY_DISPLAY_BASE_URL

    conn = get_db_conn()
    msg = None
    rows = []
    try:
        with conn.cursor() as cur:
            if request.method == 'POST':
                action = request.form.get('action')
                if action == 'token_login':
                    set_config_value(
                        TOKEN_LOGIN_ENABLED_KEY,
                        '1' if request.form.get('token_login_enabled') == 'on' else '0',
                    )
                    new_token = request.form.get('token_login_value', '').strip()
                    set_config_value(TOKEN_KEY, new_token or DEFAULT_DASHBOARD_TOKEN)
                    msg = translate_text('Configuration saved.')
                elif action == 'add':
                    cur.execute(
                        "INSERT INTO op_config (config_key, config_value, config_hint) VALUES (%s, %s, %s)",
                        (
                            request.form.get('config_key', ''),
                            request.form.get('config_value', ''),
                            request.form.get('config_hint', ''),
                        ),
                    )
                elif action == 'update':
                    cur.execute(
                        "UPDATE op_config SET config_key=%s, config_value=%s, config_hint=%s WHERE config_key=%s",
                        (
                            request.form.get('config_key', ''),
                            request.form.get('config_value', ''),
                            request.form.get('config_hint', ''),
                            request.form.get('orig_key'),
                        ),
                    )
                elif action == 'delete':
                    cur.execute(
                        "DELETE FROM op_config WHERE config_key=%s",
                        (request.form.get('orig_key'),),
                    )
                conn.commit()
                msg = translate_text('Configuration saved.')

            cur.execute("SELECT config_key, config_value, config_hint FROM op_config")
            rows = cur.fetchall()
    finally:
        conn.close()

    rows = [
        r
        for r in rows
        if r["config_key"]
        not in (
            "authorizeTransaction_enabled",
            "authorizeTransaction_url",
            "authorizeTransaction_token",
            "authorizeTransaction_api_key",
        )
    ]

    # Laufende Konfiguration neu laden, um globale URLs zu aktualisieren
    runtime_cfg = load_runtime_config()
    proxy_settings = _build_proxy_settings(runtime_cfg)
    _proxy_ip = proxy_settings['ip']
    _proxy_port = proxy_settings['port']
    _proxy_api_port = proxy_settings['api_port']
    PROXY_DISPLAY_BASE_URL = proxy_settings['display_base_url']
    PROXY_BASE_URL = proxy_settings['base_url']
    PROXY_BASE_WS = proxy_settings['ws_base_url']
    base_without_trailing = PROXY_BASE_URL.rstrip('/')
    CONNECTED_ENDPOINT = base_without_trailing + '/getConnecteEVSE'
    STATS_ENDPOINT = base_without_trailing + '/connectedWallboxes'
    ACTIVE_SESSIONS_ENDPOINT = base_without_trailing + '/activeSessions'
    BROKER_STATUS_ENDPOINT = base_without_trailing + '/brokerStatus'

    _ocpp_server_ip = runtime_cfg.get('ocpp_server_ip', _proxy_ip)
    try:
        _ocpp_api_port = int(runtime_cfg.get('ocpp_api_port', _ocpp_api_port))
    except (TypeError, ValueError):
        _ocpp_api_port = 9751
    OCPP_SERVER_API_BASE_URL = f'http://{_ocpp_server_ip}:{_ocpp_api_port}'

    _fw_prefix = (runtime_cfg.get('ocpp_server_fw_url_prefix') or '').strip()
    OCPP_SERVER_FW_URL_PREFIX = _fw_prefix.rstrip('/') if _fw_prefix else None

    token_login_enabled = is_token_login_enabled()
    token_login_value = get_token()
    token_login_link = url_for('dashboard', token=token_login_value, _external=True)

    return render_template(
        'op_system_config.html',
        entries=rows,
        message=msg,
        token_login_enabled=token_login_enabled,
        token_login_value=token_login_value,
        token_login_link=token_login_link,
        default_token_value=DEFAULT_DASHBOARD_TOKEN,
        aside='settingsMenu',
    )


@app.route('/op_external_api', methods=['GET', 'POST'])
def external_api_config():
    msg = None
    if request.method == 'POST':
        set_config_value(
            'authorizeTransaction_enabled',
            '1' if request.form.get('auth_enabled') == 'on' else '0',
        )
        set_config_value(
            'authorizeTransaction_url', request.form.get('auth_url', ''),
        )
        set_config_value(
            'authorizeTransaction_token', request.form.get('auth_token', ''),
        )
        set_config_value(
            'authorizeTransaction_api_key', request.form.get('auth_api_key', ''),
        )
        msg = 'Konfiguration gespeichert'

    auth_enabled = get_config_value('authorizeTransaction_enabled') == '1'
    auth_url = get_config_value('authorizeTransaction_url') or ''
    auth_token = get_config_value('authorizeTransaction_token') or ''
    auth_api_key = get_config_value('authorizeTransaction_api_key') or ''
    return render_template(
        'op_external_api.html',
        message=msg,
        auth_enabled=auth_enabled,
        auth_url=auth_url,
        auth_token=auth_token,
        auth_api_key=auth_api_key,
        aside='settingsMenu',
    )


OCPI_AVAILABLE_MODULES = (
    'locations',
    'sessions',
    'cdrs',
    'tariffs',
    'tokens',
    'commands',
    'chargingprofiles',
)

OCPI_SERVICE_SCRIPT = (Path(__file__).resolve().parent / "services" / "ocpi_api.py").resolve()
OCPI_SERVICE_RESTART_SCRIPT = (Path(__file__).resolve().parent / "restart_pipelet_ocpi_service.sh").resolve()
OCPI_SERVICE_LOG_FILE = (Path(__file__).resolve().parent / "pipelet_ocpi_service.log").resolve()


def _serialize_backend_modules(form) -> str:
    modules = {
        module.strip().lower()
        for module in form.getlist('backend_modules')
        if module and module.strip()
    }
    if not modules:
        modules = {'cdrs'}
    return ','.join(sorted(modules))


def _load_ocpi_feed_stats() -> dict:
    conn = get_db_conn()
    stats: dict = {}
    try:
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_ocpi_exports'")
            if not cur.fetchone():
                return {}
            cur.execute(
                """
                SELECT backend_id,
                       COUNT(*) AS total,
                       SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS success_count,
                       SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS failure_count,
                       SUM(CASE WHEN should_retry = 1 THEN 1 ELSE 0 END) AS pending_retry,
                       MAX(created_at) AS last_created_at
                FROM op_ocpi_exports
                GROUP BY backend_id
                """
            )
            for row in cur.fetchall():
                backend_id = row.get('backend_id')
                stats[backend_id] = {
                    'total': int(row.get('total') or 0),
                    'success': int(row.get('success_count') or 0),
                    'failure': int(row.get('failure_count') or 0),
                    'pending': int(row.get('pending_retry') or 0),
                    'last_created_at': row.get('last_created_at'),
                }

            cur.execute(
                """
                SELECT backend_id, response_status, success, created_at
                FROM op_ocpi_exports
                ORDER BY created_at DESC
                """
            )
            for row in cur.fetchall():
                backend_id = row.get('backend_id')
                stats.setdefault(
                    backend_id,
                    {
                        'total': 0,
                        'success': 0,
                        'failure': 0,
                        'pending': 0,
                        'last_created_at': row.get('created_at'),
                    },
                )
                if 'last_status' not in stats[backend_id]:
                    stats[backend_id]['last_status'] = row.get('response_status')
                    stats[backend_id]['last_success'] = bool(row.get('success'))
    except Exception:
        logger.debug('Failed to load OCPI feed stats', exc_info=True)
    finally:
        conn.close()
    return stats


def _load_handshake_statuses(backend_id: Optional[int] = None) -> list[dict[str, Any]]:
    ensure_ocpi_backend_tables()
    conn = get_db_conn()
    rows: list[dict[str, Any]] = []
    try:
        with conn.cursor(pymysql.cursors.DictCursor) as cur:
            where = []
            params: list[Any] = []
            if backend_id is not None:
                where.append("h.backend_id=%s")
                params.append(backend_id)
            where_clause = f"WHERE {' AND '.join(where)}" if where else ""
            cur.execute(
                f"""
                SELECT h.id, h.backend_id, b.name, h.state, h.status, h.detail, h.token, h.peer_url, h.updated_at, h.created_at,
                       b.token AS backend_token, b.peer_token, b.credentials_token
                FROM op_ocpi_handshakes AS h
                JOIN op_ocpi_backends AS b ON h.backend_id = b.backend_id
                {where_clause}
                ORDER BY h.updated_at DESC, h.id DESC
                LIMIT 200
                """,
                params,
            )
            rows = cur.fetchall()
    except Exception:
        logger.debug("Failed to load handshake statuses", exc_info=True)
    finally:
        conn.close()

    for row in rows:
        for key in ("backend_token", "peer_token", "credentials_token"):
            token_val = row.get(key)
            if token_val:
                row[f"{key}_present"] = True
                row[f"{key}_hint"] = str(token_val)[-6:]
            else:
                row[f"{key}_present"] = False
                row[f"{key}_hint"] = None
        row["mtls_ready"] = bool(_config.get("ocpi_api", {}).get("ocpp_api_verify_tls", True)) if isinstance(_config, Mapping) else True
    return rows


def _load_ocpi_command_stats() -> dict:
    conn = get_db_conn()
    stats: dict = {}
    try:
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_ocpi_command_results'")
            if not cur.fetchone():
                return {}
            cur.execute(
                """
                SELECT backend_id,
                       module,
                       COUNT(*) AS total,
                       SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS success_count,
                       SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS failure_count,
                       MAX(created_at) AS last_created_at,
                       MAX(response_status) AS last_status
                FROM op_ocpi_command_results
                GROUP BY backend_id, module
                """
            )
            for row in cur.fetchall():
                backend_id = row.get('backend_id')
                module = row.get('module') or 'commands'
                backend_stats = stats.setdefault(backend_id, {})
                backend_stats[module] = {
                    'total': int(row.get('total') or 0),
                    'success': int(row.get('success_count') or 0),
                    'failure': int(row.get('failure_count') or 0),
                    'last_status': row.get('last_status'),
                    'last_created_at': row.get('last_created_at'),
                }
    except Exception:
        logger.debug('Failed to load OCPI command stats', exc_info=True)
    finally:
        conn.close()
    return stats


COMMAND_QUEUE_STATUS_LABELS = {
    'queued': 'Queued',
    'in_progress': 'In progress',
    'succeeded': 'Succeeded',
    'failed': 'Failed',
    'cancelled': 'Cancelled',
}

COMMAND_QUEUE_TYPE_LABELS = {
    'START_SESSION': 'Start session',
    'STOP_SESSION': 'Stop session',
    'RESERVE_NOW': 'Reserve now',
    'UNLOCK_CONNECTOR': 'Unlock connector',
}


def _load_command_queue(limit: int = 200) -> list[dict[str, Any]]:
    conn = get_db_conn()
    entries: list[dict[str, Any]] = []
    try:
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_ocpi_command_queue'")
            if not cur.fetchone():
                return []
            cur.execute(
                """
                SELECT q.*, b.name AS backend_name
                FROM op_ocpi_command_queue AS q
                LEFT JOIN op_ocpi_backends AS b ON q.backend_id = b.backend_id
                ORDER BY q.updated_at DESC, q.id DESC
                LIMIT %s
                """,
                (limit,),
            )
            entries = cur.fetchall()
    except Exception:
        logger.debug("Failed to load OCPI command queue", exc_info=True)
    finally:
        conn.close()
    return entries


def _ocpi_api_base_url() -> str:
    api_cfg = _config.get("ocpi_api", {}) if isinstance(_config, Mapping) else {}
    host = api_cfg.get("host") or "127.0.0.1"
    port = int(api_cfg.get("port", 9760))
    scheme = "https" if str(api_cfg.get("use_tls", "")).lower() in {"1", "true"} else "http"
    normalized_host = "127.0.0.1" if host == "0.0.0.0" else host
    return f"{scheme}://{normalized_host}:{port}"


def _trigger_command_queue_action(command_id: str, action: str) -> tuple[bool, str | None]:
    base_url = _ocpi_api_base_url().rstrip("/")
    url = f"{base_url}/api/command-queue/{quote(command_id)}/{action}"
    try:
        response = requests.post(url, timeout=10)
    except Exception as exc:
        return False, str(exc)
    if response.status_code >= 400:
        try:
            payload = response.json()
            detail = payload.get("status_message") or payload.get("data") or response.text
        except Exception:
            detail = response.text
        return False, f"{response.status_code}: {detail}"
    return True, None


def _fetch_ocpi_sync_status() -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], str | None]:
    base_url = _ocpi_api_base_url().rstrip("/")
    url = f"{base_url}/api/sync-runs"
    try:
        response = requests.get(url, timeout=10)
    except Exception as exc:
        return [], [], [], [], str(exc)

    try:
        payload = response.json()
    except Exception:
        return [], [], [], [], response.text

    data = payload.get("data") if isinstance(payload, Mapping) else {}
    runs = data.get("runs") or []
    dead_letter = data.get("dead_letter") or []
    jobs = data.get("jobs") or []
    logs = data.get("logs") or []
    status_code_value = payload.get("status_code") if isinstance(payload, Mapping) else None
    if response.status_code >= 400 or (status_code_value is not None and status_code_value >= 2000):
        error_message = payload.get("status_message") if isinstance(payload, Mapping) else None
        return runs, dead_letter, jobs, logs, error_message or response.text
    return runs, dead_letter, jobs, logs, None


def _summarize_sync_runs(runs: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
    modules = ["locations", "tariffs", "tokens", "sessions", "cdrs"]
    summary: dict[str, dict[str, Any]] = {module: {"pull": None, "push": None, "retry": None} for module in modules}
    for entry in runs or []:
        module = str(entry.get("module") or "").lower()
        direction_raw = str(entry.get("direction") or "").lower()
        direction = "retry" if direction_raw == "replay" else direction_raw
        if module in summary and direction in summary[module] and summary[module][direction] is None:
            summary[module][direction] = entry
    return [{"module": module, **summary[module]} for module in modules]


def _retry_export_via_ocpi(export_id: int) -> tuple[bool, str | Mapping[str, Any]]:
    base_url = _ocpi_api_base_url().rstrip("/")
    url = f"{base_url}/api/exports/{export_id}/retry"
    try:
        response = requests.post(url, timeout=15)
    except Exception as exc:
        return False, str(exc)

    try:
        payload = response.json()
    except Exception:
        return False, response.text

    data = payload.get("data") if isinstance(payload, Mapping) else {}
    status_code_value = payload.get("status_code") if isinstance(payload, Mapping) else None
    success = response.status_code < 400 and (status_code_value is None or status_code_value < 2000)
    if success and isinstance(data, Mapping):
        success = bool(data.get("success", True))
    if not success:
        message = payload.get("status_message") if isinstance(payload, Mapping) else None
        message = message or (data.get("response") if isinstance(data, Mapping) else None)
        return False, message or response.text
    return True, data


def _ocpi_service_status() -> dict[str, Any]:
    pids: list[int] = []
    error: str | None = None
    try:
        result = subprocess.run(
            ["pgrep", "-f", str(OCPI_SERVICE_SCRIPT)],
            capture_output=True,
            text=True,
            check=False,
        )
        if result.returncode == 0:
            pids = [
                int(pid)
                for pid in (result.stdout or "").split()
                if pid.strip().isdigit()
            ]
        elif result.returncode not in (0, 1):
            error = (result.stderr or result.stdout or "").strip() or translate_text('Status check failed.')
    except FileNotFoundError:
        error = translate_text('pgrep not available on this system.')
    except Exception:
        app.logger.warning("Failed to check OCPI service status", exc_info=True)
        error = translate_text('Status check failed.')

    return {
        "running": bool(pids),
        "pids": pids,
        "script": str(OCPI_SERVICE_SCRIPT),
        "restart_script": str(OCPI_SERVICE_RESTART_SCRIPT),
        "log_file": str(OCPI_SERVICE_LOG_FILE),
        "error": error,
    }


def _format_timestamp(value: Any) -> str:
    if isinstance(value, datetime.datetime):
        return value.replace(microsecond=0).isoformat(sep=" ")
    if isinstance(value, str):
        return value
    return ""


def _load_live_sessions(limit: int = 200) -> tuple[list[dict[str, Any]], dict[str, int]]:
    try:
        conn = get_db_conn()
    except Exception:
        logger.debug("Failed to open DB connection for session status", exc_info=True)
        return [], {"total": 0, "active": 0, "completed": 0}

    sessions: list[dict[str, Any]] = []
    stats = {"total": 0, "active": 0, "completed": 0}
    try:
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_broker_sessions'")
            if not cur.fetchone():
                return [], stats
            cur.execute(
                """
                SELECT session_uid, station_id, connector_id, evse_id, transaction_id, id_tag,
                       status, start_timestamp, end_timestamp, meter_start_wh, meter_stop_wh,
                       meter_last_wh, last_status, last_status_timestamp, updated_at
                FROM op_broker_sessions
                ORDER BY updated_at DESC
                LIMIT %s
                """,
                (max(limit, 1),),
            )
            rows = cur.fetchall()
    except Exception:
        logger.debug("Failed to load broker sessions", exc_info=True)
        return [], stats
    finally:
        try:
            conn.close()
        except Exception:
            pass

    for row in rows:
        status = (row.get("status") or "").upper()
        stats["total"] += 1
        if status == "ACTIVE":
            stats["active"] += 1
        elif status in {"COMPLETED", "ENDED", "STOPPED"}:
            stats["completed"] += 1
        sessions.append(
            {
                "session_uid": row.get("session_uid"),
                "station_id": row.get("station_id"),
                "connector_id": row.get("connector_id"),
                "evse_id": row.get("evse_id"),
                "transaction_id": row.get("transaction_id"),
                "id_tag": row.get("id_tag"),
                "status": status or None,
                "start_timestamp": _format_timestamp(row.get("start_timestamp")),
                "end_timestamp": _format_timestamp(row.get("end_timestamp")),
                "meter_start_wh": row.get("meter_start_wh"),
                "meter_stop_wh": row.get("meter_stop_wh"),
                "meter_last_wh": row.get("meter_last_wh"),
                "last_status": row.get("last_status"),
                "last_status_timestamp": _format_timestamp(row.get("last_status_timestamp")),
                "updated_at": _format_timestamp(row.get("updated_at")),
            }
        )

    return sessions, stats


def _session_index(sessions: Iterable[Mapping[str, Any]]) -> dict[str, Mapping[str, Any]]:
    index: dict[str, Mapping[str, Any]] = {}
    for session in sessions:
        for key in (session.get("transaction_id"), session.get("session_uid")):
            if key:
                index[str(key)] = session
    return index


def _parse_export_payload(raw_payload: Any) -> dict[str, Any]:
    if isinstance(raw_payload, str):
        try:
            raw_payload = json.loads(raw_payload)
        except Exception:
            return {}
    if isinstance(raw_payload, Mapping):
        return dict(raw_payload)
    return {}


def _derive_cdr_state(row: Mapping[str, Any]) -> str:
    if int(row.get("success") or 0):
        return "success"
    if int(row.get("should_retry") or 0):
        return "pending"
    return "failed"


def _serialize_cdr_row(
    row: Mapping[str, Any],
    *,
    session_index: Optional[Mapping[str, Mapping[str, Any]]] = None,
) -> dict[str, Any]:
    payload = _parse_export_payload(row.get("payload"))
    transaction_id = row.get("transaction_id") or payload.get("id")
    session_match = None
    if transaction_id and session_index:
        session_match = session_index.get(str(transaction_id))
    state = _derive_cdr_state(row)
    response_body = row.get("response_body")
    if response_body and len(str(response_body)) > 240:
        response_body = str(response_body)[:240] + "…"
    entry = {
        "id": row.get("id"),
        "station_id": row.get("station_id") or payload.get("station_id"),
        "backend_id": row.get("backend_id"),
        "backend_name": row.get("backend_name"),
        "transaction_id": transaction_id,
        "payload_id": payload.get("id"),
        "state": state,
        "should_retry": bool(row.get("should_retry")),
        "retry_count": int(row.get("retry_count") or 0),
        "response_status": row.get("response_status"),
        "response_body": response_body,
        "created_at": _format_timestamp(row.get("created_at")),
    }
    if session_match:
        entry["session_status"] = session_match.get("status")
        entry["session_uid"] = session_match.get("session_uid")
    return entry


def _load_cdr_queue(
    *,
    session_index: Optional[Mapping[str, Mapping[str, Any]]] = None,
    limit: int = 200,
) -> tuple[list[dict[str, Any]], dict[str, int]]:
    try:
        conn = get_db_conn()
    except Exception:
        logger.debug("Failed to open DB connection for CDR queue", exc_info=True)
        return [], {"total": 0, "success": 0, "pending": 0, "failed": 0}

    entries: list[dict[str, Any]] = []
    try:
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_ocpi_exports'")
            if not cur.fetchone():
                return [], {"total": 0, "success": 0, "pending": 0, "failed": 0}
            cur.execute(
                """
                SELECT id, station_id, backend_id, backend_name, transaction_id, payload,
                       success, response_status, response_body, retry_count, should_retry,
                       created_at
                FROM op_ocpi_exports
                WHERE record_type='cdr'
                ORDER BY created_at DESC
                LIMIT %s
                """,
                (max(limit, 1),),
            )
            rows = cur.fetchall()
    except Exception:
        logger.debug("Failed to load CDR queue", exc_info=True)
        return [], {"total": 0, "success": 0, "pending": 0, "failed": 0}
    finally:
        try:
            conn.close()
        except Exception:
            pass

    stats = {"total": 0, "success": 0, "pending": 0, "failed": 0}
    for row in rows:
        entry = _serialize_cdr_row(row, session_index=session_index)
        entries.append(entry)
        state = entry.get("state")
        stats["total"] += 1
        if state in stats:
            stats[state] += 1
    return entries, stats


def _select_backends_for_station(conn, station_id: str) -> list[Mapping[str, Any]]:
    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT b.backend_id, b.name, b.url, b.remote_versions_url, b.peer_versions_url,
                   b.active_version, b.token, b.peer_token, b.modules
            FROM op_ocpi_backends AS b
            JOIN op_ocpi_wallbox_backends AS wb ON wb.backend_id = b.backend_id
            WHERE wb.station_id=%s AND wb.enabled=1 AND b.enabled=1
            ORDER BY wb.priority ASC, b.backend_id ASC
            """,
            (station_id,),
        )
        return cur.fetchall()


def _replay_cdr_export(
    export_id: int,
    *,
    session_index: Optional[Mapping[str, Mapping[str, Any]]] = None,
) -> tuple[bool, str | dict[str, Any]]:
    try:
        conn = get_db_conn()
    except Exception:
        logger.debug("Failed to open DB connection for CDR replay", exc_info=True)
        return False, translate_text("CDR replay failed.")

    try:
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'op_ocpi_exports'")
            if not cur.fetchone():
                return False, translate_text("CDR export table not found.")
            cur.execute(
                """
                SELECT id, station_id, backend_id, backend_name, transaction_id, payload,
                       success, response_status, response_body, retry_count, should_retry,
                       created_at
                FROM op_ocpi_exports
                WHERE record_type='cdr' AND id=%s
                """,
                (export_id,),
            )
            row = cur.fetchone()
            if not row:
                return False, translate_text("Export not found.")

            payload = _parse_export_payload(row.get("payload"))

            backend_row: Mapping[str, Any] | None = None
            if row.get("backend_id"):
                cur.execute(
                    """
                    SELECT backend_id, name, url, remote_versions_url, peer_versions_url,
                           active_version, token, peer_token, modules
                    FROM op_ocpi_backends
                    WHERE backend_id=%s AND enabled=1
                    """,
                    (row.get("backend_id"),),
                )
                backend_row = cur.fetchone()

            if not backend_row and row.get("station_id"):
                backends = _select_backends_for_station(conn, row.get("station_id"))
                backend_row = backends[0] if backends else None

            if not backend_row:
                return False, translate_text("No OCPI backend configured for this station.")

            modules = (backend_row.get("modules") or "cdrs").lower().replace(";", ",")
            module_set = {m.strip() for m in modules.split(",") if m.strip()}
            if module_set and "cdrs" not in module_set:
                return False, translate_text("CDR module is disabled for the selected backend.")

            target_url = build_cdr_endpoint(backend_row)
            headers = {}
            token = backend_row.get("peer_token") or backend_row.get("token")
            if token:
                headers["Authorization"] = f"Bearer {token}"

            response_status = None
            response_body = None
            success = False
            try:
                resp = requests.post(
                    target_url,
                    json=payload,
                    timeout=10,
                    headers=headers or None,
                )
                response_status = resp.status_code
                response_body = (resp.text or "")[:1000]
                success = resp.ok
            except Exception as exc:
                response_body = str(exc)
                success = False

            retry_count = int(row.get("retry_count") or 0) + 1
            should_retry = 0 if success else 1
            cur.execute(
                """
                UPDATE op_ocpi_exports
                SET backend_id=%s,
                    backend_name=%s,
                    success=%s,
                    response_status=%s,
                    response_body=%s,
                    retry_count=%s,
                    should_retry=%s
                WHERE id=%s
                """,
                (
                    backend_row.get("backend_id"),
                    backend_row.get("name"),
                    1 if success else 0,
                    response_status,
                    response_body,
                    retry_count,
                    should_retry,
                    export_id,
                ),
            )
            conn.commit()

            updated_row = dict(row)
            updated_row.update(
                {
                    "backend_id": backend_row.get("backend_id"),
                    "backend_name": backend_row.get("name"),
                    "success": 1 if success else 0,
                    "response_status": response_status,
                    "response_body": response_body,
                    "retry_count": retry_count,
                    "should_retry": should_retry,
                }
            )
            return True, _serialize_cdr_row(updated_row, session_index=session_index)
    finally:
        try:
            conn.close()
        except Exception:
            pass


def ensure_emsp_token_table() -> None:
    conn = get_db_conn()
    try:
        get_token_service().ensure_table(conn)
    finally:
        conn.close()


@app.route('/op_cyber_security', methods=['GET'])
def cyber_security_overview():
    return render_template('op_cyber_security.html', aside='cyber_security')


@app.route('/op_ocpp_broker_monitor', methods=['GET'])
def op_ocpp_broker_monitor():
    context = _build_broker_monitor_context()
    return render_template('op_ocpp_broker_monitor.html', aside='ocpp_broker_monitor', **context)


@app.route('/op_ocpp_server_monitor', methods=['GET'])
def op_ocpp_server_monitor():
    context = _build_ocpp_server_monitor_context()
    return render_template('op_ocpp_server_monitor.html', aside='ocpp_server_monitor', **context)


@app.route('/op_ocpi_monitoring', methods=['GET'])
def ocpi_monitoring_view():
    status = _ocpi_service_status()
    kpi_metrics = _collect_dashboard_metrics(include_jobs=False)
    kpi_translations = _build_kpi_translation_map()
    return render_template(
        'op_ocpi_monitoring.html',
        status=status,
        kpi_metrics=kpi_metrics,
        kpi_translations=kpi_translations,
        aside='ocpi_monitoring',
    )


@app.route('/api/op_ocpi_exports/<int:export_id>/replay', methods=['POST'])
def api_replay_ocpi_export(export_id: int):
    success, detail = _retry_export_via_ocpi(export_id)
    status_code = 200 if success else 502
    payload: dict[str, Any] = {"success": success}
    if success:
        payload["data"] = detail
    else:
        payload["error"] = detail
    return jsonify(payload), status_code


@app.route('/op_ocpi_sync', methods=['GET'])
def ocpi_sync_overview():
    runs, dead_letter, jobs, logs, error = _fetch_ocpi_sync_status()
    summary = _summarize_sync_runs(runs)
    translations = build_translation_map(
        replay="Replay",
        replaying="Replaying…",
        replay_success="Replay sent.",
        replay_failed="Replay failed.",
    )
    return render_template(
        'op_ocpi_sync.html',
        summary=summary,
        dead_letter=dead_letter,
        jobs=jobs,
        logs=logs,
        error=error,
        translations=translations,
        module_options=sorted(SUPPORTED_OCPI_MODULES),
        version_options=OCPI_VERSION_OPTIONS,
        aside='ocpi_sync',
    )


@app.route('/op_ocpi_sessions', methods=['GET'])
def ocpi_sessions_view():
    sessions, session_stats = _load_live_sessions()
    session_map = _session_index(sessions)
    cdr_entries, cdr_stats = _load_cdr_queue(session_index=session_map)
    translations = build_translation_map(
        active="Active",
        completed="Completed",
        pending="Pending",
        failed="Failed",
        success="Success",
        unknown_status="Unknown status",
        session_match="Session match",
        session_missing="No matching session found",
        replay="Replay",
        replay_running="Replaying…",
        replay_success="CDR replay sent.",
        replay_failed="CDR replay failed.",
        refresh_failed="Could not refresh data.",
    )
    return render_template(
        'op_ocpi_sessions.html',
        sessions=sessions,
        session_stats=session_stats,
        cdr_entries=cdr_entries,
        cdr_stats=cdr_stats,
        translations=translations,
        import_url=url_for('api_import_ocpi', module='cdrs'),
        validate_url=url_for('api_validate_ocpi', module='cdrs'),
        export_links={
            "json": url_for('api_export_ocpi', module='cdrs', format='json'),
            "csv": url_for('api_export_ocpi', module='cdrs', format='csv'),
        },
        version_options=OCPI_VERSION_OPTIONS,
        sandbox_url=url_for('api_ocpi_sandbox_samples'),
        aside='ocpi_sessions',
    )


@app.route('/api/op_session_status', methods=['GET'])
def api_session_status():
    try:
        limit = int(request.args.get("limit", 200))
    except (TypeError, ValueError):
        limit = 200
    limit = max(1, min(limit, 1000))
    sessions, stats = _load_live_sessions(limit=limit)
    return jsonify({"sessions": sessions, "stats": stats})


@app.route('/api/op_cdr_queue', methods=['GET'])
def api_cdr_queue():
    try:
        limit = int(request.args.get("limit", 200))
    except (TypeError, ValueError):
        limit = 200
    limit = max(1, min(limit, 1000))
    sessions, _ = _load_live_sessions(limit=limit * 2)
    session_map = _session_index(sessions)
    cdr_entries, cdr_stats = _load_cdr_queue(session_index=session_map, limit=limit)
    return jsonify({"cdrs": cdr_entries, "stats": cdr_stats})


@app.route('/api/op_cdr_queue/<int:export_id>/replay', methods=['POST'])
def api_replay_cdr(export_id: int):
    sessions, _ = _load_live_sessions(limit=400)
    session_map = _session_index(sessions)
    ok, result = _replay_cdr_export(export_id, session_index=session_map)
    if not ok:
        return jsonify({"error": str(result)}), 400
    cdr_entry = result if isinstance(result, Mapping) else {"id": export_id}
    _, stats = _load_cdr_queue(session_index=session_map, limit=200)
    return jsonify({"cdr": cdr_entry, "stats": stats})


@app.route('/op_ocpi', methods=['GET', 'POST'])
def ocpi_config():
    msg = None
    ensure_ocpi_backend_tables()
    if request.method == 'POST':
        backend_action = request.form.get('backend_action')
        if backend_action == 'exchange':
            backend_id_raw = request.form.get('backend_id')
            try:
                backend_id = int(backend_id_raw) if backend_id_raw else None
            except (TypeError, ValueError):
                backend_id = None
            if backend_id is None:
                msg = translate_text('Missing backend id for credentials exchange.')
            else:
                success, status_msg = _perform_credentials_exchange(backend_id)
                msg = status_msg
                if success:
                    msg = f"{translate_text('Credentials updated for backend')} #{backend_id}: {status_msg}"
        elif backend_action:
            conn = get_db_conn()
            try:
                with conn.cursor() as cur:
                    backend_id_raw = request.form.get('backend_id')
                    try:
                        backend_id = int(backend_id_raw) if backend_id_raw else None
                    except (TypeError, ValueError):
                        backend_id = None
                    name = (request.form.get('backend_name') or '').strip()
                    url = (request.form.get('backend_url') or '').strip()
                    versions_url = (request.form.get('backend_versions_url') or '').strip() or None
                    token = (request.form.get('backend_token') or '').strip()
                    credentials_token = (request.form.get('backend_credentials_token') or '').strip()
                    modules = _serialize_backend_modules(request.form)
                    enabled = 1 if request.form.get('backend_enabled') == '1' else 0

                    if backend_action == 'add' and name and url:
                        cur.execute(
                            """
                            INSERT INTO op_ocpi_backends (name, url, remote_versions_url, token, credentials_token, modules, enabled)
                            VALUES (%s, %s, %s, %s, %s, %s, %s)
                            """,
                            (name, url, versions_url, token, credentials_token, modules, enabled),
                        )
                        msg = translate_text('Backend added')
                    elif backend_action == 'update' and backend_id:
                        cur.execute(
                            """
                            UPDATE op_ocpi_backends
                            SET name=%s,
                                url=%s,
                                remote_versions_url=%s,
                                token=%s,
                                credentials_token=COALESCE(NULLIF(%s, ''), credentials_token),
                                modules=%s,
                                enabled=%s
                            WHERE backend_id=%s
                            """,
                            (name, url, versions_url, token, credentials_token, modules, enabled, backend_id),
                        )
                        msg = translate_text('Backend updated')
                    elif backend_action == 'delete' and backend_id:
                        cur.execute(
                            "DELETE FROM op_ocpi_backends WHERE backend_id=%s",
                            (backend_id,),
                        )
                        msg = translate_text('Backend deleted')
                conn.commit()
            finally:
                conn.close()
        else:
            set_config_value(
                'ocpi_backend_enabled',
                '1' if request.form.get('ocpi_enabled') == 'on' else '0',
            )
            set_config_value(
                'ocpi_backend_url', request.form.get('ocpi_url', ''),
            )
            set_config_value(
                'ocpi_backend_token', request.form.get('ocpi_token', ''),
            )
            msg = translate_text('Configuration saved.')

    ocpi_enabled_value = ensure_config_default('ocpi_backend_enabled', '0')
    ocpi_enabled = ocpi_enabled_value == '1'
    ocpi_url = get_config_value('ocpi_backend_url') or ''
    ocpi_token = get_config_value('ocpi_backend_token') or ''

    conn = get_db_conn()
    ocpi_backends: list[dict] = []
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT backend_id, name, url, remote_versions_url, peer_versions_url, token, peer_token, credentials_token, modules, enabled, last_credentials_status, last_credentials_at
                FROM op_ocpi_backends
                ORDER BY name
                """
            )
            ocpi_backends = cur.fetchall()
    except Exception:
        app.logger.warning("Failed to load OCPI backends; continuing with empty list", exc_info=True)
    finally:
        conn.close()

    feed_stats = _load_ocpi_feed_stats()
    command_stats = _load_ocpi_command_stats()
    for backend in ocpi_backends:
        modules_raw = backend.get('modules') or ''
        backend['modules_list'] = {
            item.strip().lower()
            for item in modules_raw.replace(';', ',').split(',')
            if item.strip()
        }
        backend['feed_stats'] = feed_stats.get(backend.get('backend_id')) or {}
        backend['command_stats'] = command_stats.get(backend.get('backend_id')) or {}

    return render_template(
        'op_ocpi.html',
        message=msg,
        ocpi_enabled=ocpi_enabled,
        ocpi_url=ocpi_url,
        ocpi_token=ocpi_token,
        ocpi_backends=ocpi_backends,
        available_modules=OCPI_AVAILABLE_MODULES,
        aside='ocpi_backends',
    )


@app.route('/op_ocpi_handshakes', methods=['GET', 'POST'])
def ocpi_handshakes_view():
    ensure_ocpi_backend_tables()
    message = None
    errors: list[str] = []
    if request.method == 'POST':
        backend_id_raw = request.form.get('backend_id')
        try:
            backend_id = int(backend_id_raw) if backend_id_raw else None
        except (TypeError, ValueError):
            backend_id = None
        if backend_id is None:
            errors.append(translate_text('Missing backend id for credentials exchange.'))
        else:
            success, status_msg = _perform_credentials_exchange(backend_id)
            message = status_msg
            if success:
                message = f"{translate_text('Credentials updated for backend')} #{backend_id}: {status_msg}"
            _record_admin_audit(
                "credentials_exchange",
                "ocpi_backend",
                str(backend_id),
                {"success": success, "status": status_msg},
            )

    handshakes = _load_handshake_statuses()
    open_handshakes = [row for row in handshakes if row.get('state') == 'open']
    closed_handshakes = [row for row in handshakes if row.get('state') == 'closed']

    conn = get_db_conn()
    backends: list[dict] = []
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT backend_id, name FROM op_ocpi_backends ORDER BY name")
            backends = cur.fetchall()
    finally:
        conn.close()

    return render_template(
        'op_ocpi_handshakes.html',
        open_handshakes=open_handshakes,
        closed_handshakes=closed_handshakes,
        handshakes=handshakes,
        backends=backends,
        errors=errors,
        message=message,
        aside='ocpi_handshakes',
    )


@app.route('/api/op_ocpi_handshakes', methods=['GET'])
def api_ocpi_handshakes():
    ensure_ocpi_backend_tables()
    backend_id_raw = request.args.get('backend_id')
    try:
        backend_id = int(backend_id_raw) if backend_id_raw else None
    except (TypeError, ValueError):
        backend_id = None
    rows = _load_handshake_statuses(backend_id=backend_id)
    return jsonify({'handshakes': rows})


@app.route('/op_command_queue', methods=['GET', 'POST'])
def ocpi_command_queue_view():
    ensure_ocpi_backend_tables()
    success_message = None
    error_message = None
    if request.method == 'POST':
        command_id = (request.form.get('command_id') or '').strip()
        action = request.form.get('action')
        if not command_id:
            error_message = translate_text('Command ID is required.')
        elif action not in {'retry', 'cancel'}:
            error_message = translate_text('Unknown action.')
        else:
            ok, detail = _trigger_command_queue_action(command_id, action)
            if ok:
                success_message = translate_text('Command retriggered.') if action == 'retry' else translate_text('Command cancelled.')
            else:
                detail_text = detail or translate_text('Unknown error')
                error_message = translate_text('Command action failed: {detail}', detail=detail_text)

    entries = _load_command_queue()
    return render_template(
        'op_command_queue.html',
        entries=entries,
        success_message=success_message,
        error_message=error_message,
        status_labels=COMMAND_QUEUE_STATUS_LABELS,
        type_labels=COMMAND_QUEUE_TYPE_LABELS,
        aside='ocpi_command_queue',
    )


@app.route('/op_ocpi_settings', methods=['GET', 'POST'])
def ocpi_settings():
    global _alert_notifier
    message = None
    error = None

    current_config = _load_config_file()
    ocpi_cfg = dict(current_config.get("ocpi_api") or {})
    scheduler_cfg = dict(current_config.get("scheduler") or {})
    active_base_url = _ocpi_env_value(ocpi_cfg, "base_url") or ""

    environment = _ocpi_active_environment(ocpi_cfg)
    base_url_prod = ocpi_cfg.get("base_url_prod") or ""
    base_url_sandbox = ocpi_cfg.get("base_url_sandbox") or ""
    if not base_url_prod and environment == "prod":
        base_url_prod = ocpi_cfg.get("base_url") or ""
    if not base_url_sandbox and environment == "sandbox":
        base_url_sandbox = ocpi_cfg.get("base_url") or ""

    token_prod = ocpi_cfg.get("token_prod") or ocpi_cfg.get("token") or ""
    token_sandbox = ocpi_cfg.get("token_sandbox") or ""

    alerts_cfg = ocpi_cfg.get("alerts") if isinstance(ocpi_cfg.get("alerts"), Mapping) else {}
    webhook_url = (alerts_cfg or {}).get("webhook_url") or ""
    slack_webhook_url = (alerts_cfg or {}).get("slack_webhook_url") or ""
    existing_recipients_raw = (alerts_cfg or {}).get("email_recipients")
    if isinstance(existing_recipients_raw, str):
        existing_recipients = [
            part.strip() for part in existing_recipients_raw.split(",") if part.strip()
        ]
    elif isinstance(existing_recipients_raw, Iterable):
        existing_recipients = [
            str(entry).strip()
            for entry in existing_recipients_raw
            if str(entry).strip()
        ]
    else:
        existing_recipients = []
    email_recipients = ", ".join(existing_recipients)

    def _parse_int(value: str | None, default: int) -> int:
        try:
            return int(value) if value not in (None, "") else default
        except (TypeError, ValueError):
            return default

    def _parse_float(value: str | None, default: float | None) -> float | None:
        try:
            return float(value) if value not in (None, "") else default
        except (TypeError, ValueError):
            return default

    retry_interval = _parse_int(
        scheduler_cfg.get("retry_interval_seconds"), DEFAULT_OCPI_RETRY_INTERVAL
    )
    retry_batch_size = _parse_int(
        scheduler_cfg.get("retry_batch_size"), DEFAULT_OCPI_RETRY_BATCH_SIZE
    )
    retry_max_attempts = _parse_int(
        scheduler_cfg.get("retry_max_attempts"), DEFAULT_OCPI_RETRY_MAX_ATTEMPTS
    )
    module_error_threshold_pct = _parse_float(alerts_cfg.get("module_error_threshold_pct"), None)
    backend_error_threshold_pct = _parse_float(alerts_cfg.get("backend_error_threshold_pct"), None)
    webhook_down_minutes = _parse_int(alerts_cfg.get("webhook_down_minutes"), 0)
    token_expiry_warning_days = _parse_int(alerts_cfg.get("token_expiry_warning_days"), 0)
    token_expiry_date = (alerts_cfg.get("token_expiry_date") or "").strip() if isinstance(alerts_cfg.get("token_expiry_date"), str) else ""
    token_expiry_dates = alerts_cfg.get("token_expiry_dates") if isinstance(alerts_cfg.get("token_expiry_dates"), Mapping) else {}
    token_expiry_dates_json = json.dumps(token_expiry_dates, ensure_ascii=False, indent=2) if token_expiry_dates else ""
    quiet_hours_cfg = alerts_cfg.get("quiet_hours") if isinstance(alerts_cfg.get("quiet_hours"), Mapping) else {}
    quiet_hours_start = (quiet_hours_cfg.get("start") or "").strip() if quiet_hours_cfg else ""
    quiet_hours_end = (quiet_hours_cfg.get("end") or "").strip() if quiet_hours_cfg else ""
    quiet_hours_timezone = (quiet_hours_cfg.get("timezone") or "UTC").strip() if quiet_hours_cfg else "UTC"
    alert_cooldown_seconds = _parse_int(alerts_cfg.get("alert_cooldown_seconds"), alerts_cfg.get("cooldown_seconds", 300))

    if request.method == 'POST':
        environment = (request.form.get('ocpi_environment') or environment or "prod").lower()
        if environment not in {"prod", "sandbox"}:
            environment = "prod"

        base_url_prod = (request.form.get('base_url_prod') or '').strip()
        base_url_sandbox = (request.form.get('base_url_sandbox') or '').strip()
        token_prod = (request.form.get('token_prod') or '').strip()
        token_sandbox = (request.form.get('token_sandbox') or '').strip()

        if 'generate_token_prod' in request.form:
            token_prod = secrets.token_hex(24)
        if 'generate_token_sandbox' in request.form:
            token_sandbox = secrets.token_hex(24)

        webhook_url = (request.form.get('webhook_url') or '').strip()
        slack_webhook_url = (request.form.get('slack_webhook_url') or '').strip()
        email_recipients_input = request.form.get('email_recipients') or ''
        parsed_recipients = [
            entry.strip()
            for entry in email_recipients_input.split(',')
            if entry and entry.strip()
        ]
        email_recipients = ", ".join(parsed_recipients)

        retry_interval = _parse_int(request.form.get('retry_interval'), retry_interval)
        retry_batch_size = _parse_int(request.form.get('retry_batch_size'), retry_batch_size)
        retry_max_attempts = _parse_int(request.form.get('retry_max_attempts'), retry_max_attempts)
        module_error_threshold_pct = _parse_float(request.form.get('module_error_threshold_pct'), module_error_threshold_pct)
        backend_error_threshold_pct = _parse_float(request.form.get('backend_error_threshold_pct'), backend_error_threshold_pct)
        webhook_down_minutes = _parse_int(request.form.get('webhook_down_minutes'), webhook_down_minutes)
        token_expiry_warning_days = _parse_int(request.form.get('token_expiry_warning_days'), token_expiry_warning_days)
        token_expiry_date = (request.form.get('token_expiry_date') or "").strip()
        token_expiry_dates_raw = request.form.get('token_expiry_dates') or ""
        quiet_hours_start = (request.form.get('quiet_hours_start') or quiet_hours_start).strip()
        quiet_hours_end = (request.form.get('quiet_hours_end') or quiet_hours_end).strip()
        quiet_hours_timezone = (request.form.get('quiet_hours_timezone') or quiet_hours_timezone).strip() or "UTC"
        alert_cooldown_seconds = _parse_int(request.form.get('alert_cooldown_seconds'), alert_cooldown_seconds)
        if token_expiry_dates_raw.strip():
            try:
                token_expiry_dates = json.loads(token_expiry_dates_raw)
                if not isinstance(token_expiry_dates, Mapping):
                    raise ValueError("Token expiry map must be an object/dictionary.")
                token_expiry_dates_json = json.dumps(token_expiry_dates, ensure_ascii=False, indent=2)
            except Exception as exc:
                error = translate_text("Invalid token expiry map: {detail}", detail=str(exc))
                token_expiry_dates = {}
        else:
            token_expiry_dates = {}
            token_expiry_dates_json = ""

        if not error:
            try:
                new_config = _load_config_file()
                updated_ocpi_cfg = dict(new_config.get("ocpi_api") or {})
                updated_ocpi_cfg.update(
                    {
                        "environment": environment,
                        "base_url_prod": base_url_prod,
                        "base_url_sandbox": base_url_sandbox,
                        "token_prod": token_prod,
                        "token_sandbox": token_sandbox,
                    }
                )
                active_base_url = base_url_prod if environment == "prod" else base_url_sandbox
                if active_base_url:
                    updated_ocpi_cfg["base_url"] = active_base_url
                active_token = token_prod if environment == "prod" else token_sandbox
                if active_token:
                    updated_ocpi_cfg["token"] = active_token

                alert_block = dict(updated_ocpi_cfg.get("alerts") or {})
                alert_block["webhook_url"] = webhook_url or None
                alert_block["slack_webhook_url"] = slack_webhook_url or None
                alert_block["email_recipients"] = parsed_recipients
                alert_block["module_error_threshold_pct"] = module_error_threshold_pct
                alert_block["backend_error_threshold_pct"] = backend_error_threshold_pct
                alert_block["webhook_down_minutes"] = webhook_down_minutes or None
                alert_block["token_expiry_warning_days"] = token_expiry_warning_days or None
                alert_block["token_expiry_date"] = token_expiry_date or None
                alert_block["token_expiry_dates"] = token_expiry_dates or {}
                alert_block["alert_cooldown_seconds"] = alert_cooldown_seconds
                quiet_hours_block = {
                    "start": quiet_hours_start or None,
                    "end": quiet_hours_end or None,
                    "timezone": quiet_hours_timezone or "UTC",
                }
                alert_block["quiet_hours"] = quiet_hours_block
                updated_ocpi_cfg["alerts"] = alert_block
                new_config["ocpi_api"] = updated_ocpi_cfg

                updated_scheduler = dict(new_config.get("scheduler") or {})
                updated_scheduler["retry_interval_seconds"] = retry_interval
                updated_scheduler["retry_batch_size"] = retry_batch_size
                updated_scheduler["retry_max_attempts"] = retry_max_attempts
                new_config["scheduler"] = updated_scheduler

                _write_config_file(new_config)
                global _config
                _config = new_config
                _alert_notifier = _build_alert_notifier(_config)
                current_config = new_config
                ocpi_cfg = updated_ocpi_cfg
                scheduler_cfg = updated_scheduler
                message = translate_text('Configuration saved.')
                active_base_url = updated_ocpi_cfg.get("base_url") or active_base_url
                _record_admin_audit(
                    "ocpi_credentials_updated",
                    "credentials",
                    environment,
                    {
                        "base_url": active_base_url,
                        "alerts_configured": bool(webhook_url or slack_webhook_url or parsed_recipients),
                        "retry_interval": retry_interval,
                        "retry_batch_size": retry_batch_size,
                        "retry_max_attempts": retry_max_attempts,
                    },
                )
            except Exception as exc:
                error = str(exc)

    active_base_url = _ocpi_env_value(ocpi_cfg, "base_url") or active_base_url

    return render_template(
        'op_ocpi_settings.html',
        message=message,
        error=error,
        environment=environment,
        base_url_prod=base_url_prod,
        base_url_sandbox=base_url_sandbox,
        active_base_url=active_base_url,
        token_prod=token_prod,
        token_sandbox=token_sandbox,
        webhook_url=webhook_url,
        slack_webhook_url=slack_webhook_url,
        email_recipients=email_recipients,
        retry_interval=retry_interval,
        retry_batch_size=retry_batch_size,
        retry_max_attempts=retry_max_attempts,
        module_error_threshold_pct=module_error_threshold_pct,
        backend_error_threshold_pct=backend_error_threshold_pct,
        webhook_down_minutes=webhook_down_minutes,
        token_expiry_warning_days=token_expiry_warning_days,
        token_expiry_date=token_expiry_date,
        token_expiry_dates=token_expiry_dates_json,
        quiet_hours_start=quiet_hours_start,
        quiet_hours_end=quiet_hours_end,
        quiet_hours_timezone=quiet_hours_timezone,
        alert_cooldown_seconds=alert_cooldown_seconds,
        aside='ocpi_settings',
    )


def _parse_tariff_form_payload(form: Mapping[str, Any]) -> dict[str, Any]:
    payload: dict[str, Any] = {
        "id": (form.get("tariff_id") or form.get("id") or "").strip(),
        "name_en": (form.get("name_en") or "").strip(),
        "name_de": (form.get("name_de") or "").strip(),
        "currency": (form.get("currency") or "").strip(),
        "valid_from": form.get("valid_from") or None,
        "valid_until": form.get("valid_until") or None,
        "tax_included": form.get("tax_included") == "on",
    }
    payload["name"] = payload.get("name_en") or payload.get("name_de")
    elements_raw = form.get("elements") or form.get("price_components") or "[]"
    payload["elements"] = elements_raw

    backend_ids = form.getlist("surcharge_backend_id")
    percents = form.getlist("surcharge_percent")
    fixed_values = form.getlist("surcharge_fixed")
    surcharges: list[dict[str, Any]] = []
    for idx, backend_raw in enumerate(backend_ids):
        backend_value = (backend_raw or "").strip()
        percent_raw = percents[idx] if idx < len(percents) else None
        fixed_raw = fixed_values[idx] if idx < len(fixed_values) else None
        if not backend_value and not percent_raw and not fixed_raw:
            continue
        surcharges.append(
            {
                "backend_id": backend_value,
                "percent": percent_raw,
                "fixed": fixed_raw,
            }
        )
    if surcharges:
        payload["emsp_surcharges"] = surcharges
    return payload


@app.route('/op_tariffs', methods=['GET', 'POST'])
def manage_tariffs():
    service = get_tariff_service()
    success_messages: list[str] = []
    error_messages: list[str] = []
    selected_tariff_id = request.args.get('tariff_id') or request.form.get('tariff_id')

    if request.method == 'POST':
        action = (request.form.get('action') or 'save').lower()
        if action in {'save', 'create', 'update'}:
            try:
                payload = _parse_tariff_form_payload(request.form)
                saved = service.upsert_tariff(payload)
                selected_tariff_id = saved.get("id") or selected_tariff_id
                success_messages.append(translate_text('Tariff saved.'))
                _record_admin_audit(
                    "tariff_saved",
                    "tariff",
                    saved.get("id"),
                    {"action": action, "payload": payload},
                )
            except ValueError as exc:
                error_messages.append(translate_text(str(exc)))
        elif action == 'delete':
            tariff_id = request.form.get('tariff_id') or ''
            try:
                removed = service.delete_tariff(tariff_id)
                if removed:
                    success_messages.append(translate_text('Tariff deleted.'))
                    if selected_tariff_id == tariff_id:
                        selected_tariff_id = None
                    _record_admin_audit(
                        "tariff_deleted",
                        "tariff",
                        tariff_id,
                        {"removed": True},
                    )
                else:
                    error_messages.append(translate_text('Tariff not found.'))
            except ValueError as exc:
                error_messages.append(translate_text(str(exc)))
        elif action == 'assign':
            tariff_id = request.form.get('tariff_id') or selected_tariff_id
            location_id = request.form.get('assignment_location') or ''
            evse_uid = request.form.get('assignment_evse') or None
            try:
                service.assign_tariff(
                    tariff_id,
                    location_id=location_id,
                    evse_uid=evse_uid or None,
                )
                success_messages.append(translate_text('Tariff assignment saved.'))
                selected_tariff_id = tariff_id
                _record_admin_audit(
                    "tariff_assigned",
                    "tariff_assignment",
                    f"{tariff_id}:{location_id}:{evse_uid or '*'}",
                    {"tariff_id": tariff_id, "location_id": location_id, "evse_uid": evse_uid},
                )
            except ValueError as exc:
                error_messages.append(translate_text(str(exc)))
        elif action == 'remove_assignment':
            tariff_id = request.form.get('tariff_id') or selected_tariff_id
            location_id = request.form.get('assignment_location') or ''
            evse_uid = request.form.get('assignment_evse') or None
            try:
                service.remove_assignment(
                    tariff_id,
                    location_id=location_id,
                    evse_uid=evse_uid or None,
                )
                success_messages.append(translate_text('Tariff assignment removed.'))
                _record_admin_audit(
                    "tariff_assignment_removed",
                    "tariff_assignment",
                    f"{tariff_id}:{location_id}:{evse_uid or '*'}",
                    {"tariff_id": tariff_id, "location_id": location_id, "evse_uid": evse_uid},
                )
            except ValueError as exc:
                error_messages.append(translate_text(str(exc)))

    tariffs, _ = service.list_tariffs(limit=500)
    tariffs = sorted(tariffs, key=lambda t: str(t.get("id") or ""))
    assignments_by_tariff = _group_tariff_assignments(service.list_assignments())
    selected_tariff = service.get_tariff(selected_tariff_id) if selected_tariff_id else None
    surcharge_rows = []
    if selected_tariff:
        surcharge_rows.extend(selected_tariff.get("emsp_surcharges") or [])
    surcharge_rows.append({})

    return render_template(
        'op_tariffs.html',
        tariffs=tariffs,
        selected_tariff=selected_tariff,
        selected_tariff_id=selected_tariff_id,
        assignments_by_tariff=assignments_by_tariff,
        locations=_load_tariff_locations(),
        backends=_load_tariff_backends(),
        surcharge_rows=surcharge_rows,
        format_dt_local=_format_datetime_local,
        success_messages=success_messages,
        error_messages=error_messages,
        import_url=url_for('api_import_ocpi', module='tariffs'),
        validate_url=url_for('api_validate_ocpi', module='tariffs'),
        export_links={
            "json": url_for('api_export_ocpi', module='tariffs', format='json'),
            "csv": url_for('api_export_ocpi', module='tariffs', format='csv'),
        },
        version_options=OCPI_VERSION_OPTIONS,
        sandbox_url=url_for('api_ocpi_sandbox_samples'),
        aside='ocpi_tariffs',
    )


@app.route('/op_ocpi_partners', methods=['GET', 'POST'])
def ocpi_partners():
    message = None
    error = None
    ensure_emsp_token_table()
    token_service = get_token_service()

    if request.method == 'POST':
        action = request.form.get('action') or 'add'
        uid = (request.form.get('uid') or '').strip()
        if not uid:
            error = translate_text('UID is required.')
        else:
            status = (request.form.get('status') or 'valid').strip().lower()
            if action == 'block':
                status = 'blocked'
            elif action == 'unblock':
                status = 'valid'
            existing_token = token_service.get_token(uid) if action in {'block', 'unblock'} else None
            payload = {
                "uid": uid,
                "authId": (request.form.get('auth_id') or '').strip()
                or (existing_token.get('auth_id') if existing_token else None),
                "issuer": (request.form.get('issuer') or '').strip()
                or (existing_token.get('issuer') if existing_token else None),
                "whitelist": (request.form.get('whitelist') or '').strip().upper()
                or (existing_token.get('whitelist') if existing_token else None),
                "localRfid": (request.form.get('local_rfid') or '').strip().upper()
                or (existing_token.get('local_rfid') if existing_token else None),
                "status": status,
                "source": (request.form.get('source') or 'emsp').strip().lower(),
                "validUntil": request.form.get('valid_until')
                or (existing_token.get('valid_until') if existing_token else None),
            }
            try:
                if action == 'delete':
                    if token_service.delete_token(uid):
                        message = translate_text('Token deleted')
                        _record_admin_audit(
                            "token_deleted",
                            "token",
                            uid,
                            {"action": action},
                        )
                else:
                    token_service.upsert_tokens([payload])
                    if status == 'blocked':
                        message = translate_text('Token blocked')
                        _record_admin_audit(
                            "token_blocked",
                            "token",
                            uid,
                            {"issuer": payload.get("issuer"), "whitelist": payload.get("whitelist")},
                        )
                    elif status == 'valid':
                        message = translate_text('Token unblocked')
                        _record_admin_audit(
                            "token_unblocked",
                            "token",
                            uid,
                            {"issuer": payload.get("issuer"), "whitelist": payload.get("whitelist")},
                        )
                    else:
                        message = translate_text('Token saved')
                        _record_admin_audit(
                            "token_saved",
                            "token",
                            uid,
                            {"issuer": payload.get("issuer"), "whitelist": payload.get("whitelist")},
                        )
            except Exception as exc:
                error = str(exc)

    tokens, _ = token_service.list_tokens(limit=500)
    cache_stats = token_service.cache_stats()

    return render_template(
        'op_ocpi_partners.html',
        tokens=tokens,
        cache_stats=cache_stats,
        message=message,
        error=error,
        import_url=url_for('api_import_ocpi', module='tokens'),
        validate_url=url_for('api_validate_ocpi', module='tokens'),
        export_links={
            "json": url_for('api_export_ocpi', module='tokens', format='json'),
            "csv": url_for('api_export_ocpi', module='tokens', format='csv'),
        },
        version_options=OCPI_VERSION_OPTIONS,
        sandbox_url=url_for('api_ocpi_sandbox_samples'),
        aside='ocpi_partners',
    )


def _parse_external_api_log_filters(args: Mapping[str, str]) -> dict[str, object]:
    partner = (args.get('partner') or '').strip()
    module = (args.get('module') or '').strip()
    status_raw = (args.get('status') or args.get('response_code') or '').strip()
    try:
        status = int(status_raw) if status_raw else None
    except ValueError:
        status = None
    return {
        "partner": partner or None,
        "module": module or None,
        "status": status,
    }


def _sanitize_page_params(page_raw: str | None, page_size_raw: str | None, *, default_page: int = 1, default_size: int = 50) -> tuple[int, int]:
    try:
        page = int(page_raw) if page_raw not in (None, "") else default_page
    except ValueError:
        page = default_page
    try:
        size = int(page_size_raw) if page_size_raw not in (None, "") else default_size
    except ValueError:
        size = default_size
    page = max(1, page)
    size = max(1, min(size, 500))
    return page, size


def _load_external_api_logs(filters: Mapping[str, object], *, limit: int, offset: int) -> tuple[list[dict], int]:
    ensure_external_api_logging_table()
    conn = get_db_conn()
    rows: list[dict] = []
    total = 0
    where: list[str] = []
    params: list[object] = []
    if filters.get("partner"):
        where.append("partner_id = %s")
        params.append(filters["partner"])
    if filters.get("module"):
        where.append("module = %s")
        params.append(filters["module"])
    if filters.get("status") is not None:
        where.append("response_code = %s")
        params.append(filters["status"])
    where_clause = f"WHERE {' AND '.join(where)}" if where else ""
    offset = max(0, int(offset))
    limit = max(1, min(int(limit), 500))
    try:
        with conn.cursor() as cur:
            cur.execute(
                f"SELECT COUNT(*) AS cnt FROM op_broker_api_logging {where_clause}",
                params,
            )
            count_row = cur.fetchone() or {}
            total = int(count_row.get("cnt") or 0)
            cur.execute(
                f"""
                SELECT
                    created_at,
                    chargepoint_id,
                    partner_id,
                    module,
                    endpoint,
                    request_payload,
                    response_code,
                    response_body
                FROM op_broker_api_logging
                {where_clause}
                ORDER BY created_at DESC
                LIMIT %s OFFSET %s
                """,
                params + [limit, offset],
            )
            rows = cur.fetchall()
    finally:
        conn.close()
    return rows, total


def _decode_json(raw_value: object) -> object:
    if not raw_value:
        return ""
    if isinstance(raw_value, (dict, list)):
        return raw_value
    try:
        return json.loads(raw_value)
    except Exception:
        return raw_value


def _format_external_api_entry(row: Mapping[str, object], *, for_display: bool = True) -> dict[str, object]:
    payload = row.get('request_payload')
    formatted_payload = payload or ''
    if for_display:
        if payload:
            try:
                formatted_payload = json.dumps(json.loads(payload), indent=2, ensure_ascii=False)
            except Exception:
                formatted_payload = payload
    else:
        formatted_payload = _decode_json(payload)

    response_body = row.get('response_body')
    formatted_response = response_body or ''
    if for_display:
        if response_body:
            try:
                formatted_response = json.dumps(
                    json.loads(response_body), indent=2, ensure_ascii=False
                )
            except Exception:
                formatted_response = response_body
    else:
        formatted_response = _decode_json(response_body)

    return {
        'created_at': row.get('created_at'),
        'chargepoint_id': row.get('chargepoint_id'),
        'partner_id': row.get('partner_id'),
        'module': row.get('module'),
        'endpoint': row.get('endpoint'),
        'request_payload': formatted_payload,
        'response_code': row.get('response_code'),
        'response_body': formatted_response,
    }


@app.route('/op_external_api/logs', methods=['GET'])
def external_api_logs():
    filters = _parse_external_api_log_filters(request.args)
    page, page_size = _sanitize_page_params(
        request.args.get("page"), request.args.get("page_size"), default_size=50
    )
    offset = (page - 1) * page_size
    rows, total = _load_external_api_logs(filters, limit=page_size, offset=offset)
    entries = [_format_external_api_entry(row, for_display=True) for row in rows]
    total_pages = max(1, math.ceil(total / page_size)) if total else 1
    pagination = {
        "page": page,
        "page_size": page_size,
        "total": total,
        "pages": total_pages,
    }
    selected_filters = {
        "partner": filters.get("partner") or "",
        "module": filters.get("module") or "",
        "status": filters.get("status") or "",
    }

    return render_template(
        'op_external_api_logs.html',
        entries=entries,
        pagination=pagination,
        selected_filters=selected_filters,
        aside='settingsMenu',
    )


@app.route('/api/op_external_api/logs', methods=['GET'])
def api_external_api_logs():
    filters = _parse_external_api_log_filters(request.args)
    page, page_size = _sanitize_page_params(
        request.args.get("page"), request.args.get("page_size"), default_size=50
    )
    offset = (page - 1) * page_size
    rows, total = _load_external_api_logs(filters, limit=page_size, offset=offset)
    entries = [_format_external_api_entry(row, for_display=False) for row in rows]
    return jsonify(
        {
            "data": entries,
            "filters": filters,
            "pagination": {
                "page": page,
                "page_size": page_size,
                "total": total,
                "pages": max(1, math.ceil(total / page_size)) if total else 1,
            },
        }
    )

@app.route('/op_delete_redirect', methods=['POST'])
def delete_redirect():
    """
    Löscht einen Eintrag aus op_redirects.
    Erwartet JSON { "source_url": "<der Eintrag>" }.
    """
    data = request.get_json() or {}
    source = data.get('source_url')
    if not source:
        return jsonify({'error': 'source_url fehlt'}), 400

    conn = get_db_conn()
    try:
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            cur.execute("DELETE FROM op_redirects WHERE source_url=%s LIMIT 1", (source,))
            conn.commit()
    finally:
        conn.close()

    refresh_station_list_safely()

    return jsonify({'status': 'ok', 'source_url': source}), 200


@app.route('/api/op_redirects', methods=['POST'])
def api_create_redirect():
    """Create a new entry in op_redirects.

    Expects JSON with required fields ``source_url``, ``ws_url`` and
    ``activity``. Optional fields are ``mqtt_enabled`` (default ``0``),
    ``ping_interval`` (default ``60``), ``strict_availability`` (default ``0``),
    ``charging_analytics`` (default ``0``) und ``ocpp_subprotocol``.
    Access is protected via an
    ``Authorization`` header using a Bearer token that must match the
    dashboard token stored in the configuration table.
    """

    auth_header = request.headers.get('Authorization', '')
    token = get_token()
    if not auth_header.startswith('Bearer ') or auth_header.split(' ', 1)[1] != token:
        return jsonify({'error': 'unauthorized'}), 401

    data = request.get_json() or {}
    missing = [f for f in ('source_url', 'ws_url', 'activity') if f not in data]
    if missing:
        return jsonify({'error': f'missing fields: {", ".join(missing)}'}), 400

    mqtt_enabled = int(data.get('mqtt_enabled', 0))
    ping_interval = int(data.get('ping_interval', 60))
    strict_availability = int(data.get('strict_availability', 0))
    charging_analytics = int(data.get('charging_analytics', 0))
    ocpp_subprotocol = data.get('ocpp_subprotocol')
    if isinstance(ocpp_subprotocol, str):
        ocpp_subprotocol = ocpp_subprotocol.strip() or None

    conn = get_db_conn()
    try:
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            cur.execute(
                "SELECT 1 FROM op_redirects WHERE source_url=%s",
                (data['source_url'],),
            )
            if cur.fetchone():
                return jsonify({'error': 'redirect exists'}), 409
            try:
                cur.execute(
                    """
                    INSERT INTO op_redirects
                        (source_url, ws_url, activity, mqtt_enabled, ping_interval, strict_availability, charging_analytics, ocpp_subprotocol)
                    VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
                    """,
                    (
                        data['source_url'],
                        data['ws_url'],
                        data['activity'],
                        mqtt_enabled,
                        ping_interval,
                        strict_availability,
                        charging_analytics,
                        ocpp_subprotocol,
                    ),
                )
            except Exception:
                # Fallback if the column is named ping_enabled in the DB
                try:
                    cur.execute(
                        """
                        INSERT INTO op_redirects
                            (source_url, ws_url, activity, mqtt_enabled, ping_enabled, strict_availability, charging_analytics, ocpp_subprotocol)
                        VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
                        """,
                        (
                            data['source_url'],
                            data['ws_url'],
                            data['activity'],
                            mqtt_enabled,
                            ping_interval,
                            strict_availability,
                            charging_analytics,
                            ocpp_subprotocol,
                        ),
                    )
                except Exception:
                    cur.execute(
                        """
                        INSERT INTO op_redirects
                            (source_url, ws_url, activity, mqtt_enabled, ping_enabled)
                        VALUES (%s, %s, %s, %s, %s)
                        """,
                        (
                            data['source_url'],
                            data['ws_url'],
                            data['activity'],
                            mqtt_enabled,
                            ping_interval,
                        ),
                    )

        if ocpp_subprotocol is not None:
            with conn.cursor() as cur:
                cur.execute(
                    "UPDATE op_redirects SET ocpp_subprotocol=%s WHERE source_url=%s",
                    (ocpp_subprotocol, data['source_url']),
                )

        conn.commit()
    finally:
        conn.close()

        refresh_station_list_safely()

    return jsonify({'status': 'ok', 'source_url': data['source_url']}), 201


def _coerce_bool_flag(value, field_name):
    """Convert truthy/falsey payload values into database-friendly ints."""

    try:
        if isinstance(value, str):
            normalized = value.strip().lower()
            if normalized in {'1', 'true', 'yes', 'on'}:
                return 1
            if normalized in {'0', 'false', 'no', 'off', ''}:
                return 0
            raise ValueError
        return int(bool(value))
    except (TypeError, ValueError):
        raise ValueError(f'invalid value for {field_name}') from None


def _prepare_op_redirect_updates(data):
    """Normalize and validate update payload for ``op_redirects``.

    Returns a tuple ``(assignments, values, error_response)``. If
    ``error_response`` is not ``None``, it contains a Flask response
    that should be returned to the caller.
    """

    fields = []
    values = []
    bool_like_fields = {
        'mqtt_enabled',
        'strict_availability',
        'charging_analytics',
    }
    nullable_fields = {
        'backend_basic_user',
        'backend_basic_password',
        'location_name',
        'location_link',
        'webui_remote_access_url',
        'load_management_remote_access_url',
        'ocpp_subprotocol',
        'comment',
    }

    for key in (
        'ws_url',
        'activity',
        'mqtt_enabled',
        'ping_interval',
        'strict_availability',
        'charging_analytics',
        'backend_basic_user',
        'backend_basic_password',
        'location_name',
        'location_link',
        'webui_remote_access_url',
        'load_management_remote_access_url',
        'ocpp_subprotocol',
        'comment',
    ):
        if key not in data:
            continue

        val = data[key]
        if key in {'ocpp_subprotocol', 'comment'} and isinstance(val, str):
            val = val.strip()
            if key == 'comment' and len(val) > 200:
                val = val[:200]
        if key in bool_like_fields:
            try:
                val = _coerce_bool_flag(val, key)
            except ValueError as exc:  # pragma: no cover - defensive path
                return None, None, (jsonify({'error': str(exc)}), 400)
        elif key == 'ping_interval':
            try:
                val = int(val)
            except (TypeError, ValueError):
                return None, None, (jsonify({'error': 'ping_interval must be an integer'}), 400)

        if key in nullable_fields and (val == '' or val is None):
            val = None

        fields.append(f"{key}=%s")
        values.append(val)

    if not fields:
        return None, None, (jsonify({'error': 'no fields to update'}), 400)

    assignments = list(zip(fields, values))
    return assignments, values, None


@app.route('/api/op_redirects/upsert', methods=['POST'])
def api_upsert_redirect():
    """Create or update an ``op_redirects`` entry in one request.

    Expects JSON with ``source_url``. If the entry does not exist, the
    fields ``ws_url`` and ``activity`` are required for creation.
    Optional fields include ``mqtt_enabled``, ``ping_interval``,
    ``strict_availability``, ``charging_analytics``,
    ``backend_basic_user``, ``backend_basic_password``,
    ``location_name``, ``location_link``, ``webui_remote_access_url``,
    ``load_management_remote_access_url``, ``ocpp_subprotocol`` and
    ``comment``. Existing entries are updated with the provided fields.
    """

    auth_header = request.headers.get('Authorization', '')
    token = get_token()
    if not auth_header.startswith('Bearer ') or auth_header.split(' ', 1)[1] != token:
        return jsonify({'error': 'unauthorized'}), 401

    data = request.get_json() or {}
    source = data.get('source_url')
    if not source:
        return jsonify({'error': 'source_url missing'}), 400

    conn = get_db_conn()
    try:
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            cur.execute(
                "SELECT 1 FROM op_redirects WHERE source_url=%s",
                (source,),
            )
            exists = cur.fetchone() is not None

        if exists:
            assignments, values, error_response = _prepare_op_redirect_updates(data)
            if error_response:
                return error_response

            values.append(source)
            with conn.cursor() as cur:
                try:
                    cur.execute(
                        f"UPDATE op_redirects SET {', '.join(field for field, _ in assignments)} WHERE source_url=%s",
                        [val for _, val in assignments] + [source],
                    )
                except Exception:
                    replaced = [
                        (field.replace('ping_interval', 'ping_enabled'), val)
                        for field, val in assignments
                    ]
                    try:
                        cur.execute(
                            f"UPDATE op_redirects SET {', '.join(field for field, _ in replaced)} WHERE source_url=%s",
                            [val for _, val in replaced] + [source],
                        )
                    except Exception:
                        filtered = [
                            item
                            for item in replaced
                            if 'backend_basic_user' not in item[0]
                            and 'backend_basic_password' not in item[0]
                            and 'location_name' not in item[0]
                            and 'location_link' not in item[0]
                            and 'webui_remote_access_url' not in item[0]
                            and 'load_management_remote_access_url' not in item[0]
                            and 'comment' not in item[0]
                            and 'strict_availability' not in item[0]
                            and 'charging_analytics' not in item[0]
                            and 'ocpp_subprotocol' not in item[0]
                        ]
                        if not filtered:
                            raise
                        cur.execute(
                            f"UPDATE op_redirects SET {', '.join(field for field, _ in filtered)} WHERE source_url=%s",
                            [val for _, val in filtered] + [source],
                        )
        else:
            missing = [f for f in ('ws_url', 'activity') if f not in data]
            if missing:
                return jsonify({'error': f'missing fields: {", ".join(missing)}'}), 400

            mqtt_enabled = data.get('mqtt_enabled', 0)
            strict_availability = data.get('strict_availability', 0)
            charging_analytics = data.get('charging_analytics', 0)
            ping_interval = data.get('ping_interval', 60)
            ocpp_subprotocol = data.get('ocpp_subprotocol')

            try:
                mqtt_enabled = _coerce_bool_flag(mqtt_enabled, 'mqtt_enabled')
                strict_availability = _coerce_bool_flag(strict_availability, 'strict_availability')
                charging_analytics = _coerce_bool_flag(charging_analytics, 'charging_analytics')
            except ValueError as exc:
                return jsonify({'error': str(exc)}), 400

            try:
                ping_interval = int(ping_interval)
            except (TypeError, ValueError):
                return jsonify({'error': 'ping_interval must be an integer'}), 400

            if isinstance(ocpp_subprotocol, str):
                ocpp_subprotocol = ocpp_subprotocol.strip() or None

            with conn.cursor() as cur:
                try:
                    cur.execute(
                        """
                        INSERT INTO op_redirects
                            (source_url, ws_url, activity, mqtt_enabled, ping_interval, strict_availability, charging_analytics, ocpp_subprotocol)
                        VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
                        """,
                        (
                            data['source_url'],
                            data['ws_url'],
                            data['activity'],
                            mqtt_enabled,
                            ping_interval,
                            strict_availability,
                            charging_analytics,
                            ocpp_subprotocol,
                        ),
                    )
                except Exception:
                    try:
                        cur.execute(
                            """
                            INSERT INTO op_redirects
                                (source_url, ws_url, activity, mqtt_enabled, ping_enabled, strict_availability, charging_analytics, ocpp_subprotocol)
                            VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
                            """,
                            (
                                data['source_url'],
                                data['ws_url'],
                                data['activity'],
                                mqtt_enabled,
                                ping_interval,
                                strict_availability,
                                charging_analytics,
                                ocpp_subprotocol,
                            ),
                        )
                    except Exception:
                        cur.execute(
                            """
                            INSERT INTO op_redirects
                                (source_url, ws_url, activity, mqtt_enabled, ping_enabled)
                            VALUES (%s, %s, %s, %s, %s)
                            """,
                            (
                                data['source_url'],
                                data['ws_url'],
                                data['activity'],
                                mqtt_enabled,
                                ping_interval,
                            ),
                        )

        conn.commit()
    finally:
        conn.close()

    refresh_station_list_safely()

    status_code = 200 if exists else 201
    return jsonify({'status': 'ok', 'source_url': source, 'updated': exists}), status_code


@app.route('/api/op_redirects', methods=['GET'])
def api_get_redirect():
    """Return an op_redirects entry identified by ``source_url``."""

    auth_header = request.headers.get('Authorization', '')
    token = get_token()
    if not auth_header.startswith('Bearer ') or auth_header.split(' ', 1)[1] != token:
        return jsonify({'error': 'unauthorized'}), 401

    source = request.args.get('source_url')
    if not source:
        return jsonify({'error': 'source_url missing'}), 400

    conn = get_db_conn()
    try:
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            query_variants = [
                (
                    """
                    SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, ping_interval, strict_availability,
                           charging_analytics,
                           backend_basic_user, backend_basic_password,
                           location_name, location_link,
                           webui_remote_access_url, load_management_remote_access_url,
                           comment
                    FROM op_redirects WHERE source_url=%s
                    """,
                ),
                (
                    """
                    SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, ping_enabled AS ping_interval, strict_availability,
                           charging_analytics,
                           backend_basic_user, backend_basic_password,
                           location_name, location_link,
                           webui_remote_access_url, load_management_remote_access_url,
                           comment
                    FROM op_redirects WHERE source_url=%s
                    """,
                ),
                (
                    """
                    SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, ping_interval, strict_availability,
                           charging_analytics,
                           backend_basic_user, backend_basic_password,
                           location_name, location_link,
                           NULL AS webui_remote_access_url, NULL AS load_management_remote_access_url,
                           comment
                    FROM op_redirects WHERE source_url=%s
                    """,
                ),
                (
                    """
                    SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, ping_enabled AS ping_interval, strict_availability,
                           charging_analytics,
                           backend_basic_user, backend_basic_password,
                           location_name, location_link,
                           NULL AS webui_remote_access_url, NULL AS load_management_remote_access_url,
                           comment
                    FROM op_redirects WHERE source_url=%s
                    """,
                ),
                (
                    """
                    SELECT source_url, ws_url, activity, mqtt_enabled, ping_interval, strict_availability,
                           charging_analytics,
                           backend_basic_user, backend_basic_password,
                           NULL AS location_name, NULL AS location_link,
                           NULL AS webui_remote_access_url, NULL AS load_management_remote_access_url,
                           comment
                    FROM op_redirects WHERE source_url=%s
                    """,
                ),
                (
                    """
                    SELECT source_url, ws_url, activity, mqtt_enabled, ping_enabled AS ping_interval, strict_availability,
                           charging_analytics,
                           backend_basic_user, backend_basic_password,
                           NULL AS location_name, NULL AS location_link,
                           NULL AS webui_remote_access_url, NULL AS load_management_remote_access_url,
                           comment
                    FROM op_redirects WHERE source_url=%s
                    """,
                ),
                (
                    """
                    SELECT source_url, ws_url, activity, mqtt_enabled, ping_interval, strict_availability,
                           charging_analytics,
                           NULL AS backend_basic_user, NULL AS backend_basic_password,
                           NULL AS location_name, NULL AS location_link,
                           NULL AS webui_remote_access_url, NULL AS load_management_remote_access_url,
                           comment
                    FROM op_redirects WHERE source_url=%s
                    """,
                ),
                (
                    """
                    SELECT source_url, ws_url, activity, mqtt_enabled, ping_enabled AS ping_interval, strict_availability,
                           charging_analytics,
                           NULL AS backend_basic_user, NULL AS backend_basic_password,
                           NULL AS location_name, NULL AS location_link,
                           NULL AS webui_remote_access_url, NULL AS load_management_remote_access_url,
                           comment
                    FROM op_redirects WHERE source_url=%s
                    """,
                ),
                (
                    """
                    SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, ping_interval, 0 AS strict_availability,
                           charging_analytics,
                           backend_basic_user, backend_basic_password,
                           location_name, location_link,
                           webui_remote_access_url, load_management_remote_access_url,
                           comment
                    FROM op_redirects WHERE source_url=%s
                    """,
                ),
                (
                    """
                    SELECT source_url, ws_url, ocpp_subprotocol, activity, mqtt_enabled, ping_interval, strict_availability,
                           0 AS charging_analytics,
                           backend_basic_user, backend_basic_password,
                           location_name, location_link,
                           webui_remote_access_url, load_management_remote_access_url,
                           comment
                    FROM op_redirects WHERE source_url=%s
                    """,
                ),
            ]
            row = None
            for (sql,) in query_variants:
                try:
                    cur.execute(sql, (source,))
                except Exception:
                    continue
                row = cur.fetchone()
                if row:
                    break

            if not row:
                return jsonify({'error': 'not found'}), 404
    finally:
        conn.close()

    row['strict_availability'] = bool(row.get('strict_availability'))
    row['charging_analytics'] = bool(row.get('charging_analytics'))
    return jsonify(row), 200

@app.route('/api/op_redirects', methods=['DELETE'])
def api_delete_redirect():
    """Delete an op_redirects entry."""

    auth_header = request.headers.get('Authorization', '')
    token = get_token()
    if not auth_header.startswith('Bearer ') or auth_header.split(' ', 1)[1] != token:
        return jsonify({'error': 'unauthorized'}), 401

    data = request.get_json() or {}
    source = data.get('source_url')
    if not source:
        return jsonify({'error': 'source_url missing'}), 400

    conn = get_db_conn()
    try:
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            cur.execute("DELETE FROM op_redirects WHERE source_url=%s", (source,))
            if cur.rowcount == 0:
                return jsonify({'error': 'not found'}), 404
            conn.commit()
    finally:
        conn.close()

    refresh_station_list_safely()

    return jsonify({'status': 'ok', 'source_url': source}), 200


@app.route('/api/docs/swagger.json', methods=['GET'])
def api_swagger_spec():
    """Return a minimal OpenAPI schema for the dashboard API."""

    base_url = request.host_url.rstrip('/')

    redirect_schema = {
        'type': 'object',
        'properties': {
            'source_url': {'type': 'string', 'example': 'wss://charger-1/ws'},
            'ws_url': {'type': 'string', 'example': 'wss://broker/ws'},
            'activity': {'type': 'string', 'example': 'active'},
            'mqtt_enabled': {'type': 'integer', 'format': 'int32', 'example': 1},
            'ping_interval': {'type': 'integer', 'format': 'int32', 'example': 60},
            'strict_availability': {'type': 'integer', 'format': 'int32', 'example': 0},
            'charging_analytics': {'type': 'integer', 'format': 'int32', 'example': 0},
            'backend_basic_user': {'type': 'string', 'nullable': True},
            'backend_basic_password': {'type': 'string', 'nullable': True},
            'location_name': {'type': 'string', 'nullable': True},
            'location_link': {'type': 'string', 'nullable': True},
            'webui_remote_access_url': {'type': 'string', 'nullable': True},
            'load_management_remote_access_url': {'type': 'string', 'nullable': True},
            'ocpp_subprotocol': {'type': 'string', 'nullable': True},
            'comment': {'type': 'string', 'nullable': True},
        },
        'required': ['source_url'],
    }

    spec = {
        'openapi': '3.0.3',
        'info': {
            'title': 'Pipelet Dashboard API',
            'version': '1.0.0',
            'description': 'Endpoints to manage op_redirects entries.',
        },
        'servers': [{'url': base_url}],
        'paths': {
            '/api/op_redirects': {
                'get': {
                    'summary': 'Hole einen op_redirects Eintrag',
                    'parameters': [
                        {
                            'name': 'source_url',
                            'in': 'query',
                            'required': True,
                            'schema': {'type': 'string'},
                        }
                    ],
                    'responses': {'200': {'description': 'Eintrag gefunden'}},
                    'security': [{'bearerAuth': []}],
                },
                'post': {
                    'summary': 'Erstelle einen neuen op_redirects Eintrag',
                    'requestBody': {
                        'required': True,
                        'content': {
                            'application/json': {
                                'schema': {
                                    'allOf': [
                                        redirect_schema,
                                        {'required': ['source_url', 'ws_url', 'activity']},
                                    ]
                                }
                            }
                        },
                    },
                    'responses': {'201': {'description': 'Eintrag erstellt'}},
                    'security': [{'bearerAuth': []}],
                },
                'patch': {
                    'summary': 'Aktualisiere einen bestehenden op_redirects Eintrag',
                    'requestBody': {
                        'required': True,
                        'content': {'application/json': {'schema': redirect_schema}},
                    },
                    'responses': {'200': {'description': 'Eintrag aktualisiert'}},
                    'security': [{'bearerAuth': []}],
                },
                'delete': {
                    'summary': 'Lösche einen op_redirects Eintrag',
                    'requestBody': {
                        'required': True,
                        'content': {
                            'application/json': {
                                'schema': {
                                    'type': 'object',
                                    'required': ['source_url'],
                                    'properties': {'source_url': {'type': 'string'}},
                                }
                            }
                        },
                    },
                    'responses': {'200': {'description': 'Eintrag gelöscht'}},
                    'security': [{'bearerAuth': []}],
                },
            },
            '/api/op_redirects/upsert': {
                'post': {
                    'summary': 'Erstelle oder aktualisiere einen op_redirects Eintrag',
                    'requestBody': {
                        'required': True,
                        'content': {'application/json': {'schema': redirect_schema}},
                    },
                    'responses': {
                        '200': {'description': 'Eintrag aktualisiert'},
                        '201': {'description': 'Eintrag erstellt'},
                    },
                    'security': [{'bearerAuth': []}],
                }
            },
        },
        'components': {
            'securitySchemes': {
                'bearerAuth': {
                    'type': 'http',
                    'scheme': 'bearer',
                }
            }
        },
    }

    return jsonify(spec)


@app.route('/api/op_redirects', methods=['PATCH'])
def api_update_redirect():
    """Update an existing op_redirects entry.

    Requires ``source_url`` in der JSON-Body um den Datensatz zu identifizieren
    und akzeptiert die Felder ``ws_url``, ``activity``, ``mqtt_enabled``,
    ``ping_interval``, ``strict_availability``, ``charging_analytics`` sowie
    ``backend_basic_user``, ``backend_basic_password``, ``comment`` und
    ``ocpp_subprotocol`` zur Aktualisierung.
    """

    auth_header = request.headers.get('Authorization', '')
    token = get_token()
    if not auth_header.startswith('Bearer ') or auth_header.split(' ', 1)[1] != token:
        return jsonify({'error': 'unauthorized'}), 401

    data = request.get_json() or {}
    source = data.get('source_url')
    if not source:
        return jsonify({'error': 'source_url missing'}), 400

    assignments, values, error_response = _prepare_op_redirect_updates(data)
    if error_response:
        return error_response

    values.append(source)

    conn = get_db_conn()
    try:
        ensure_op_redirects_columns(conn)
        with conn.cursor() as cur:
            cur.execute(
                "SELECT 1 FROM op_redirects WHERE source_url=%s",
                (source,),
            )
            if cur.fetchone() is None:
                return jsonify({'error': 'redirect not found'}), 404

        with conn.cursor() as cur:
            try:
                cur.execute(
                    f"UPDATE op_redirects SET {', '.join(field for field, _ in assignments)} WHERE source_url=%s",
                    [val for _, val in assignments] + [source],
                )
            except Exception:
                # First fallback: handle ping_interval vs. ping_enabled naming differences
                replaced = [
                    (field.replace('ping_interval', 'ping_enabled'), val)
                    for field, val in assignments
                ]
                try:
                    cur.execute(
                        f"UPDATE op_redirects SET {', '.join(field for field, _ in replaced)} WHERE source_url=%s",
                        [val for _, val in replaced] + [source],
                    )
                except Exception:
                    # Second fallback: remove backend credential updates if the columns are absent
                    filtered = [
                        item
                        for item in replaced
                        if 'backend_basic_user' not in item[0]
                        and 'backend_basic_password' not in item[0]
                        and 'location_name' not in item[0]
                        and 'location_link' not in item[0]
                        and 'webui_remote_access_url' not in item[0]
                        and 'load_management_remote_access_url' not in item[0]
                        and 'comment' not in item[0]
                        and 'strict_availability' not in item[0]
                        and 'charging_analytics' not in item[0]
                        and 'ocpp_subprotocol' not in item[0]
                    ]
                    if not filtered:
                        raise
                    cur.execute(
                        f"UPDATE op_redirects SET {', '.join(field for field, _ in filtered)} WHERE source_url=%s",
                        [val for _, val in filtered] + [source],
                    )
        conn.commit()
    finally:
        conn.close()

    refresh_station_list_safely()

    return jsonify({'status': 'ok', 'source_url': source}), 200

# ---------------------------------------------------------------------------
# Set a configuration key on a wallbox via the proxy
# ---------------------------------------------------------------------------

@app.route('/op_wallboxSetConfig', methods=['POST'])
def wallbox_set_config():
    key = request.form.get('Key')
    value = request.form.get('Value')
    station_id = request.form.get('stationID')
    if not key or value is None or not station_id:
        return jsonify({'error': 'Key, Value und stationID erforderlich'}), 400

    url = f"{PROXY_BASE_URL}/wallboxSetConfig"
    try:
        resp = requests.post(
            url,
            json={'Key': key, 'Value': value, 'stationID': station_id},
            timeout=5,
        )
        resp.raise_for_status()
    except Exception as e:
        return jsonify({'error': str(e)}), 500

    return jsonify({'status': 'ok'}), 200


@app.route('/mock/charging-station/authorizeTransaction', methods=['POST'])
def mock_authorize_transaction():
    """Simple mock endpoint for external authorizeTransaction.

    Expects JSON with ``idToken`` and optional fields. It always returns
    ``status="APPROVED"`` and echoes back the provided token as
    ``mappedToken`` so it can be used for testing the external
    authorizeTransaction integration.
    """
    data = request.get_json(silent=True) or {}
    id_token = data.get('idToken', '')
    return jsonify({'status': 'APPROVED', 'mappedToken': id_token}), 200

# ---------------------------------------------------------------------------
# Config checker web interface (moved from service agent)
# ---------------------------------------------------------------------------


@app.route('/op_')
def index():
    """Simple start page."""
    return render_template('op_home.html')


@app.route('/op_checker')
def checker_home():
    conn = get_db_conn()
    with conn.cursor() as cur:
        cur.execute("SELECT COUNT(*) FROM op_config_results")
        count = cur.fetchone()[0]
    conn.close()
    return render_template('op_sa_dashboard.html', count=count)


@app.route('/op_fault_detection', methods=['GET', 'POST'])
def fault_detection():
    ensure_fault_detection_tables()
    error = None
    message = None
    active_tab = 'common'

    if request.method == 'POST':
        requested_tab = (request.form.get('active_tab') or '').strip()
        if requested_tab in {'common', 'custom'}:
            active_tab = requested_tab

    if request.method == 'POST':
        action = request.form.get('action')
        if action == 'add':
            pattern_title = (request.form.get('pattern_title') or '').strip()
            pattern = (request.form.get('pattern') or '').strip()
            explanation = (request.form.get('explanation') or '').strip()
            criticality = (request.form.get('criticality') or 'medium').strip().lower()
            is_active = 1 if request.form.get('is_active') else 0
            if not pattern_title or not pattern or not explanation:
                error = 'Titel, Pattern und Erklärung dürfen nicht leer sein.'
            elif criticality not in {'low', 'medium', 'high'}:
                error = 'Ungültige Kritikalität ausgewählt.'
            else:
                try:
                    re.compile(pattern)
                except re.error as exc:
                    error = f'Ungültiges Pattern: {exc}'
                else:
                    conn = get_db_conn()
                    try:
                        with conn.cursor() as cur:
                            cur.execute(
                                """
                                INSERT INTO op_fault_detection_rules
                                    (pattern_title, pattern, explanation, criticality, is_active)
                                VALUES (%s, %s, %s, %s, %s)
                                """,
                                (pattern_title, pattern, explanation, criticality, is_active),
                            )
                        conn.commit()
                        message = 'Pattern gespeichert.'
                    except Exception as exc:
                        conn.rollback()
                        error = f'Fehler beim Speichern: {exc}'
                    finally:
                        conn.close()
        elif action == 'delete':
            active_tab = 'custom'
            try:
                rule_id = int(request.form.get('rule_id', '0'))
            except ValueError:
                error = 'Ungültige Regel-ID.'
            else:
                conn = get_db_conn()
                try:
                    with conn.cursor() as cur:
                        cur.execute(
                            "DELETE FROM op_fault_detection_rules WHERE id=%s",
                            (rule_id,),
                        )
                        cur.execute(
                            "DELETE FROM op_station_marks WHERE pattern_id=%s AND source='fault_detection'",
                            (rule_id,),
                        )
                    conn.commit()
                    message = 'Pattern gelöscht.'
                except Exception as exc:
                    conn.rollback()
                    error = f'Fehler beim Löschen: {exc}'
                finally:
                    conn.close()
        elif action == 'toggle':
            active_tab = 'custom'
            try:
                rule_id = int(request.form.get('rule_id', '0'))
            except ValueError:
                error = 'Ungültige Regel-ID.'
            else:
                new_status_raw = (request.form.get('new_status') or '').strip()
                is_active = 1 if new_status_raw in {'1', 'true', 'on', 'yes'} else 0
                conn = get_db_conn()
                try:
                    with conn.cursor() as cur:
                        cur.execute(
                            "UPDATE op_fault_detection_rules SET is_active=%s WHERE id=%s",
                            (is_active, rule_id),
                        )
                    conn.commit()
                    message = 'Pattern aktualisiert.'
                except Exception as exc:
                    conn.rollback()
                    error = f'Fehler beim Aktualisieren: {exc}'
                finally:
                    conn.close()
        elif action == 'toggle_trigger':
            active_tab = 'common'
            try:
                trigger_id = int(request.form.get('trigger_id', '0'))
            except ValueError:
                error = 'Ungültige Trigger-ID.'
            else:
                new_status_raw = (request.form.get('new_status') or '').strip()
                is_enabled = 1 if new_status_raw in {'1', 'true', 'on', 'yes'} else 0
                conn = get_db_conn()
                try:
                    with conn.cursor() as cur:
                        cur.execute(
                            "UPDATE op_fault_detection_trigger SET is_enabled=%s WHERE id=%s",
                            (is_enabled, trigger_id),
                        )
                    conn.commit()
                    message = 'Trigger aktualisiert.'
                except Exception as exc:
                    conn.rollback()
                    error = f'Fehler beim Aktualisieren: {exc}'
                finally:
                    conn.close()
        elif action == 'update_trigger_threshold':
            active_tab = 'common'
            try:
                trigger_id = int(request.form.get('trigger_id', '0'))
                new_threshold = int(request.form.get('threshold', '1'))
            except ValueError:
                error = 'Ungültige Eingabe für den Trigger.'
            else:
                if new_threshold < 1 or new_threshold > 10:
                    error = 'Der Schwellenwert muss zwischen 1 und 10 liegen.'
                else:
                    conn = get_db_conn()
                    try:
                        with conn.cursor() as cur:
                            cur.execute(
                                "UPDATE op_fault_detection_trigger SET threshold=%s WHERE id=%s",
                                (new_threshold, trigger_id),
                            )
                        conn.commit()
                        message = 'Schwellenwert gespeichert.'
                    except Exception as exc:
                        conn.rollback()
                        error = f'Fehler beim Speichern: {exc}'
                    finally:
                        conn.close()

    requested_tab = (request.args.get('active_tab') or '').strip()
    if requested_tab in {'common', 'custom'}:
        active_tab = requested_tab

    grouped_triggers = {key: [] for key, _ in TRIGGER_CLUSTER_SECTIONS}
    conn = get_db_conn()
    try:
        with conn.cursor(pymysql.cursors.DictCursor) as cur:
            cur.execute(
                """
                SELECT id, pattern_title, pattern, explanation, criticality, is_active, created_at
                FROM op_fault_detection_rules
                ORDER BY id DESC
                """
            )
            rules = cur.fetchall()
            cur.execute(
                """
                SELECT pattern_id, COUNT(*) AS cnt
                FROM op_station_marks
                WHERE pattern_id IS NOT NULL
                GROUP BY pattern_id
                """
            )
            counts = {row['pattern_id']: row['cnt'] for row in cur.fetchall() if row['pattern_id']}
            cur.execute(
                """
                SELECT id,
                       report_reason_id,
                       report_reason_trigger,
                       report_reason_title,
                       report_reason_description,
                       cluster,
                       is_enabled,
                       threshold,
                       priority
                FROM op_fault_detection_trigger
                ORDER BY
                    CASE priority WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 ELSE 3 END,
                    report_reason_title
                """
            )
            triggers = cur.fetchall()
            for trigger in triggers:
                trigger['is_enabled'] = bool(trigger.get('is_enabled'))
                threshold_val = trigger.get('threshold')
                try:
                    trigger['threshold'] = int(threshold_val)
                except (TypeError, ValueError):
                    trigger['threshold'] = 1
                if trigger['threshold'] < 1:
                    trigger['threshold'] = 1
                cluster_value = (trigger.get('cluster') or '').strip().lower()
                if cluster_value not in _VALID_TRIGGER_CLUSTERS:
                    cluster_value = 'other'
                trigger['cluster'] = cluster_value
                grouped_triggers.setdefault(cluster_value, []).append(trigger)
    finally:
        conn.close()

    trigger_sections = [
        {
            'key': key,
            'label': label,
            'triggers': grouped_triggers.get(key, []),
        }
        for key, label in TRIGGER_CLUSTER_SECTIONS
    ]

    return render_template(
        'op_fault_detection.html',
        aside='repo_diag',
        rules=rules,
        counts=counts,
        trigger_sections=trigger_sections,
        error=error,
        message=message,
        active_tab=active_tab,
    )


@app.route('/op_marked_wallboxes/<int:mark_id>/request_service', methods=['POST'])
def request_marked_wallbox_service(mark_id: int):
    data = request.get_json(silent=True) or {}
    measure_raw = (data.get('measure') or '').strip()
    comment = (data.get('comment') or '').strip()

    allowed_measures = {
        'Gerät Tauschen',
        'Techniker vor Ort erforderlich',
        'Thirdlevel',
    }

    if measure_raw not in allowed_measures:
        return jsonify({'error': 'Ungültige Maßnahme ausgewählt.'}), 400

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT station_id, reason, created_at, updated_at, service_requested_at
                FROM op_station_marks
                WHERE id=%s
                """,
                (mark_id,),
            )
            mark_row = cur.fetchone()

        if not mark_row:
            return jsonify({'error': 'Markierung wurde nicht gefunden.'}), 404

        if mark_row.get('service_requested_at'):
            return jsonify({'error': 'Für diese Markierung wurde bereits eine Service-Anfrage versendet.'}), 409

        station_id = (mark_row.get('station_id') or '').strip()
        reason = mark_row.get('reason') or ''
        marked_at_raw = mark_row.get('updated_at') or mark_row.get('created_at')
        if isinstance(marked_at_raw, datetime.datetime):
            marked_display = marked_at_raw.strftime('%Y-%m-%d %H:%M:%S')
        elif marked_at_raw:
            marked_display = str(marked_at_raw)
        else:
            marked_display = '-'

        recipient_raw = get_config_value('crm_email')
        if not recipient_raw:
            return jsonify({'error': 'Keine CRM-E-Mail-Adresse konfiguriert (crm_email).'}), 500

        recipients = [
            entry.strip()
            for entry in re.split(r'[;,]', str(recipient_raw))
            if entry and entry.strip()
        ]
        if not recipients:
            return jsonify({'error': 'Keine gültige CRM-E-Mail-Adresse konfiguriert.'}), 500

        subject = f"Service Chargepoint {station_id or mark_id}"
        body_lines = [
            f"Chargepoint ID: {station_id or '-'}",
            f"Reason: {reason or '-'}",
            f"Marked: {marked_display}",
            f"Maßnahme: {measure_raw}",
            "",
            "Kommentar:",
            comment if comment else "-",
        ]
        body = "\n".join(body_lines)

        try:
            send_dashboard_email(subject, body, recipients)
        except RuntimeError as exc:
            return jsonify({'error': str(exc)}), 500

        service_requested_at = datetime.datetime.utcnow().replace(microsecond=0)
        with conn.cursor() as cur:
            cur.execute(
                """
                UPDATE op_station_marks
                SET service_requested_at=%s
                WHERE id=%s AND service_requested_at IS NULL
                """,
                (service_requested_at, mark_id),
            )
            updated_rows = cur.rowcount
        if updated_rows == 0:
            conn.rollback()
            return jsonify({'error': 'Für diese Markierung wurde bereits eine Service-Anfrage versendet.'}), 409
        conn.commit()
    finally:
        conn.close()

    service_display = service_requested_at.strftime('%Y-%m-%d %H:%M:%S')
    return (
        jsonify(
            {
                'status': 'ok',
                'service_requested_at': service_requested_at.isoformat(),
                'service_requested_at_display': service_display,
                'station_id': station_id,
            }
        ),
        200,
    )


@app.route('/op_mark_clear', methods=['POST'])
def clear_mark():
    try:
        mark_id = int(request.form.get('mark_id', '0'))
    except ValueError:
        return redirect(url_for('dashboard'))

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute("DELETE FROM op_station_marks WHERE id=%s", (mark_id,))
        conn.commit()
    finally:
        conn.close()

    return redirect(url_for('dashboard'))


@app.route('/op_mark_clear_all', methods=['POST'])
def clear_all_marks():
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT COUNT(*) AS cnt FROM op_station_marks")
            row = cur.fetchone() or {}
            count_raw = row.get('cnt') if isinstance(row, dict) else None
            try:
                count = int(count_raw or 0)
            except (TypeError, ValueError):
                count = 0

            if count:
                cur.execute("DELETE FROM op_station_marks")
        conn.commit()
    finally:
        conn.close()

    return redirect(url_for('dashboard'))


@app.route('/op_mark_set', methods=['POST'])
def mark_station():
    station_id_raw = request.form.get('station_id', '').strip()
    reason = request.form.get('reason', '').strip() or 'Marked via dashboard'
    if not station_id_raw:
        return redirect(url_for('dashboard'))

    station_id = normalize_station_id(station_id_raw).rsplit('/', 1)[-1]

    ensure_fault_detection_tables()

    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                "DELETE FROM op_station_marks WHERE station_id=%s AND source='manual' AND pattern_id IS NULL",
                (station_id,),
            )
            cur.execute(
                """
                INSERT INTO op_station_marks (station_id, reason, source, pattern_id)
                VALUES (%s, %s, 'manual', NULL)
                """,
                (station_id, reason),
            )
        conn.commit()
    finally:
        conn.close()

    return redirect(url_for('dashboard'))


def ensure_ruleset_table():
    """Ensure the verification list table exists."""
    conn = get_db_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(
                """
                CREATE TABLE IF NOT EXISTS op_verfication_lists (
                    id INT AUTO_INCREMENT PRIMARY KEY,
                    name VARCHAR(255) NOT NULL,
                    description TEXT
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
                """
            )
        conn.commit()
    finally:
        conn.close()


@app.route('/op_rulesets', methods=['GET', 'POST'])
def checker_rulesets():
    ensure_ruleset_table()
    conn = get_db_conn()
    cur = conn.cursor(pymysql.cursors.DictCursor)

    if request.method == 'POST':
        action = request.form.get('action')
        if action == 'create':
            name = request.form.get('name', '').strip()
            desc = request.form.get('description', '').strip()
            if name:
                cur.execute(
                    "INSERT INTO op_verfication_lists (name, description) VALUES (%s, %s)",
                    (name, desc),
                )
                conn.commit()
                rs = cur.lastrowid
                conn.close()
                return redirect(url_for('checker_edit_rules', rule_set=rs))
        elif action == 'delete':
            rs = request.form.get('rule_set_id')
            if rs:
                cur.execute("DELETE FROM op_verfication_lists WHERE id=%s", (rs,))
                cur.execute("DELETE FROM op_config_rules WHERE rule_set=%s", (rs,))
                cur.execute("DELETE FROM op_config_station_rules WHERE rule_set=%s", (rs,))
                conn.commit()
        elif action == 'rename':
            rs = request.form.get('rule_set_id')
            name = request.form.get('name', '').strip()
            desc = request.form.get('description', '').strip()
            if rs and name:
                cur.execute(
                    "UPDATE op_verfication_lists SET name=%s, description=%s WHERE id=%s",
                    (name, desc, rs),
                )
                conn.commit()
        conn.close()
        return redirect(url_for('checker_rulesets'))

    cur.execute("SELECT id, name, description FROM op_verfication_lists ORDER BY name")
    rule_sets = cur.fetchall()
    conn.close()
    return render_template('op_sa_rulesets.html', rule_sets=rule_sets)


@app.route('/op_rulesets/<int:rule_set>', methods=['GET', 'POST'])
def checker_edit_rules(rule_set):
    conn = get_db_conn()
    cur = conn.cursor(pymysql.cursors.DictCursor)
    if request.method == 'POST':
        action = request.form.get('action')
        if action == 'add':
            cur.execute(
                "INSERT INTO op_config_rules (rule_set, configuration_key, value, `condition`, auto_fix, explanation) VALUES (%s, %s, %s, %s, %s, %s)",
                (
                    rule_set,
                    request.form.get('key'),
                    request.form.get('value'),
                    request.form.get('condition'),
                    1 if request.form.get('auto_fix') else 0,
                    request.form.get('explanation'),
                ),
            )
        elif action == 'delete':
            cur.execute("DELETE FROM op_config_rules WHERE id=%s", (request.form.get('id'),))
        elif action == 'edit':
            cur.execute(
                "UPDATE op_config_rules SET configuration_key=%s, value=%s, `condition`=%s, auto_fix=%s, explanation=%s WHERE id=%s",
                (
                    request.form.get('key'),
                    request.form.get('value'),
                    request.form.get('condition'),
                    1 if request.form.get('auto_fix') else 0,
                    request.form.get('explanation'),
                    request.form.get('id'),
                ),
            )
        conn.commit()
        conn.close()
        return redirect(url_for('checker_edit_rules', rule_set=rule_set))

    cur.execute(
        "SELECT name, description FROM op_verfication_lists WHERE id=%s",
        (rule_set,),
    )
    row = cur.fetchone()
    rule_set_name = row["name"] if row else str(rule_set)
    rule_set_desc = row.get("description") if row else ""

    cur.execute(
        "SELECT id, configuration_key, value, `condition`, auto_fix, explanation "
        "FROM op_config_rules WHERE rule_set=%s",
        (rule_set,),
    )
    rules = cur.fetchall()
    conn.close()
    return render_template(
        'op_sa_edit_rules.html',
        rule_set=rule_set,
        rule_set_name=rule_set_name,
        rule_set_desc=rule_set_desc,
        rules=rules,
    )


@app.route('/op_station-rules', methods=['GET', 'POST'])
def checker_station_rules():
    ensure_ruleset_table()
    conn = get_db_conn()
    cur = conn.cursor(pymysql.cursors.DictCursor)

    if request.method == 'POST':
        cur.execute(
            "REPLACE INTO op_config_station_rules (station_id, rule_set) VALUES (%s, %s)",
            (request.form.get('station_id'), request.form.get('rule_set')),
        )
        conn.commit()

    # Alle Station-IDs und ihre RuleSets
    cur.execute(
        "SELECT station_id, rule_set FROM op_config_station_rules ORDER BY station_id"
    )
    assignments = {r['station_id']: r['rule_set'] for r in cur.fetchall()}
    stations = list(assignments.keys())

    cur.execute("SELECT id, name FROM op_verfication_lists ORDER BY name")
    rule_sets = cur.fetchall()

    entries = []
    name_map = {r['id']: r['name'] for r in rule_sets}
    for sid in stations:
        rs = assignments.get(sid)
        entries.append({'station_id': sid, 'rule_set': rs, 'name': name_map.get(rs)})

    conn.close()
    return render_template('op_sa_station_rules.html', entries=entries, rule_sets=rule_sets)


@app.route('/op_station-rules/<station_id>')
def checker_station_config(station_id):
    """Show configuration values of a station and verify them against rules."""
    ensure_ruleset_table()
    conn = get_db_conn()
    cur = conn.cursor(pymysql.cursors.DictCursor)

    cur.execute(
        "SELECT rule_set FROM op_config_station_rules WHERE station_id=%s",
        (station_id,),
    )
    row = cur.fetchone()
    rule_set = row['rule_set'] if row else None

    rule_set_name = None
    if rule_set:
        cur.execute(
            "SELECT name FROM op_verfication_lists WHERE id=%s",
            (rule_set,),
        )
        rs_row = cur.fetchone()
        rule_set_name = rs_row['name'] if rs_row else None

    cur.execute(
        "SELECT config_key, config_value FROM op_wb_config_keys WHERE station_id=%s",
        (station_id,),
    )
    configs = {r['config_key']: r['config_value'] for r in cur.fetchall()}

    rules = {}
    if rule_set:
        cur.execute(
            "SELECT configuration_key, value, `condition` FROM op_config_rules WHERE rule_set=%s",
            (rule_set,),
        )
        for r in cur.fetchall():
            rules[r['configuration_key']] = r

    conn.close()

    entries = []
    for key, val in configs.items():
        rule = rules.get(key)
        status = None
        expected = None
        condition = None
        if rule:
            expected = rule['value']
            condition = rule['condition']
            ok = True
            try:
                if condition == 'equals':
                    ok = str(val) == expected
                elif condition == 'contains':
                    ok = expected in str(val)
                elif condition == 'larger':
                    ok = float(val) > float(expected)
                elif condition == 'smaller':
                    ok = float(val) < float(expected)
            except Exception:
                ok = False
            status = 'ok' if ok else 'failed'
        entries.append({
            'key': key,
            'value': val,
            'expected': expected,
            'condition': condition,
            'status': status,
        })

    return render_template(
        'op_sa_station_config.html',
        station_id=station_id,
        rule_set_name=rule_set_name,
        entries=entries,
    )


def _resolve_dashboard_port(config: Mapping[str, Any]) -> int:
    default_port = 5000
    raw_port = None
    if isinstance(config.get("dashboard"), Mapping):
        raw_port = config["dashboard"].get("port")
    if raw_port is None:
        raw_port = config.get("dashboard_port")
    try:
        port = int(raw_port) if raw_port is not None else default_port
    except (TypeError, ValueError):
        LOGGER.warning("Invalid dashboard port %r, using default %s", raw_port, default_port)
        return default_port
    if port <= 0 or port > 65535:
        LOGGER.warning("Dashboard port %s out of range, using default %s", port, default_port)
        return default_port
    return port


if __name__ == "__main__":
    port = _resolve_dashboard_port(_config)
    app.run(host="0.0.0.0", port=port, debug=True, use_reloader=True)
