<?php

declare(strict_types=1);

require_once __DIR__ . '/install-support.php';

function app_env(): array
{
    static $env;

    if ($env === null) {
        $env = scripts_read_env_file(scripts_env_path());

        if ($env === []) {
            $env = scripts_read_env_file(scripts_env_example_path());
        }
    }

    return $env;
}

function app_connection(): string
{
    return scripts_normalize_db_connection(app_env()['DB_CONNECTION'] ?? 'mysql');
}

function app_uses_mysql(): bool
{
    return in_array(app_connection(), ['mysql', 'mariadb'], true);
}

function app_table_has_column(PDO $pdo, string $tableName, string $columnName): bool
{
    if (!scripts_table_exists($pdo, $tableName)) {
        return false;
    }

    return app_query_one(
        $pdo,
        <<<'SQL'
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = :table_name
  AND COLUMN_NAME = :column_name
LIMIT 1
SQL,
        [
            'table_name' => $tableName,
            'column_name' => $columnName,
        ]
    ) !== null;
}

function app_request_path(): string
{
    $path = parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);

    if (!is_string($path) || $path === '') {
        return '/';
    }

    $scriptName = (string) ($_SERVER['SCRIPT_NAME'] ?? '/index.php');
    $baseDir = rtrim(str_replace('\\', '/', dirname($scriptName)), '/');

    if ($baseDir !== '' && $baseDir !== '.' && $baseDir !== '/' && str_starts_with($path, $baseDir)) {
        $path = substr($path, strlen($baseDir)) ?: '/';
    }

    return $path === '' ? '/' : $path;
}

function app_current_url(string $path = '/'): string
{
    $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    $host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost');

    return $scheme . '://' . $host . $path;
}

function app_pdo(): PDO
{
    static $pdo;

    if ($pdo instanceof PDO) {
        return $pdo;
    }

    if (!app_uses_mysql()) {
        throw new RuntimeException('Diese Public-Runtime ist aktuell für MySQL/MariaDB ausgelegt.');
    }

    if (!extension_loaded('pdo_mysql')) {
        throw new RuntimeException('Die PHP-Erweiterung pdo_mysql ist nicht geladen.');
    }

    $env = app_env();
    $host = $env['DB_HOST'] ?? '127.0.0.1';
    $port = $env['DB_PORT'] ?? '3306';
    $database = $env['DB_DATABASE'] ?? '';
    $username = $env['DB_USERNAME'] ?? '';
    $password = $env['DB_PASSWORD'] ?? '';

    if ($database === '' || $username === '') {
        throw new RuntimeException('Die Datenbankkonfiguration in `saas-app/.env` ist unvollständig.');
    }

    $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);

    $pdo = new PDO($dsn, $username, $password, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);

    return $pdo;
}

function app_query_all(PDO $pdo, string $sql, array $params = []): array
{
    $statement = $pdo->prepare($sql);
    $statement->execute($params);

    return $statement->fetchAll() ?: [];
}

function app_query_one(PDO $pdo, string $sql, array $params = []): ?array
{
    $statement = $pdo->prepare($sql);
    $statement->execute($params);
    $row = $statement->fetch();

    return is_array($row) ? $row : null;
}

function app_query_value(PDO $pdo, string $sql, array $params = [], mixed $default = null): mixed
{
    $row = app_query_one($pdo, $sql, $params);

    if (!is_array($row) || $row === []) {
        return $default;
    }

    $value = array_shift($row);

    return $value ?? $default;
}

function app_execute(PDO $pdo, string $sql, array $params = []): void
{
    $statement = $pdo->prepare($sql);
    $statement->execute($params);
}

function app_flash(?string $message = null, string $type = 'info'): ?array
{
    if ($message !== null) {
        $_SESSION['app_flash'] = [
            'message' => $message,
            'type' => $type,
        ];

        return null;
    }

    $flash = $_SESSION['app_flash'] ?? null;
    unset($_SESSION['app_flash']);

    return is_array($flash) ? $flash : null;
}

function app_login_state(): array
{
    return $_SESSION['login_state'] ?? [];
}

function app_admin_login_state(): array
{
    return $_SESSION['admin_login_state'] ?? [];
}

function app_set_login_state(array $state): void
{
    $_SESSION['login_state'] = $state;
}

function app_clear_login_state(): void
{
    unset($_SESSION['login_state']);
}

function app_clear_admin_login_state(): void
{
    unset($_SESSION['admin_login_state']);
}

function app_auth_user(): ?array
{
    return $_SESSION['auth_user'] ?? null;
}

function app_admin_user(): ?array
{
    return $_SESSION['admin_user'] ?? null;
}

function app_set_auth_user(array $user): void
{
    $_SESSION['auth_user'] = $user;
}

function app_set_admin_user(array $user): void
{
    $_SESSION['admin_user'] = $user;
}

function app_logout(): void
{
    unset($_SESSION['auth_user'], $_SESSION['admin_user'], $_SESSION['login_state'], $_SESSION['admin_login_state']);
}

function app_route(string $path): string
{
    $parts = parse_url($path);

    if ($parts === false) {
        return $path;
    }

    $routePath = $parts['path'] ?? '/';

    if ($routePath !== '/' && $routePath !== '' && pathinfo($routePath, PATHINFO_EXTENSION) === '') {
        $routePath = rtrim($routePath, '/') . '/';
    }

    $query = isset($parts['query']) ? '?' . $parts['query'] : '';
    $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';

    return $routePath . $query . $fragment;
}

function app_redirect(string $path): never
{
    header('Location: ' . app_route($path));
    exit;
}

function app_uuid(): string
{
    $bytes = random_bytes(16);
    $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
    $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);

    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}

function app_require_auth(): array
{
    $auth = app_auth_user();

    if ($auth === null) {
        app_flash('Bitte melde dich zuerst über die zentrale Anmeldung an.', 'warning');
        app_redirect('/login/');
    }

    return $auth;
}

function app_require_platform_admin(): array
{
    $admin = app_admin_user();

    if ($admin === null || empty($admin['is_platform_admin'])) {
        app_flash('Bitte melde dich zuerst als Global-Admin an.', 'warning');
        app_redirect('/admin/login/');
    }

    return $admin;
}

function app_is_platform_admin(?array $auth): bool
{
    return is_array($auth) && !empty($auth['is_platform_admin']);
}

function app_is_tenant_admin(?array $auth): bool
{
    if (!is_array($auth)) {
        return false;
    }

    return in_array('tenant_admin', $auth['roles'] ?? [], true);
}

function app_can_manage_tenant(?array $auth): bool
{
    return app_is_platform_admin($auth) || app_is_tenant_admin($auth);
}

function app_has_role(?array $auth, string $role): bool
{
    if (!is_array($auth)) {
        return false;
    }

    return in_array($role, $auth['roles'] ?? [], true);
}

function app_can_manage_finance(?array $auth): bool
{
    return app_can_manage_tenant($auth) || app_has_role($auth, 'finance_admin');
}

function app_can_manage_support(?array $auth): bool
{
    return app_can_manage_tenant($auth) || app_has_role($auth, 'support_contact');
}

function app_can_manage_surveys(?array $auth): bool
{
    return app_can_manage_tenant($auth) || app_has_role($auth, 'survey_manager');
}

/**
 * @return array<int, string>
 */
function app_auth_role_labels(?array $auth): array
{
    if (!is_array($auth)) {
        return [];
    }

    if (app_is_platform_admin($auth) && empty($auth['acting_as_platform_admin'])) {
        return ['Global-Admin'];
    }

    $labels = [];
    $map = [
        'tenant_admin' => 'Tenant-Admin',
        'finance_admin' => 'Finanzen',
        'support_contact' => 'Support',
        'survey_manager' => 'Umfragen',
    ];

    foreach ($map as $role => $label) {
        if (app_has_role($auth, $role)) {
            $labels[] = $label;
        }
    }

    if ($labels === []) {
        $labels[] = 'Mitglied';
    }

    if (!empty($auth['acting_as_platform_admin'])) {
        array_unshift($labels, 'Global-Admin');
    }

    return array_values(array_unique($labels));
}

function app_primary_role_label(?array $auth): string
{
    $labels = app_auth_role_labels($auth);

    return $labels[0] ?? 'Mitglied';
}

/**
 * @param array<int, string> $roles
 */
function app_auth_has_any_role(?array $auth, array $roles): bool
{
    if (!is_array($auth)) {
        return false;
    }

    $granted = $auth['roles'] ?? [];

    if (!is_array($granted)) {
        return false;
    }

    foreach ($roles as $role) {
        if (in_array($role, $granted, true)) {
            return true;
        }
    }

    return false;
}

/**
 * @return array<int, array{key:string,label:string,href:string}>
 */
function app_tenant_navigation_items(?array $auth, array $license = []): array
{
    if (!is_array($auth)) {
        return [
            ['key' => 'home', 'label' => 'Start', 'href' => '/'],
            ['key' => 'login', 'label' => 'Anmeldung', 'href' => '/login/'],
            ['key' => 'admin', 'label' => 'Verwaltung', 'href' => '/admin/login/'],
        ];
    }

    $features = is_array($license['features'] ?? null) ? $license['features'] : [];
    $canManage = app_can_manage_tenant($auth);
    $canFinance = app_can_manage_finance($auth);
    $hasExports = !empty($features['pdf_export']) || !empty($features['paper_strike_entry']) || !empty($features['basic_exports']);

    $items = [
        ['key' => 'dashboard', 'label' => 'Übersicht', 'href' => '/dashboard/'],
    ];

    if ($canManage) {
        $items[] = ['key' => 'members', 'label' => 'Mitglieder', 'href' => '/members/'];
    }

    if ($canFinance) {
        $items[] = ['key' => 'ledger', 'label' => 'Buchungen', 'href' => '/ledger/'];
        $items[] = ['key' => 'payments', 'label' => 'Zahlungen', 'href' => '/payments/'];
        $items[] = ['key' => 'imports', 'label' => 'Importe', 'href' => '/imports/'];
        $items[] = ['key' => 'year-end', 'label' => 'Jahresabschluss', 'href' => '/year-end/'];
    }

    if ($canManage || app_can_manage_support($auth)) {
        $items[] = ['key' => 'content', 'label' => 'Hinweise & FAQ', 'href' => '/content/'];
        $items[] = ['key' => 'notifications', 'label' => 'Benachrichtigungen', 'href' => '/notifications/'];
        $items[] = ['key' => 'reports', 'label' => 'Reporting', 'href' => '/reports/'];

        if (!empty($features['tenant_settings'])) {
            $items[] = ['key' => 'settings', 'label' => 'Mandanten-Einstellungen', 'href' => '/settings/'];
        }

        $items[] = ['key' => 'roles', 'label' => 'Rollen', 'href' => '/tenants/roles/'];

        if ($hasExports) {
            $items[] = ['key' => 'exports', 'label' => 'Exporte', 'href' => '/exports/'];
        }
    }

    if (app_can_manage_support($auth)) {
        $items[] = ['key' => 'support', 'label' => 'Hilfe', 'href' => '/support/'];
    }

    if (app_can_manage_surveys($auth)) {
        $items[] = ['key' => 'surveys', 'label' => 'Umfragen', 'href' => '/surveys/'];
    }

    return $items;
}

/**
 * @return array<int, array{label:string,items:array<int, array{key:string,label:string,href:string}>}>
 */
function app_tenant_navigation_groups(array $items): array
{
    $groupOrder = ['data', 'content', 'management'];
    $groupLabels = [
        'data' => 'Daten',
        'content' => 'Inhalte & Support',
        'management' => 'Verwaltung',
    ];
    $groupMap = [
        'imports' => 'data',
        'exports' => 'data',
        'reports' => 'data',
        'year-end' => 'data',
        'content' => 'content',
        'notifications' => 'content',
        'support' => 'content',
        'surveys' => 'content',
        'members' => 'management',
        'roles' => 'management',
        'settings' => 'management',
    ];
    $grouped = [];

    foreach ($items as $item) {
        $key = (string) ($item['key'] ?? '');
        $groupKey = $groupMap[$key] ?? null;
        if ($groupKey === null) {
            continue;
        }

        if (!isset($grouped[$groupKey])) {
            $grouped[$groupKey] = [
                'label' => $groupLabels[$groupKey] ?? ucfirst($groupKey),
                'items' => [],
            ];
        }

        $grouped[$groupKey]['items'][] = $item;
    }

    $result = [];
    foreach ($groupOrder as $groupKey) {
        if (!empty($grouped[$groupKey]['items'])) {
            $result[] = $grouped[$groupKey];
        }
    }

    return $result;
}

/**
 * @return array<int, array{key:string,label:string,href:string}>
 */
function app_tenant_account_items(?array $auth): array
{
    if (!is_array($auth)) {
        return [];
    }

    return [
        ['key' => 'profile', 'label' => 'Persönliche Einstellungen', 'href' => '/profile/'],
    ];
}

function app_users_support_theme_mode(PDO $pdo): bool
{
    static $cache = null;

    if (is_bool($cache)) {
        return $cache;
    }

    $cache = app_table_has_column($pdo, 'users', 'theme_mode');

    return $cache;
}

function app_normalize_theme_mode(?string $mode): string
{
    $mode = strtolower(trim((string) $mode));

    return in_array($mode, ['light', 'dark'], true) ? $mode : 'dark';
}

function app_platform_admin_by_email(PDO $pdo, string $email): ?array
{
    $themeSelect = app_users_support_theme_mode($pdo)
        ? ', theme_mode'
        : ", 'dark' AS theme_mode";

    return app_query_one(
        $pdo,
        'SELECT id, email, password_hash, display_name, is_platform_admin' . $themeSelect . ', created_at, updated_at FROM users WHERE LOWER(email) = LOWER(:email) AND is_platform_admin = 1 LIMIT 1',
        ['email' => $email]
    );
}

function app_handle_platform_admin_login(PDO $pdo): array
{
    $message = null;
    $error = null;
    $state = app_admin_login_state();

    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
        $action = (string) ($_POST['action'] ?? '');

        if ($action === 'admin-login') {
            $csrf = (string) ($_POST['csrf'] ?? '');
            $email = strtolower(trim((string) ($_POST['email'] ?? '')));
            $password = (string) ($_POST['password'] ?? '');
            $state = ['email' => $email];

            if (!isset($_SESSION['admin_csrf']) || !hash_equals((string) $_SESSION['admin_csrf'], $csrf)) {
                $error = 'Der Sicherheitsstatus ist ungültig. Bitte lade die Seite neu.';
            } elseif ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
                $error = 'Bitte gib eine gültige E-Mail-Adresse an.';
            } elseif ($password === '') {
                $error = 'Bitte gib dein Passwort ein.';
            } else {
                $admin = app_platform_admin_by_email($pdo, $email);

                if ($admin === null) {
                    $error = 'Zu dieser E-Mail-Adresse wurde kein Global-Admin gefunden.';
                } elseif (!app_verify_password((string) ($admin['password_hash'] ?? ''), $password)) {
                    $error = 'Das Passwort ist ungültig oder für diesen Global-Admin noch nicht gesetzt.';
                } else {
                    app_set_admin_user([
                        'user_id' => $admin['id'],
                        'email' => $admin['email'],
                        'display_name' => $admin['display_name'],
                        'is_platform_admin' => (int) ($admin['is_platform_admin'] ?? 0) === 1,
                    ]);
                    app_clear_admin_login_state();
                    app_flash('Global-Admin erfolgreich angemeldet.', 'success');
                    app_redirect('/admin/');
                }
            }

            $_SESSION['admin_login_state'] = $state;
        }
    }

    return [
        'state' => $state,
        'message' => $message,
        'error' => $error,
    ];
}

function app_admin_tenant_list(PDO $pdo): array
{
    $licenseSelect = "'free' AS plan_key, 'Free' AS plan_name";
    $licenseJoins = '';

    if (scripts_table_exists($pdo, 'tenant_licenses') && scripts_table_exists($pdo, 'license_plans')) {
        $licenseSelect = "COALESCE(lp.plan_key, 'free') AS plan_key, COALESCE(lp.name, 'Free') AS plan_name";
        $licenseJoins = "\nLEFT JOIN tenant_licenses tl ON tl.tenant_id = t.id AND tl.status = 'active'\nLEFT JOIN license_plans lp ON lp.id = tl.license_plan_id";
    }

    return app_query_all(
        $pdo,
        str_replace(
            ['__LICENSE_SELECT__', '__LICENSE_JOINS__'],
            [$licenseSelect, $licenseJoins],
            <<<'SQL'
SELECT
    t.id,
    t.tenant_key,
    t.name,
    t.status,
    __LICENSE_SELECT__,
    COUNT(DISTINCT CASE WHEN m.status = 'active' THEN m.id END) AS member_count,
    COUNT(DISTINCT CASE WHEN r.role_key = 'tenant_admin' THEN tu.id END) AS admin_count,
    COUNT(DISTINCT CASE WHEN tip.is_enabled = 1 THEN tip.id END) AS provider_count
FROM tenants t
LEFT JOIN tenant_users tu ON tu.tenant_id = t.id AND tu.status = 'active'
LEFT JOIN members m ON m.tenant_id = t.id
LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id
LEFT JOIN roles r ON r.id = tur.role_id
LEFT JOIN tenant_identity_providers tip ON tip.tenant_id = t.id
__LICENSE_JOINS__
GROUP BY t.id, t.tenant_key, t.name, t.status, plan_key, plan_name
ORDER BY t.name ASC
SQL
        )
    );
}

function app_upsert_tenant(PDO $pdo, array $data): void
{
    $tenantKey = strtolower(trim((string) ($data['tenant_key'] ?? '')));
    $name = trim((string) ($data['name'] ?? ''));
    $status = trim((string) ($data['status'] ?? 'active'));
    $tenantId = trim((string) ($data['tenant_id'] ?? ''));
    $planKey = trim((string) ($data['plan_key'] ?? ''));

    if ($tenantKey === '' || !preg_match('/^[a-z0-9-]+$/', $tenantKey)) {
        throw new RuntimeException('Der Mandanten-Key darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.');
    }

    if ($name === '') {
        throw new RuntimeException('Bitte gib einen Namen für den Mandanten an.');
    }

    if (!in_array($status, ['active', 'inactive', 'sandbox'], true)) {
        $status = 'active';
    }

    $existing = app_query_one(
        $pdo,
        'SELECT id FROM tenants WHERE tenant_key = :tenant_key AND (:tenant_id = "" OR id <> :tenant_id) LIMIT 1',
        ['tenant_key' => $tenantKey, 'tenant_id' => $tenantId]
    );

    if ($existing !== null) {
        throw new RuntimeException('Der Mandanten-Key ist bereits vergeben.');
    }

    $now = date('Y-m-d H:i:s');

    if ($tenantId !== '') {
        app_execute(
            $pdo,
            'UPDATE tenants SET tenant_key = :tenant_key, name = :name, status = :status, updated_at = :updated_at WHERE id = :id',
            [
                'tenant_key' => $tenantKey,
                'name' => $name,
                'status' => $status,
                'updated_at' => $now,
                'id' => $tenantId,
            ]
        );

        if ($planKey !== '') {
            app_assign_tenant_license($pdo, $tenantId, $planKey);
        }

        app_flash('Der Mandant wurde aktualisiert.', 'success');

        return;
    }

    $newTenantId = app_uuid();

    app_execute(
        $pdo,
        'INSERT INTO tenants (id, tenant_key, name, status, created_at, updated_at) VALUES (:id, :tenant_key, :name, :status, :created_at, :updated_at)',
        [
            'id' => $newTenantId,
            'tenant_key' => $tenantKey,
            'name' => $name,
            'status' => $status,
            'created_at' => $now,
            'updated_at' => $now,
        ]
    );

    if ($planKey !== '') {
        app_assign_tenant_license($pdo, $newTenantId, $planKey);
    }

    app_flash('Der Mandant wurde angelegt.', 'success');
}

function app_handle_platform_tenant_action(PDO $pdo): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        return;
    }

    $action = (string) ($_POST['action'] ?? '');

    app_require_platform_admin();

    if ($action === 'save-tenant') {
        try {
            app_upsert_tenant($pdo, [
                'tenant_id' => (string) ($_POST['tenant_id'] ?? ''),
                'tenant_key' => (string) ($_POST['tenant_key'] ?? ''),
                'name' => (string) ($_POST['name'] ?? ''),
                'status' => (string) ($_POST['status'] ?? 'active'),
                'plan_key' => (string) ($_POST['plan_key'] ?? ''),
            ]);
        } catch (Throwable $exception) {
            app_flash($exception->getMessage(), 'error');
        }

        app_redirect('/admin/tenants/');
    }

    if ($action === 'switch-tenant') {
        $tenantId = trim((string) ($_POST['tenant_id'] ?? ''));
        $admin = app_require_platform_admin();

        try {
            app_enter_tenant_as_platform_admin($pdo, $admin, $tenantId);
            app_flash('Der Mandant wurde geöffnet. Du arbeitest jetzt mit erweitertem Global-Admin-Zugriff im Mandanten.', 'success');
            app_redirect('/dashboard/');
        } catch (Throwable $exception) {
            app_flash($exception->getMessage(), 'error');
            app_redirect('/admin/tenants/');
        }
    }
}

function app_tenant_by_id(PDO $pdo, string $tenantId): ?array
{
    return app_query_one(
        $pdo,
        'SELECT id, tenant_key, name, status FROM tenants WHERE id = :id LIMIT 1',
        ['id' => $tenantId]
    );
}

function app_enter_tenant_as_platform_admin(PDO $pdo, array $admin, string $tenantId): void
{
    if ($tenantId === '') {
        throw new RuntimeException('Bitte wähle einen Mandanten aus.');
    }

    $tenant = app_tenant_by_id($pdo, $tenantId);

    if ($tenant === null) {
        throw new RuntimeException('Der ausgewählte Mandant konnte nicht gefunden werden.');
    }

    app_set_auth_user([
        'user_id' => $admin['user_id'],
        'email' => $admin['email'],
        'display_name' => $admin['display_name'],
        'theme_mode' => app_normalize_theme_mode((string) ($admin['theme_mode'] ?? 'dark')),
        'is_platform_admin' => true,
        'tenant_id' => $tenant['id'],
        'tenant_key' => $tenant['tenant_key'],
        'tenant_name' => $tenant['name'],
        'tenant_user_id' => null,
        'member_id' => null,
        'roles' => ['tenant_admin'],
        'acting_as_platform_admin' => true,
    ]);
}

function app_user_by_email(PDO $pdo, string $email): ?array
{
    return app_query_one(
        $pdo,
        'SELECT id, email, password_hash, display_name, is_platform_admin FROM users WHERE LOWER(email) = LOWER(:email) LIMIT 1',
        ['email' => $email]
    );
}

function app_members_support_payment_reference(PDO $pdo): bool
{
    return app_table_has_column($pdo, 'members', 'payment_reference');
}

function app_ensure_member_rows_for_tenant(PDO $pdo, string $tenantId): void
{
    if ($tenantId === '' || !scripts_table_exists($pdo, 'members') || !scripts_table_exists($pdo, 'tenant_users')) {
        return;
    }

    $missingRows = app_query_all(
        $pdo,
        <<<'SQL'
SELECT
    tu.id AS tenant_user_id,
    tu.status AS tenant_user_status,
    u.email,
    u.display_name
FROM tenant_users tu
INNER JOIN users u ON u.id = tu.user_id
LEFT JOIN members m ON m.tenant_user_id = tu.id AND m.tenant_id = tu.tenant_id
WHERE tu.tenant_id = :tenant_id
  AND m.id IS NULL
SQL,
        ['tenant_id' => $tenantId]
    );

    if ($missingRows === []) {
        return;
    }

    $now = date('Y-m-d H:i:s');

    foreach ($missingRows as $row) {
        $status = ((string) ($row['tenant_user_status'] ?? 'active')) === 'inactive' ? 'inactive' : 'active';
        $params = [
            'id' => app_uuid(),
            'tenant_id' => $tenantId,
            'tenant_user_id' => (string) ($row['tenant_user_id'] ?? ''),
            'display_name' => trim((string) ($row['display_name'] ?? '')) !== ''
                ? trim((string) ($row['display_name'] ?? ''))
                : trim((string) ($row['email'] ?? 'Mitglied')),
            'email' => strtolower(trim((string) ($row['email'] ?? ''))),
            'status' => $status,
            'created_at' => $now,
            'updated_at' => $now,
        ];

        if (app_members_support_payment_reference($pdo)) {
            $params['payment_reference'] = null;
            app_execute(
                $pdo,
                'INSERT INTO members (id, tenant_id, tenant_user_id, display_name, email, payment_reference, status, created_at, updated_at) VALUES (:id, :tenant_id, :tenant_user_id, :display_name, :email, :payment_reference, :status, :created_at, :updated_at)',
                $params
            );
            continue;
        }

        app_execute(
            $pdo,
            'INSERT INTO members (id, tenant_id, tenant_user_id, display_name, email, status, created_at, updated_at) VALUES (:id, :tenant_id, :tenant_user_id, :display_name, :email, :status, :created_at, :updated_at)',
            $params
        );
    }
}

function app_tenant_user_by_id(PDO $pdo, string $tenantUserId, string $tenantId): ?array
{
    $paymentReferenceSelect = app_members_support_payment_reference($pdo)
        ? 'm.payment_reference AS payment_reference,'
        : 'NULL AS payment_reference,';

    return app_query_one(
        $pdo,
        str_replace(
            '__PAYMENT_REFERENCE__',
            $paymentReferenceSelect,
            <<<'SQL'
SELECT
    tu.id,
    tu.tenant_id,
    tu.user_id,
    tu.status,
    u.email,
    u.display_name,
    u.password_hash,
    m.id AS member_id,
    m.display_name AS member_display_name,
    m.email AS member_email,
    __PAYMENT_REFERENCE__
    m.status AS member_status,
    GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ',') AS role_keys
FROM tenant_users tu
INNER JOIN users u ON u.id = tu.user_id
LEFT JOIN members m ON m.tenant_user_id = tu.id
LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id
LEFT JOIN roles r ON r.id = tur.role_id
WHERE tu.id = :tenant_user_id
  AND tu.tenant_id = :tenant_id
GROUP BY
    tu.id, tu.tenant_id, tu.user_id, tu.status,
    u.email, u.display_name, u.password_hash,
    m.id, m.display_name, m.email, payment_reference, m.status
LIMIT 1
SQL,
        ),
        ['tenant_user_id' => $tenantUserId, 'tenant_id' => $tenantId]
    );
}

function app_membership_row_for_user(PDO $pdo, string $tenantId, string $userId): ?array
{
    $paymentReferenceSelect = app_members_support_payment_reference($pdo)
        ? 'm.payment_reference AS payment_reference,'
        : 'NULL AS payment_reference,';

    return app_query_one(
        $pdo,
        str_replace(
            '__PAYMENT_REFERENCE__',
            $paymentReferenceSelect,
            <<<'SQL'
SELECT
    tu.id,
    tu.tenant_id,
    tu.user_id,
    tu.status,
    u.email,
    u.display_name,
    u.password_hash,
    m.id AS member_id,
    m.display_name AS member_display_name,
    m.email AS member_email,
    __PAYMENT_REFERENCE__
    m.status AS member_status,
    GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ',') AS role_keys
FROM tenant_users tu
INNER JOIN users u ON u.id = tu.user_id
LEFT JOIN members m ON m.tenant_user_id = tu.id
LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id
LEFT JOIN roles r ON r.id = tur.role_id
WHERE tu.tenant_id = :tenant_id
  AND tu.user_id = :user_id
GROUP BY
    tu.id, tu.tenant_id, tu.user_id, tu.status,
    u.email, u.display_name, u.password_hash,
    m.id, m.display_name, m.email, payment_reference, m.status
LIMIT 1
SQL,
        ),
        ['tenant_id' => $tenantId, 'user_id' => $userId]
    );
}

function app_member_form_defaults(?array $member = null): array
{
    $roleKeys = array_filter(array_map('trim', explode(',', (string) ($member['role_keys'] ?? ''))));

    return [
        'tenant_user_id' => (string) ($member['id'] ?? ''),
        'display_name' => (string) ($member['member_display_name'] ?? $member['display_name'] ?? ''),
        'email' => (string) ($member['member_email'] ?? $member['email'] ?? ''),
        'payment_reference' => (string) ($member['payment_reference'] ?? ''),
        'status' => (string) ($member['member_status'] ?? $member['status'] ?? 'active'),
        'password' => '',
        'is_tenant_admin' => in_array('tenant_admin', $roleKeys, true),
        'is_finance_admin' => in_array('finance_admin', $roleKeys, true),
        'is_support_contact' => in_array('support_contact', $roleKeys, true),
        'is_survey_manager' => in_array('survey_manager', $roleKeys, true),
    ];
}

/**
 * @param array<int, string> $targetRoleKeys
 */
function app_sync_tenant_user_roles(PDO $pdo, string $tenantUserId, array $targetRoleKeys): void
{
    scripts_ensure_core_roles($pdo);
    $targetRoleKeys = array_values(array_unique(array_filter($targetRoleKeys)));
    $existingAssignments = app_query_all(
        $pdo,
        <<<'SQL'
SELECT tur.id, r.role_key
FROM tenant_user_roles tur
INNER JOIN roles r ON r.id = tur.role_id
WHERE tur.tenant_user_id = :tenant_user_id
SQL,
        ['tenant_user_id' => $tenantUserId]
    );

    $existingByRole = [];
    foreach ($existingAssignments as $assignment) {
        $existingByRole[(string) ($assignment['role_key'] ?? '')] = (string) ($assignment['id'] ?? '');
    }

    $now = date('Y-m-d H:i:s');

    foreach (['tenant_admin', 'finance_admin', 'support_contact', 'survey_manager'] as $roleKey) {
        $hasRole = in_array($roleKey, $targetRoleKeys, true);
        $assignmentId = $existingByRole[$roleKey] ?? '';

        if ($hasRole && $assignmentId === '') {
            app_execute(
                $pdo,
                'INSERT INTO tenant_user_roles (id, tenant_user_id, role_id, created_at) VALUES (:id, :tenant_user_id, :role_id, :created_at)',
                [
                    'id' => app_uuid(),
                    'tenant_user_id' => $tenantUserId,
                    'role_id' => scripts_role_id($roleKey, 'tenant'),
                    'created_at' => $now,
                ]
            );
        }

        if (!$hasRole && $assignmentId !== '') {
            app_execute(
                $pdo,
                'DELETE FROM tenant_user_roles WHERE id = :id',
                ['id' => $assignmentId]
            );
        }
    }
}

function app_upsert_member(PDO $pdo, string $tenantId, array $data): void
{
    $tenantUserId = trim((string) ($data['tenant_user_id'] ?? ''));
    $displayName = trim((string) ($data['display_name'] ?? ''));
    $email = strtolower(trim((string) ($data['email'] ?? '')));
    $paymentReference = trim((string) ($data['payment_reference'] ?? ''));
    $status = trim((string) ($data['status'] ?? 'active'));
    $password = (string) ($data['password'] ?? '');
    $targetRoleKeys = [];
    foreach (['tenant_admin', 'finance_admin', 'support_contact', 'survey_manager'] as $roleKey) {
        if (!empty($data[$roleKey])) {
            $targetRoleKeys[] = $roleKey;
        }
    }

    if ($displayName === '') {
        throw new RuntimeException('Bitte gib einen Namen für die Person an.');
    }

    if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
        throw new RuntimeException('Bitte gib eine gültige E-Mail-Adresse an.');
    }

    if (!in_array($status, ['active', 'inactive'], true)) {
        $status = 'active';
    }

    $tenant = app_tenant_by_id($pdo, $tenantId);

    if ($tenant === null) {
        throw new RuntimeException('Der Mandant konnte nicht gefunden werden.');
    }

    $existingUser = app_user_by_email($pdo, $email);
    $editingMembership = $tenantUserId !== '' ? app_tenant_user_by_id($pdo, $tenantUserId, $tenantId) : null;

    if ($editingMembership === null && $tenantUserId !== '') {
        throw new RuntimeException('Die ausgewählte Person konnte im Mandanten nicht gefunden werden.');
    }

    if ($existingUser === null && $password === '') {
        throw new RuntimeException('Für neue Personen ist ein Passwort erforderlich, damit die zentrale Anmeldung möglich ist.');
    }

    if ($existingUser !== null && $editingMembership === null) {
        $otherTenantMembership = app_membership_row_for_user($pdo, $tenantId, (string) $existingUser['id']);

        if ($otherTenantMembership !== null) {
            throw new RuntimeException('Diese E-Mail-Adresse ist im Mandanten bereits vorhanden.');
        }
    }

    if ($editingMembership !== null && $existingUser !== null && (string) $existingUser['id'] !== (string) $editingMembership['user_id']) {
        throw new RuntimeException('Diese E-Mail-Adresse gehört bereits zu einer anderen Person im Mandanten.');
    }

    $license = app_tenant_license($pdo, $tenantId);
    $memberLimit = isset($license['member_limit']) && (int) $license['member_limit'] > 0
        ? (int) $license['member_limit']
        : null;

    if ($memberLimit !== null) {
        $activeMembers = (int) app_query_value(
            $pdo,
            'SELECT COUNT(*) FROM members WHERE tenant_id = :tenant_id AND status = :status',
            ['tenant_id' => $tenantId, 'status' => 'active'],
            0
        );
        $wasActive = $editingMembership !== null
            && (string) ($editingMembership['member_status'] ?? $editingMembership['status'] ?? 'active') === 'active';
        $willBeActive = $status === 'active';

        if ($willBeActive && !$wasActive && $activeMembers >= $memberLimit) {
            throw new RuntimeException(
                'Das Mitgliederlimit des Lizenzplans '
                . (string) ($license['plan_name'] ?? 'Free')
                . ' ist erreicht. Aktuell sind maximal '
                . $memberLimit
                . ' aktive Mitglieder möglich.'
            );
        }
    }

    $now = date('Y-m-d H:i:s');

    $pdo->beginTransaction();

    try {
        if ($editingMembership !== null) {
            $userId = (string) $editingMembership['user_id'];

            app_execute(
                $pdo,
                'UPDATE users SET email = :email, display_name = :display_name, updated_at = :updated_at' . ($password !== '' ? ', password_hash = :password_hash' : '') . ' WHERE id = :id',
                array_filter([
                    'email' => $email,
                    'display_name' => $displayName,
                    'updated_at' => $now,
                    'password_hash' => $password !== '' ? password_hash($password, PASSWORD_BCRYPT) : null,
                    'id' => $userId,
                ], static fn($value, $key): bool => $key !== 'password_hash' || $value !== null, ARRAY_FILTER_USE_BOTH)
            );

            app_execute(
                $pdo,
                'UPDATE tenant_users SET status = :status, updated_at = :updated_at WHERE id = :id',
                [
                    'status' => $status,
                    'updated_at' => $now,
                    'id' => $tenantUserId,
                ]
            );

            app_execute(
                $pdo,
                app_members_support_payment_reference($pdo)
                    ? 'UPDATE members SET display_name = :display_name, email = :email, payment_reference = :payment_reference, status = :status, updated_at = :updated_at WHERE tenant_user_id = :tenant_user_id AND tenant_id = :tenant_id'
                    : 'UPDATE members SET display_name = :display_name, email = :email, status = :status, updated_at = :updated_at WHERE tenant_user_id = :tenant_user_id AND tenant_id = :tenant_id',
                array_filter([
                    'display_name' => $displayName,
                    'email' => $email,
                    'payment_reference' => app_members_support_payment_reference($pdo) ? $paymentReference : null,
                    'status' => $status,
                    'updated_at' => $now,
                    'tenant_user_id' => $tenantUserId,
                    'tenant_id' => $tenantId,
                ], static fn($value, $key): bool => $key !== 'payment_reference' || $value !== null, ARRAY_FILTER_USE_BOTH)
            );
        } else {
            if ($existingUser !== null) {
                $userId = (string) $existingUser['id'];

                app_execute(
                    $pdo,
                    'UPDATE users SET display_name = :display_name, updated_at = :updated_at' . ($password !== '' ? ', password_hash = :password_hash' : '') . ' WHERE id = :id',
                    array_filter([
                        'display_name' => $displayName,
                        'updated_at' => $now,
                        'password_hash' => $password !== '' ? password_hash($password, PASSWORD_BCRYPT) : null,
                        'id' => $userId,
                    ], static fn($value, $key): bool => $key !== 'password_hash' || $value !== null, ARRAY_FILTER_USE_BOTH)
                );
            } else {
                $userId = app_uuid();

                app_execute(
                    $pdo,
                    'INSERT INTO users (id, email, password_hash, display_name, is_platform_admin, created_at, updated_at) VALUES (:id, :email, :password_hash, :display_name, 0, :created_at, :updated_at)',
                    [
                        'id' => $userId,
                        'email' => $email,
                        'password_hash' => password_hash($password, PASSWORD_BCRYPT),
                        'display_name' => $displayName,
                        'created_at' => $now,
                        'updated_at' => $now,
                    ]
                );
            }

            $tenantUserId = app_uuid();

            app_execute(
                $pdo,
                'INSERT INTO tenant_users (id, tenant_id, user_id, status, created_at, updated_at) VALUES (:id, :tenant_id, :user_id, :status, :created_at, :updated_at)',
                [
                    'id' => $tenantUserId,
                    'tenant_id' => $tenantId,
                    'user_id' => $userId,
                    'status' => $status,
                    'created_at' => $now,
                    'updated_at' => $now,
                ]
            );

            app_execute(
                $pdo,
                app_members_support_payment_reference($pdo)
                    ? 'INSERT INTO members (id, tenant_id, tenant_user_id, display_name, email, payment_reference, status, created_at, updated_at) VALUES (:id, :tenant_id, :tenant_user_id, :display_name, :email, :payment_reference, :status, :created_at, :updated_at)'
                    : 'INSERT INTO members (id, tenant_id, tenant_user_id, display_name, email, status, created_at, updated_at) VALUES (:id, :tenant_id, :tenant_user_id, :display_name, :email, :status, :created_at, :updated_at)',
                array_filter([
                    'id' => app_uuid(),
                    'tenant_id' => $tenantId,
                    'tenant_user_id' => $tenantUserId,
                    'display_name' => $displayName,
                    'email' => $email,
                    'payment_reference' => app_members_support_payment_reference($pdo) ? $paymentReference : null,
                    'status' => $status,
                    'created_at' => $now,
                    'updated_at' => $now,
                ], static fn($value, $key): bool => $key !== 'payment_reference' || $value !== null, ARRAY_FILTER_USE_BOTH)
            );
        }

        app_sync_tenant_user_roles($pdo, $tenantUserId, $targetRoleKeys);

        $pdo->commit();
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        throw $exception;
    }
}

function app_handle_member_action(PDO $pdo, array $auth): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        return;
    }

    $action = (string) ($_POST['action'] ?? '');
    $tenantId = (string) ($auth['tenant_id'] ?? '');

    if ($action === 'save-member') {
        if (!app_can_manage_tenant($auth)) {
            app_flash('Für diese Aktion brauchst du Tenant-Admin-Rechte.', 'warning');
            app_redirect('/members/');
        }

        try {
            app_upsert_member($pdo, $tenantId, [
                'tenant_user_id' => (string) ($_POST['tenant_user_id'] ?? ''),
                'display_name' => (string) ($_POST['display_name'] ?? ''),
                'email' => (string) ($_POST['email'] ?? ''),
                'payment_reference' => (string) ($_POST['payment_reference'] ?? ''),
                'status' => (string) ($_POST['status'] ?? 'active'),
                'password' => (string) ($_POST['password'] ?? ''),
                'tenant_admin' => isset($_POST['tenant_admin']),
                'finance_admin' => isset($_POST['finance_admin']),
                'support_contact' => isset($_POST['support_contact']),
                'survey_manager' => isset($_POST['survey_manager']),
            ]);
            app_flash('Die Person wurde im Mandanten gespeichert.', 'success');
        } catch (Throwable $exception) {
            app_flash($exception->getMessage(), 'error');
        }

        app_redirect('/members/');
    }

}

function app_handle_settings_action(PDO $pdo, array $auth): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        return;
    }

    if ((string) ($_POST['action'] ?? '') !== 'save-settings') {
        return;
    }

    if (!app_can_manage_tenant($auth)) {
        app_flash('Für diese Aktion brauchst du Tenant-Admin-Rechte.', 'warning');
        app_redirect('/settings/');
    }

    $tenantId = (string) ($auth['tenant_id'] ?? '');

    if (!app_tenant_has_feature($pdo, $tenantId, 'tenant_settings')) {
        app_flash('Die Mandanten-Einstellungen sind in deiner Lizenz nicht freigeschaltet.', 'warning');
        app_redirect('/settings/');
    }

    try {
        app_save_tenant_settings($pdo, $tenantId, $_POST);
        app_flash('Die Mandanten-Einstellungen wurden gespeichert.', 'success');
    } catch (Throwable $exception) {
        app_flash($exception->getMessage(), 'error');
    }

    app_redirect('/settings/');
}

function app_handle_profile_action(PDO $pdo, array $auth): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || (string) ($_POST['action'] ?? '') !== 'save-profile') {
        return;
    }

    $tenantId = (string) ($auth['tenant_id'] ?? '');
    $tenantUserId = (string) ($auth['tenant_user_id'] ?? '');
    $displayName = trim((string) ($_POST['display_name'] ?? ''));
    $password = (string) ($_POST['password'] ?? '');

    if ($tenantId === '' || $tenantUserId === '') {
        app_flash('Das Profil konnte nicht aktualisiert werden.', 'error');
        app_redirect(app_request_path());
    }

    if ($displayName === '') {
        app_flash('Bitte gib einen Anzeigenamen an.', 'error');
        app_redirect(app_request_path());
    }

    $membership = app_tenant_user_by_id($pdo, $tenantUserId, $tenantId);

    if ($membership === null) {
        app_flash('Die zugehörige Person konnte nicht gefunden werden.', 'error');
        app_redirect(app_request_path());
    }

    $now = date('Y-m-d H:i:s');
    $params = [
        'display_name' => $displayName,
        'updated_at' => $now,
        'id' => (string) $membership['user_id'],
    ];
    $sql = 'UPDATE users SET display_name = :display_name, updated_at = :updated_at';

    if ($password !== '') {
        $sql .= ', password_hash = :password_hash';
        $params['password_hash'] = password_hash($password, PASSWORD_BCRYPT);
    }

    $sql .= ' WHERE id = :id';

    app_execute($pdo, $sql, $params);
    app_execute(
        $pdo,
        'UPDATE members SET display_name = :display_name, updated_at = :updated_at WHERE tenant_user_id = :tenant_user_id AND tenant_id = :tenant_id',
        [
            'display_name' => $displayName,
            'updated_at' => $now,
            'tenant_user_id' => $tenantUserId,
            'tenant_id' => $tenantId,
        ]
    );

    app_set_auth_user(array_merge($auth, ['display_name' => $displayName]));
    app_flash('Dein Profil wurde aktualisiert.', 'success');
    app_redirect(app_request_path());
}

function app_handle_theme_action(PDO $pdo, array $auth): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST' || (string) ($_POST['action'] ?? '') !== 'save-theme-mode') {
        return;
    }

    $userId = (string) ($auth['user_id'] ?? '');
    if ($userId === '') {
        app_flash('Das Theme konnte nicht gespeichert werden.', 'error');
        app_redirect(app_request_path());
    }

    $themeMode = app_normalize_theme_mode((string) ($_POST['theme_mode'] ?? 'dark'));

    if (app_users_support_theme_mode($pdo)) {
        app_execute(
            $pdo,
            'UPDATE users SET theme_mode = :theme_mode, updated_at = :updated_at WHERE id = :id',
            [
                'theme_mode' => $themeMode,
                'updated_at' => date('Y-m-d H:i:s'),
                'id' => $userId,
            ]
        );
    }

    app_set_auth_user(array_merge($auth, ['theme_mode' => $themeMode]));
    app_flash('Dein Anzeigemodus wurde gespeichert.', 'success');
    app_redirect(app_request_path());
}

function app_handle_content_action(PDO $pdo, array $auth): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        return;
    }

    $action = (string) ($_POST['action'] ?? '');

    if (!in_array($action, ['save-announcement', 'save-faq', 'archive-announcement', 'archive-faq'], true)) {
        return;
    }

    if (!app_can_manage_tenant($auth)) {
        app_flash('Für diese Aktion brauchst du Tenant-Admin-Rechte.', 'warning');
        app_redirect('/content/');
    }

    $tenantId = (string) ($auth['tenant_id'] ?? '');
    $now = date('Y-m-d H:i:s');

    try {
        if ($action === 'save-announcement') {
            $id = trim((string) ($_POST['announcement_id'] ?? ''));
            $title = trim((string) ($_POST['title'] ?? ''));
            $message = trim((string) ($_POST['message'] ?? ''));
            $visibleUntil = trim((string) ($_POST['visible_until'] ?? ''));
            $visibleUntil = $visibleUntil !== '' ? str_replace('T', ' ', $visibleUntil) . ':00' : null;

            if ($title === '' || $message === '') {
                throw new RuntimeException('Bitte gib Titel und Hinweistext an.');
            }

            if ($id !== '') {
                app_execute(
                    $pdo,
                    'UPDATE announcements SET title = :title, message = :message, visible_until = :visible_until, is_active = 1, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id',
                    [
                        'title' => $title,
                        'message' => $message,
                        'visible_until' => $visibleUntil,
                        'updated_at' => $now,
                        'id' => $id,
                        'tenant_id' => $tenantId,
                    ]
                );
            } else {
                app_execute(
                    $pdo,
                    'INSERT INTO announcements (id, tenant_id, title, message, visible_until, is_active, created_at, updated_at) VALUES (:id, :tenant_id, :title, :message, :visible_until, 1, :created_at, :updated_at)',
                    [
                        'id' => app_uuid(),
                        'tenant_id' => $tenantId,
                        'title' => $title,
                        'message' => $message,
                        'visible_until' => $visibleUntil,
                        'created_at' => $now,
                        'updated_at' => $now,
                    ]
                );
            }

            app_flash('Der Hinweis wurde gespeichert.', 'success');
        }

        if ($action === 'save-faq') {
            $id = trim((string) ($_POST['faq_id'] ?? ''));
            $question = trim((string) ($_POST['question'] ?? ''));
            $answer = trim((string) ($_POST['answer'] ?? ''));
            $sortOrder = max(0, (int) ($_POST['sort_order'] ?? 0));

            if ($question === '' || $answer === '') {
                throw new RuntimeException('Bitte gib Frage und Antwort an.');
            }

            if ($id !== '') {
                app_execute(
                    $pdo,
                    'UPDATE faq_items SET question = :question, answer = :answer, sort_order = :sort_order, is_active = 1, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id',
                    [
                        'question' => $question,
                        'answer' => $answer,
                        'sort_order' => $sortOrder,
                        'updated_at' => $now,
                        'id' => $id,
                        'tenant_id' => $tenantId,
                    ]
                );
            } else {
                app_execute(
                    $pdo,
                    'INSERT INTO faq_items (id, tenant_id, question, answer, sort_order, is_active, created_at, updated_at) VALUES (:id, :tenant_id, :question, :answer, :sort_order, 1, :created_at, :updated_at)',
                    [
                        'id' => app_uuid(),
                        'tenant_id' => $tenantId,
                        'question' => $question,
                        'answer' => $answer,
                        'sort_order' => $sortOrder,
                        'created_at' => $now,
                        'updated_at' => $now,
                    ]
                );
            }

            app_flash('Der FAQ-Eintrag wurde gespeichert.', 'success');
        }

        if ($action === 'archive-announcement') {
            app_execute(
                $pdo,
                'UPDATE announcements SET is_active = 0, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id',
                [
                    'updated_at' => $now,
                    'id' => (string) ($_POST['announcement_id'] ?? ''),
                    'tenant_id' => $tenantId,
                ]
            );
            app_flash('Der Hinweis wurde archiviert.', 'success');
        }

        if ($action === 'archive-faq') {
            app_execute(
                $pdo,
                'UPDATE faq_items SET is_active = 0, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id',
                [
                    'updated_at' => $now,
                    'id' => (string) ($_POST['faq_id'] ?? ''),
                    'tenant_id' => $tenantId,
                ]
            );
            app_flash('Der FAQ-Eintrag wurde archiviert.', 'success');
        }
    } catch (Throwable $exception) {
        app_flash($exception->getMessage(), 'error');
    }

    app_redirect('/content/');
}

function app_handle_export_action(PDO $pdo, array $auth, array $settings): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        return;
    }

    if (!app_can_manage_tenant($auth)) {
        app_flash('Für diese Aktion brauchst du Tenant-Admin-Rechte.', 'warning');
        app_redirect('/exports/');
    }

    $tenantId = (string) ($auth['tenant_id'] ?? '');
    $action = (string) ($_POST['action'] ?? '');

    if ($action === 'create-print-list') {
        if (!app_tenant_has_feature($pdo, $tenantId, 'pdf_export')) {
            app_flash('Der PDF-Export ist in deiner Lizenz nicht freigeschaltet.', 'warning');
            app_redirect('/exports/');
        }

        try {
            app_create_print_list($pdo, $auth, $settings, $_POST);
            app_flash('Die Druckliste wurde erzeugt. Du kannst sie jetzt als PDF speichern oder später ausfüllen.', 'success');
        } catch (Throwable $exception) {
            app_flash($exception->getMessage(), 'error');
        }

        app_redirect('/exports/');
    }

    if ($action === 'apply-print-list') {
        if (!app_tenant_has_feature($pdo, $tenantId, 'paper_strike_entry')) {
            app_flash('Die Papierlisten-Erfassung ist in deiner Lizenz nicht freigeschaltet.', 'warning');
            app_redirect('/exports/');
        }

        try {
            app_apply_print_list($pdo, $auth, $_POST);
            app_flash('Die Striche aus der Druckliste wurden verbucht.', 'success');
        } catch (Throwable $exception) {
            app_flash($exception->getMessage(), 'error');
        }

        app_redirect('/exports/');
    }
}

function app_send_csv_download(string $filename, array $header, array $rows): void
{
    header('Content-Type: text/csv; charset=utf-8');
    header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"');
    header('Cache-Control: no-store, no-cache, must-revalidate');

    $output = fopen('php://output', 'wb');

    if ($output === false) {
        throw new RuntimeException('Die CSV-Datei konnte nicht erstellt werden.');
    }

    fwrite($output, "\xEF\xBB\xBF");
    fputcsv($output, $header, ';');

    foreach ($rows as $row) {
        fputcsv($output, $row, ';');
    }

    fclose($output);
    exit;
}

function app_handle_export_download(PDO $pdo, array $auth): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
        return;
    }

    $download = trim((string) ($_GET['download'] ?? ''));

    if ($download === '') {
        return;
    }

    if (!app_can_manage_tenant($auth)) {
        app_flash('Für diesen Export brauchst du Tenant-Admin-Rechte.', 'warning');
        app_redirect('/exports/');
    }

    $tenantId = (string) ($auth['tenant_id'] ?? '');

    $tenant = app_tenant_by_id($pdo, $tenantId);
    $tenantKey = (string) ($tenant['tenant_key'] ?? 'tenant');
    $dateSuffix = date('Y-m-d');

    if ($download === 'members-csv') {
        if (!app_tenant_has_feature($pdo, $tenantId, 'basic_exports')) {
            app_flash('Die Basis-Exporte sind in deiner Lizenz nicht freigeschaltet.', 'warning');
            app_redirect('/exports/');
        }

        $rows = [];

        foreach (app_members_for_tenant($pdo, $tenantId) as $member) {
            $rows[] = [
                (string) ($member['display_name'] ?? ''),
                (string) ($member['email'] ?? ''),
                (string) ($member['status'] ?? ''),
                (string) ($member['roles'] ?? ''),
            ];
        }

        app_send_csv_download(
            $tenantKey . '-mitglieder-' . $dateSuffix . '.csv',
            ['Name', 'E-Mail', 'Status', 'Rollen'],
            $rows
        );
    }

    if ($download === 'ledger-csv') {
        if (!app_tenant_has_feature($pdo, $tenantId, 'basic_exports')) {
            app_flash('Die Basis-Exporte sind in deiner Lizenz nicht freigeschaltet.', 'warning');
            app_redirect('/exports/');
        }

        $rows = [];

        foreach (app_ledger_for_tenant($pdo, $tenantId) as $entry) {
            $rows[] = [
                (string) ($entry['booked_at'] ?? ''),
                (string) ($entry['member_name'] ?? ''),
                (string) ($entry['entry_type'] ?? ''),
                (string) ($entry['reference_type'] ?? ''),
                number_format((float) ($entry['amount'] ?? 0), 2, '.', ''),
            ];
        }

        app_send_csv_download(
            $tenantKey . '-ledger-' . $dateSuffix . '.csv',
            ['Buchungszeit', 'Mitglied', 'Typ', 'Referenz', 'Betrag'],
            $rows
        );
    }

    if ($download === 'member-report-csv') {
        if (!app_tenant_has_feature($pdo, $tenantId, 'basic_exports')) {
            app_flash('Die Basis-Exporte sind in deiner Lizenz nicht freigeschaltet.', 'warning');
            app_redirect('/exports/');
        }

        $memberId = trim((string) ($_GET['member'] ?? ''));
        $report = app_member_report($pdo, $tenantId, $memberId);

        if ($report === null) {
            app_flash('Bitte wähle zuerst ein Mitglied für die Detailauswertung aus.', 'warning');
            app_redirect('/exports/');
        }

        $rows = [];
        foreach ($report['recent_entries'] ?? [] as $entry) {
            $rows[] = [
                (string) ($report['display_name'] ?? ''),
                (string) ($report['email'] ?? ''),
                (string) ($entry['booked_at'] ?? ''),
                (string) ($entry['entry_type'] ?? ''),
                (string) ($entry['reference_type'] ?? ''),
                number_format((float) ($entry['amount'] ?? 0), 2, '.', ''),
            ];
        }

        app_send_csv_download(
            $tenantKey . '-mitglied-' . preg_replace('/[^A-Za-z0-9._-]+/', '-', strtolower((string) ($report['display_name'] ?? 'report'))) . '-' . $dateSuffix . '.csv',
            ['Mitglied', 'E-Mail', 'Buchungszeit', 'Typ', 'Referenz', 'Betrag'],
            $rows
        );
    }

    if ($download === 'print-list-pdf') {
        if (!app_tenant_has_feature($pdo, $tenantId, 'pdf_export')) {
            app_flash('Der PDF-Export ist in deiner Lizenz nicht freigeschaltet.', 'warning');
            app_redirect('/exports/');
        }

        $listId = trim((string) ($_GET['list'] ?? ''));

        if ($listId === '') {
            app_flash('Bitte wähle zuerst eine Druckliste aus.', 'warning');
            app_redirect('/exports/');
        }

        app_send_print_list_pdf($pdo, $tenantId, $listId);
    }

    app_flash('Der angeforderte Export ist nicht verfügbar.', 'warning');
    app_redirect('/exports/');
}

function app_print_list_export_rows(PDO $pdo, string $tenantId, string $listId): array
{
    return app_query_all(
        $pdo,
        <<<'SQL'
SELECT
    pli.id,
    pli.member_id,
    pli.member_name,
    pli.list_position,
    COALESCE((
        SELECT SUM(le.amount)
        FROM ledger_entries le
        WHERE le.tenant_id = :tenant_id
          AND le.member_id = pli.member_id
    ), 0) AS balance,
    COALESCE((
        SELECT SUM(ce.strokes)
        FROM coffee_entries ce
        WHERE ce.tenant_id = :tenant_id
          AND ce.member_id = pli.member_id
          AND ce.booked_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 100 DAY)
    ), 0) AS recent_strokes
FROM print_list_items pli
INNER JOIN print_lists pl ON pl.id = pli.print_list_id
WHERE pl.tenant_id = :tenant_id
  AND pl.id = :print_list_id
ORDER BY pli.member_name ASC
SQL,
        [
            'tenant_id' => $tenantId,
            'print_list_id' => $listId,
        ]
    );
}

function app_print_list_pdf_groups(PDO $pdo, string $tenantId, string $listId): array
{
    return app_print_list_pdf_groups_with_settings($pdo, $tenantId, $listId, app_tenant_settings($pdo, $tenantId));
}

function app_print_list_row_height_mm(array $settings): float
{
    $height = (float) ($settings['pdf_row_height_mm'] ?? 4.0);

    return max(3.2, min(12.0, $height));
}

function app_print_list_layout(array $allRows, array $frontRows, array $backRows, array $settings): array
{
    $configuredHeight = app_print_list_row_height_mm($settings);
    $maxTableHeightMm = 264.0;
    $minimumHeight = 3.2;
    $singleRequiredHeight = $maxTableHeightMm / max(1, count($allRows));

    if ($singleRequiredHeight >= $minimumHeight) {
        $autoHeight = min(12.0, max($configuredHeight, $singleRequiredHeight));

        return [
            'mode' => 'single',
            'row_height_mm' => $autoHeight,
            'rows_per_page' => max(1, (int) floor($maxTableHeightMm / $autoHeight)),
        ];
    }

    $largestSide = max(1, (int) ceil(count($allRows) / 2));
    $duplexHeight = min($configuredHeight, $maxTableHeightMm / $largestSide);
    $duplexHeight = max($minimumHeight, min(12.0, $duplexHeight));

    return [
        'mode' => 'duplex',
        'row_height_mm' => $duplexHeight,
        'rows_per_page' => max(1, (int) floor($maxTableHeightMm / $duplexHeight)),
    ];
}

function app_print_list_pdf_groups_with_settings(PDO $pdo, string $tenantId, string $listId, array $settings): array
{
    $allRows = app_print_list_export_rows($pdo, $tenantId, $listId);
    $rankedRows = $allRows;

    usort(
        $rankedRows,
        static function (array $left, array $right): int {
            $strokeCompare = (int) ($right['recent_strokes'] ?? 0) <=> (int) ($left['recent_strokes'] ?? 0);

            if ($strokeCompare !== 0) {
                return $strokeCompare;
            }

            return strcasecmp((string) ($left['member_name'] ?? ''), (string) ($right['member_name'] ?? ''));
        }
    );

    $splitIndex = (int) ceil(count($rankedRows) / 2);
    $heavyRows = array_slice($rankedRows, 0, $splitIndex);
    $lightRows = array_slice($rankedRows, $splitIndex);

    $layout = app_print_list_layout($allRows, $heavyRows, $lightRows, $settings);

    if ($layout['mode'] === 'single') {
        return [
            'mode' => 'single',
            'front' => $allRows,
            'back' => [],
            'row_height_mm' => $layout['row_height_mm'],
            'rows_per_page' => $layout['rows_per_page'],
        ];
    }

    return [
        'mode' => 'duplex',
        'front' => $heavyRows,
        'back' => $lightRows,
        'row_height_mm' => $layout['row_height_mm'],
        'rows_per_page' => $layout['rows_per_page'],
    ];
}

function app_pdf_money(float $value): string
{
    return number_format($value, 2, ',', '.') . ' €';
}

function app_print_list_pdf_table(string $headline, string $headerColor, string $headerTextColor, string $metaText, array $rows, int $targetRows, ?string $sideNote = null): string
{
    $html = '
<table border="1" cellpadding="3" style="white-space:nowrap;">
    <tr style="height: 25px; background-color: ' . $headerColor . '; color: ' . $headerTextColor . ';" valign="bottom">
        <th style="width:30%;font-size:13px;" colspan="2"><b>' . app_h($headline) . '</b></th>
        <th style="width:70%;" valign="bottom">' . app_h($metaText) . '</th>
    </tr>
    <tr>
        <td style="width:20%"><b>Name</b></td>
        <td style="width:10%"><b>Guthaben</b></td>
        <td><b>Striche</b></td>
    </tr>';

    $rowCount = 1;

    foreach ($rows as $row) {
        $rowCount++;
        $balance = (float) ($row['balance'] ?? 0);
        $balanceStyle = '';

        if ($balance < -10.0) {
            $balanceStyle = 'background-color: rgb(204, 0, 0); color: rgb(255,255,255);';
        } elseif ($balance > 5.0) {
            $balanceStyle = 'background-color: rgb(0, 102, 0); color: rgb(255,255,255);';
        }

        $html .= '<tr style="height: 16px;">';
        $html .= '<td>' . app_h((string) ($row['member_name'] ?? '')) . '</td>';
        $html .= '<td style="' . $balanceStyle . '">' . app_h(app_pdf_money($balance)) . '</td>';
        $html .= '<td></td>';
        $html .= '</tr>';
    }

    for ($i = $rowCount; $i < $targetRows; $i++) {
        $html .= '<tr><td>&nbsp;</td><td>&nbsp;</td><td>&nbsp;</td></tr>';
    }

    if ($sideNote !== null && $sideNote !== '') {
        $html .= '<tr><td>&nbsp;</td><td>&nbsp;</td><td style="background-color: rgb(255,255,0); text-align: right;"><b>'
            . app_h($sideNote)
            . '</b></td></tr>';
    }

    $html .= '</table>';

    return $html;
}

function app_pdf_mm_to_pt(float $value): float
{
    return $value * 72 / 25.4;
}

function app_pdf_rgb(array $rgb): string
{
    return sprintf('%.3F %.3F %.3F', ($rgb[0] ?? 0) / 255, ($rgb[1] ?? 0) / 255, ($rgb[2] ?? 0) / 255);
}

function app_pdf_escape_text(string $text): string
{
    $encoding = function_exists('iconv') ? @iconv('UTF-8', 'Windows-1252//TRANSLIT', $text) : $text;
    $encoded = is_string($encoding) ? $encoding : $text;

    return str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $encoded);
}

function app_pdf_rect(float $xMm, float $yMm, float $wMm, float $hMm, ?array $fillRgb = null, ?array $strokeRgb = [0, 0, 0]): string
{
    $pageHeightPt = app_pdf_mm_to_pt(297);
    $x = app_pdf_mm_to_pt($xMm);
    $y = $pageHeightPt - app_pdf_mm_to_pt($yMm + $hMm);
    $w = app_pdf_mm_to_pt($wMm);
    $h = app_pdf_mm_to_pt($hMm);
    $commands = '';

    if ($strokeRgb !== null) {
        $commands .= app_pdf_rgb($strokeRgb) . " RG\n";
    }

    if ($fillRgb !== null) {
        $commands .= app_pdf_rgb($fillRgb) . " rg\n";
    }

    $operator = ($fillRgb !== null && $strokeRgb !== null) ? 'B' : (($fillRgb !== null) ? 'f' : 'S');

    return $commands . sprintf("%.2F %.2F %.2F %.2F re %s\n", $x, $y, $w, $h, $operator);
}

function app_pdf_text(float $xMm, float $yMm, string $text, string $font = 'F1', float $size = 10, array $rgb = [0, 0, 0]): string
{
    $pageHeightPt = app_pdf_mm_to_pt(297);
    $x = app_pdf_mm_to_pt($xMm);
    $y = $pageHeightPt - app_pdf_mm_to_pt($yMm);

    return "BT\n"
        . app_pdf_rgb($rgb) . " rg\n"
        . '/' . $font . ' ' . number_format($size, 2, '.', '') . " Tf\n"
        . sprintf("1 0 0 1 %.2F %.2F Tm\n", $x, $y)
        . '(' . app_pdf_escape_text($text) . ") Tj\nET\n";
}

function app_pdf_truncate(string $text, int $maxLength): string
{
    if (function_exists('mb_strlen') && function_exists('mb_substr')) {
        if (mb_strlen($text, 'UTF-8') <= $maxLength) {
            return $text;
        }

        return mb_substr($text, 0, $maxLength - 3, 'UTF-8') . '...';
    }

    if (strlen($text) <= $maxLength) {
        return $text;
    }

    return substr($text, 0, $maxLength - 3) . '...';
}

function app_render_print_list_pdf_page(string $headline, array $headerFill, array $headerText, string $metaText, array $rows, int $rowSlots, float $rowHeightMm, ?string $sideNote = null): string
{
    $commands = "0.4 w\n";
    $x = 5.0;
    $y = 5.0;
    $nameWidth = 40.0;
    $balanceWidth = 20.0;
    $strikeWidth = 140.0;
    $headerHeight = 9.0;
    $subHeaderHeight = 6.5;
    $rowHeight = max(3.2, min(12.0, $rowHeightMm));

    $commands .= app_pdf_rect($x, $y, $nameWidth + $balanceWidth, $headerHeight, $headerFill);
    $commands .= app_pdf_rect($x + $nameWidth + $balanceWidth, $y, $strikeWidth, $headerHeight, $headerFill);
    $commands .= app_pdf_text($x + 2, $y + 5.8, app_pdf_truncate($headline, 34), 'F2', 13, $headerText);
    $commands .= app_pdf_text($x + $nameWidth + $balanceWidth + 2, $y + 5.8, app_pdf_truncate($metaText, 86), 'F1', 9, $headerText);

    $y += $headerHeight;
    $commands .= app_pdf_rect($x, $y, $nameWidth, $subHeaderHeight);
    $commands .= app_pdf_rect($x + $nameWidth, $y, $balanceWidth, $subHeaderHeight);
    $commands .= app_pdf_rect($x + $nameWidth + $balanceWidth, $y, $strikeWidth, $subHeaderHeight);
    $commands .= app_pdf_text($x + 2, $y + 4.4, 'Name', 'F2', 9.5);
    $commands .= app_pdf_text($x + $nameWidth + 2, $y + 4.4, 'Guthaben', 'F2', 9.5);
    $commands .= app_pdf_text($x + $nameWidth + $balanceWidth + 2, $y + 4.4, 'Striche', 'F2', 9.5);
    $y += $subHeaderHeight;

    $noteRows = ($sideNote !== null && $sideNote !== '') ? 1 : 0;
    $dataRows = max(0, $rowSlots - $noteRows);
    $baselineY = min($rowHeight - 1.1, 2.9 + max(0.0, ($rowHeight - 4.0) * 0.45));
    $renderedRows = 0;

    foreach (array_slice($rows, 0, $dataRows) as $row) {
        $renderedRows++;
        $balance = (float) ($row['balance'] ?? 0);
        $balanceFill = null;
        $balanceTextColor = [0, 0, 0];

        if ($balance < -10.0) {
            $balanceFill = [204, 0, 0];
            $balanceTextColor = [255, 255, 255];
        } elseif ($balance > 5.0) {
            $balanceFill = [0, 102, 0];
            $balanceTextColor = [255, 255, 255];
        }

        $commands .= app_pdf_rect($x, $y, $nameWidth, $rowHeight);
        $commands .= app_pdf_rect($x + $nameWidth, $y, $balanceWidth, $rowHeight, $balanceFill);
        $commands .= app_pdf_rect($x + $nameWidth + $balanceWidth, $y, $strikeWidth, $rowHeight);
        $commands .= app_pdf_text($x + 1.5, $y + $baselineY, app_pdf_truncate((string) ($row['member_name'] ?? ''), 28), 'F1', 9);
        $commands .= app_pdf_text($x + $nameWidth + 1.5, $y + $baselineY, app_pdf_money($balance), 'F1', 8.5, $balanceTextColor);
        $y += $rowHeight;
    }

    while ($renderedRows < $dataRows) {
        $commands .= app_pdf_rect($x, $y, $nameWidth, $rowHeight);
        $commands .= app_pdf_rect($x + $nameWidth, $y, $balanceWidth, $rowHeight);
        $commands .= app_pdf_rect($x + $nameWidth + $balanceWidth, $y, $strikeWidth, $rowHeight);
        $y += $rowHeight;
        $renderedRows++;
    }

    if ($sideNote !== null && $sideNote !== '') {
        $commands .= app_pdf_rect($x, $y, $nameWidth, $rowHeight);
        $commands .= app_pdf_rect($x + $nameWidth, $y, $balanceWidth, $rowHeight);
        $commands .= app_pdf_rect($x + $nameWidth + $balanceWidth, $y, $strikeWidth, $rowHeight, [255, 255, 0]);
        $commands .= app_pdf_text($x + $nameWidth + $balanceWidth + 82, $y + $baselineY, $sideNote, 'F2', 8.5);
    }

    return $commands;
}

function app_build_simple_pdf(array $pageStreams): string
{
    $objects = [];
    $objects[1] = "<< /Type /Catalog /Pages 2 0 R >>";
    $objects[3] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
    $objects[4] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>";

    $kids = [];
    $nextObject = 5;

    foreach ($pageStreams as $stream) {
        $pageObject = $nextObject++;
        $contentObject = $nextObject++;
        $kids[] = $pageObject . ' 0 R';
        $objects[$pageObject] = '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ' . $contentObject . ' 0 R >>';
        $objects[$contentObject] = "<< /Length " . strlen($stream) . " >>\nstream\n" . $stream . "\nendstream";
    }

    $objects[2] = '<< /Type /Pages /Kids [' . implode(' ', $kids) . '] /Count ' . count($kids) . ' >>';
    ksort($objects);

    $pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
    $offsets = [];

    foreach ($objects as $number => $object) {
        $offsets[$number] = strlen($pdf);
        $pdf .= $number . " 0 obj\n" . $object . "\nendobj\n";
    }

    $xrefOffset = strlen($pdf);
    $pdf .= "xref\n0 " . (count($objects) + 1) . "\n";
    $pdf .= "0000000000 65535 f \n";

    foreach (range(1, count($objects)) as $number) {
        $pdf .= sprintf("%010d 00000 n \n", $offsets[$number] ?? 0);
    }

    $pdf .= "trailer << /Size " . (count($objects) + 1) . " /Root 1 0 R >>\n";
    $pdf .= "startxref\n" . $xrefOffset . "\n%%EOF";

    return $pdf;
}

function app_send_print_list_pdf(PDO $pdo, string $tenantId, string $listId): void
{
    $list = app_print_list_detail($pdo, $tenantId, $listId);

    if ($list === null) {
        throw new RuntimeException('Die ausgewählte Druckliste wurde nicht gefunden.');
    }

    $tenant = app_tenant_by_id($pdo, $tenantId) ?? ['name' => 'Kaffeeliste'];
    $settings = app_tenant_settings($pdo, $tenantId);
    $groups = app_print_list_pdf_groups_with_settings($pdo, $tenantId, $listId, $settings);
    $frontRows = $groups['front'];
    $backRows = $groups['back'];
    $rowSlots = max(1, (int) ($groups['rows_per_page'] ?? 60));
    $rowHeight = (float) ($groups['row_height_mm'] ?? app_print_list_row_height_mm($settings));

    $unitPrice = (float) ($list['unit_price'] ?? 0.50);
    $meta = '1 Strich = ' . app_pdf_money($unitPrice) . '. Bitte spätestens bei 10,00 € einzahlen. '
        . date('d.m.Y H:i') . ' | ' . (string) ($tenant['name'] ?? 'Kaffeeliste');
    $pageStreams = [];

    if (($groups['mode'] ?? 'single') === 'single') {
        $pageStreams[] = app_render_print_list_pdf_page(
            (string) ($list['title'] ?? ($settings['pdf_list_title'] ?? 'Kaffeeliste')),
            [0, 94, 63],
            [255, 255, 255],
            $meta,
            $frontRows,
            $rowSlots,
            $rowHeight
        );
    } else {
        $pageStreams[] = app_render_print_list_pdf_page(
            (string) ($settings['front_page_label'] ?? 'Vorderseite') . ' - Vieltrinker',
            [0, 94, 63],
            [255, 255, 255],
            $meta,
            $frontRows,
            $rowSlots,
            $rowHeight,
            $backRows !== [] ? 'Rückseite beachten!' : null
        );
        $pageStreams[] = app_render_print_list_pdf_page(
            (string) ($settings['back_page_label'] ?? 'Rückseite') . ' - Wenigtrinker',
            [91, 209, 215],
            [0, 0, 0],
            $meta,
            $backRows,
            $rowSlots,
            $rowHeight,
            $frontRows !== [] ? 'Vorderseite beachten!' : null
        );
    }

    $filename = preg_replace('/[^A-Za-z0-9._-]+/', '-', (string) ($list['title'] ?? 'Kaffeestrichliste'));
    $filename = trim((string) $filename, '-');
    $filename = $filename !== '' ? $filename : 'Kaffeestrichliste';

    header('Content-Type: application/pdf');
    header('Content-Disposition: attachment; filename="' . $filename . '.pdf"');
    header('Cache-Control: no-store, no-cache, must-revalidate');
    echo app_build_simple_pdf($pageStreams);
    exit;
}

function app_memberships_by_email(PDO $pdo, string $email): array
{
    $themeSelect = app_users_support_theme_mode($pdo)
        ? 'u.theme_mode,'
        : "'dark' AS theme_mode,";
    $sql = <<<'SQL'
SELECT
    u.id AS user_id,
    u.email AS user_email,
    u.password_hash,
    __THEME_SELECT__
    u.display_name,
    u.is_platform_admin,
    t.id AS tenant_id,
    t.tenant_key,
    t.name AS tenant_name,
    t.status AS tenant_status,
    tu.id AS tenant_user_id,
    tu.status AS tenant_user_status,
    m.id AS member_id,
    m.email AS member_email,
    COUNT(DISTINCT tip.id) AS oidc_provider_count,
    GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ', ') AS role_keys
FROM users u
INNER JOIN tenant_users tu ON tu.user_id = u.id
INNER JOIN tenants t ON t.id = tu.tenant_id
LEFT JOIN members m ON m.tenant_user_id = tu.id
LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id
LEFT JOIN roles r ON r.id = tur.role_id
LEFT JOIN tenant_identity_providers tip ON tip.tenant_id = t.id AND tip.is_enabled = 1
WHERE LOWER(u.email) = LOWER(:email) OR LOWER(COALESCE(m.email, '')) = LOWER(:email)
GROUP BY
    u.id, u.email, u.password_hash, theme_mode, u.display_name, u.is_platform_admin,
    t.id, t.tenant_key, t.name, t.status,
    tu.id, tu.status, m.id, m.email
ORDER BY t.name ASC
SQL;

    return app_query_all($pdo, str_replace('__THEME_SELECT__', $themeSelect, $sql), ['email' => $email]);
}

function app_verify_password(?string $hash, string $password): bool
{
    if ($hash === null || trim($hash) === '') {
        return false;
    }

    return password_verify($password, $hash);
}

function app_handle_login(PDO $pdo): array
{
    $state = app_login_state();
    $message = null;
    $error = null;

    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
        $action = (string) ($_POST['action'] ?? 'discover');

        if ($action === 'discover') {
            $email = strtolower(trim((string) ($_POST['email'] ?? '')));

            if ($email === '') {
                $error = 'Bitte gib zuerst deine E-Mail-Adresse ein.';
            } else {
                $memberships = app_memberships_by_email($pdo, $email);

                if ($memberships === []) {
                    $error = 'Zu dieser E-Mail-Adresse wurde kein Zugang gefunden. Bitte prüfe deine Einladung oder kontaktiere den Support.';
                    app_clear_login_state();
                } elseif (count($memberships) === 1) {
                    app_set_login_state([
                        'step' => 'password',
                        'email' => $email,
                        'selected_membership' => $memberships[0],
                    ]);
                    $state = app_login_state();
                    $message = 'Dein Bereich wurde erkannt. Bitte gib jetzt dein Passwort ein.';
                } else {
                    app_set_login_state([
                        'step' => 'choose-tenant',
                        'email' => $email,
                        'memberships' => $memberships,
                    ]);
                    $state = app_login_state();
                    $message = 'Zu deiner E-Mail-Adresse wurden mehrere Bereiche gefunden. Bitte wähle zuerst den passenden Bereich aus.';
                }
            }
        }

        if ($action === 'choose-tenant') {
            $tenantUserId = (string) ($_POST['tenant_user_id'] ?? '');
            $state = app_login_state();
            $memberships = $state['memberships'] ?? [];
            $selected = null;

            foreach ($memberships as $membership) {
                if (($membership['tenant_user_id'] ?? '') === $tenantUserId) {
                    $selected = $membership;
                    break;
                }
            }

            if (!is_array($selected)) {
                $error = 'Die ausgewählte Zuordnung konnte nicht gefunden werden.';
            } else {
                app_set_login_state([
                    'step' => 'password',
                    'email' => $state['email'] ?? ($selected['user_email'] ?? ''),
                    'selected_membership' => $selected,
                    'memberships' => $memberships,
                ]);
                $state = app_login_state();
                $message = 'Bereich ausgewählt. Bitte gib jetzt dein Passwort ein.';
            }
        }

        if ($action === 'authenticate') {
            $password = (string) ($_POST['password'] ?? '');
            $state = app_login_state();
            $selected = $state['selected_membership'] ?? null;

            if (!is_array($selected)) {
                $error = 'Der Anmeldekontext ist abgelaufen. Bitte starte die Anmeldung erneut mit deiner E-Mail-Adresse.';
            } elseif ($password === '') {
                $error = 'Bitte gib jetzt dein Passwort ein.';
            } else {
                $passwordHash = $selected['password_hash'] ?? null;

                if (!app_verify_password(is_string($passwordHash) ? $passwordHash : null, $password)) {
                    $error = 'Das Passwort konnte nicht bestätigt werden oder für dieses Benutzerkonto ist noch kein Passwort gesetzt.';
                } else {
                    app_finalize_login($selected);
                    app_flash('Anmeldung erfolgreich. Willkommen in ' . ($selected['tenant_name'] ?? 'deinem Bereich') . '.', 'success');
                    app_clear_login_state();
                    app_redirect('/dashboard/');
                }
            }
        }
    }

    return [
        'state' => $state,
        'message' => $message,
        'error' => $error,
    ];
}

function app_finalize_login(array $membership): void
{
    app_set_auth_user([
        'user_id' => $membership['user_id'],
        'email' => $membership['user_email'],
        'display_name' => $membership['display_name'],
        'theme_mode' => app_normalize_theme_mode((string) ($membership['theme_mode'] ?? 'dark')),
        'is_platform_admin' => (int) ($membership['is_platform_admin'] ?? 0) === 1,
        'tenant_id' => $membership['tenant_id'],
        'tenant_key' => $membership['tenant_key'],
        'tenant_name' => $membership['tenant_name'],
        'tenant_user_id' => $membership['tenant_user_id'],
        'member_id' => $membership['member_id'],
        'roles' => array_filter(array_map('trim', explode(',', (string) ($membership['role_keys'] ?? '')))),
    ]);
}

function app_tenant_overview(PDO $pdo): array
{
    $metrics = [
        ['label' => 'Aktive Tenants', 'value' => '0', 'detail' => 'Mandanten mit aktivem Produktbetrieb.'],
        ['label' => 'Mitglieder gesamt', 'value' => '0', 'detail' => 'Aktive Mitgliedschaften über alle Tenants.'],
        ['label' => 'SSO-Abdeckung', 'value' => '0', 'detail' => 'Aktive OIDC-Provider für zentrale oder tenantbezogene Anmeldungen.'],
        ['label' => 'Betriebsstatus', 'value' => 'Live', 'detail' => 'Die zentrale Runtime läuft direkt aus `public/`.'],
    ];

    $metricSql = <<<'SQL'
SELECT
    COUNT(DISTINCT CASE WHEN t.status = 'active' THEN t.id END) AS active_tenants,
    COUNT(DISTINCT m.id) AS member_count,
    COUNT(DISTINCT CASE WHEN tip.is_enabled = 1 THEN tip.id END) AS provider_count
FROM tenants t
LEFT JOIN members m ON m.tenant_id = t.id AND m.status = 'active'
LEFT JOIN tenant_identity_providers tip ON tip.tenant_id = t.id
SQL;

    $metricRow = app_query_one($pdo, $metricSql) ?? [];
    $metrics[0]['value'] = (string) ($metricRow['active_tenants'] ?? '0');
    $metrics[1]['value'] = (string) ($metricRow['member_count'] ?? '0');
    $metrics[2]['value'] = (string) ($metricRow['provider_count'] ?? '0');

    $tenantSql = <<<'SQL'
SELECT
    t.id,
    t.tenant_key,
    t.name,
    t.status,
    CONCAT(t.tenant_key, '.', REPLACE(:host, 'www.', '')) AS domain,
    COUNT(DISTINCT CASE WHEN m.status = 'active' THEN m.id END) AS member_count,
    COUNT(DISTINCT CASE WHEN r.role_key LIKE '%admin%' THEN tu.id END) AS admin_count,
    COUNT(DISTINCT CASE WHEN tip.is_enabled = 1 THEN tip.id END) AS oidc_provider_count
FROM tenants t
LEFT JOIN tenant_users tu ON tu.tenant_id = t.id AND tu.status = 'active'
LEFT JOIN members m ON m.tenant_id = t.id
LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id
LEFT JOIN roles r ON r.id = tur.role_id
LEFT JOIN tenant_identity_providers tip ON tip.tenant_id = t.id
GROUP BY t.id, t.tenant_key, t.name, t.status
ORDER BY t.name ASC
SQL;

    $host = (string) ($_SERVER['HTTP_HOST'] ?? 'kaffeeliste.local');
    $tenants = app_query_all($pdo, $tenantSql, ['host' => $host]);

    foreach ($tenants as &$tenant) {
        $tenant['login_mode'] = ((int) $tenant['oidc_provider_count']) > 0 ? 'oidc-first' : 'password-fallback';
        $tenant['primary_contact'] = 'Tenant Admin';
    }
    unset($tenant);

    $sharedAccess = [];
    $sharedSql = <<<'SQL'
SELECT
    base.email,
    agg.tenant_count,
    GROUP_CONCAT(base.tenant_name ORDER BY base.tenant_name SEPARATOR ', ') AS tenant_names
FROM (
    SELECT DISTINCT
        LOWER(COALESCE(m.email, u.email)) AS email,
        t.name AS tenant_name
    FROM users u
    INNER JOIN tenant_users tu ON tu.user_id = u.id
    INNER JOIN tenants t ON t.id = tu.tenant_id
    LEFT JOIN members m ON m.tenant_user_id = tu.id
    WHERE tu.status = 'active'
) base
INNER JOIN (
    SELECT
        LOWER(COALESCE(m.email, u.email)) AS email,
        COUNT(DISTINCT t.id) AS tenant_count
    FROM users u
    INNER JOIN tenant_users tu ON tu.user_id = u.id
    INNER JOIN tenants t ON t.id = tu.tenant_id
    LEFT JOIN members m ON m.tenant_user_id = tu.id
    WHERE tu.status = 'active'
    GROUP BY LOWER(COALESCE(m.email, u.email))
) agg ON agg.email = base.email
GROUP BY base.email, agg.tenant_count
ORDER BY agg.tenant_count DESC, base.email ASC
LIMIT 6
SQL;

    foreach (app_query_all($pdo, $sharedSql) as $row) {
        $tenantCount = (int) ($row['tenant_count'] ?? 0);
        $sharedAccess[] = [
            'email' => $row['email'],
            'tenants' => $row['tenant_names'] !== null ? explode(', ', (string) $row['tenant_names']) : [],
            'next_step' => $tenantCount > 1 ? 'Tenant-Auswahl vor der finalen Weiterleitung anzeigen' : 'Direkt in den zugeordneten Tenant weiterleiten',
            'status' => $tenantCount > 1 ? 'mehrfach' : 'einzeln',
        ];
    }

    return [
        'metrics' => $metrics,
        'tenants' => $tenants,
        'shared_access' => $sharedAccess,
        'operations' => [
            [
                'title' => 'Zentrale Anmeldung zuverlässig steuern',
                'detail' => 'Tenant-Erkennung, Mehrfachzuordnung und Passwort- oder SSO-Wege bleiben an einer Stelle gepflegt.',
                'state' => 'Live',
            ],
            [
                'title' => 'Tenant-Verwaltung im Betrieb ausbauen',
                'detail' => 'Dashboard, Mitglieder, Buchungen und Einzahlungen wachsen Schritt für Schritt zur vollständigen Verwaltungsoberfläche.',
                'state' => 'Nächster Schritt',
            ],
            [
                'title' => 'Marketing und Onboarding schärfen',
                'detail' => 'Landingpage, Testzugang und Nutzenversprechen sprechen Betreiber und Mitglieder klarer an.',
                'state' => 'Laufend',
            ],
        ],
    ];
}

function app_finance_entries_support_status(PDO $pdo, string $table): bool
{
    return scripts_table_exists($pdo, $table)
        && app_table_has_column($pdo, $table, 'status');
}

function app_finance_entry_active_sql(PDO $pdo, string $table, string $alias): string
{
    return app_finance_entries_support_status($pdo, $table)
        ? 'COALESCE(' . $alias . '.status, \'booked\') = \'booked\''
        : '1 = 1';
}

function app_tenant_dashboard(PDO $pdo, string $tenantId): array
{
    $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce');
    $paymentActiveSql = app_finance_entry_active_sql($pdo, 'payment_entries', 'pe');
    $summarySql = <<<'SQL'
SELECT
    COUNT(DISTINCT CASE WHEN m.status = 'active' THEN m.id END) AS active_members,
    COALESCE(SUM(CASE WHEN le.entry_type LIKE 'coffee%' THEN ABS(le.amount) ELSE 0 END), 0) AS coffee_volume,
    COALESCE(SUM(CASE WHEN le.entry_type LIKE 'payment%' THEN le.amount ELSE 0 END), 0) AS payment_volume,
    COALESCE(SUM(le.amount), 0) AS open_balance
FROM tenants t
LEFT JOIN members m ON m.tenant_id = t.id
LEFT JOIN ledger_entries le ON le.tenant_id = t.id
WHERE t.id = :tenant_id
GROUP BY t.id
SQL;

    $summary = app_query_one($pdo, $summarySql, ['tenant_id' => $tenantId]) ?? [];

    $yearSummary = app_query_one(
        $pdo,
        str_replace(
            ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'],
            [$coffeeActiveSql, $paymentActiveSql],
            <<<'SQL'
SELECT
    COALESCE((
        SELECT SUM(ce.strokes)
        FROM coffee_entries ce
        WHERE ce.tenant_id = :tenant_id
          AND __COFFEE_ACTIVE__
          AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE())
    ), 0) AS year_strokes,
    COALESCE((
        SELECT SUM(ce.total_cost)
        FROM coffee_entries ce
        WHERE ce.tenant_id = :tenant_id
          AND __COFFEE_ACTIVE__
          AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE())
    ), 0) AS year_spend,
    COALESCE((
        SELECT SUM(pe.amount)
        FROM payment_entries pe
        WHERE pe.tenant_id = :tenant_id
          AND __PAYMENT_ACTIVE__
          AND YEAR(pe.booked_at) = YEAR(CURRENT_DATE())
    ), 0) AS year_payments,
    COALESCE((
        SELECT COUNT(DISTINCT ce.member_id)
        FROM coffee_entries ce
        WHERE ce.tenant_id = :tenant_id
          AND __COFFEE_ACTIVE__
          AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE())
    ), 0) AS year_active_members
SQL,
        ),
        ['tenant_id' => $tenantId]
    ) ?? [];

    $recentSql = <<<'SQL'
SELECT
    le.booked_at,
    le.entry_type,
    le.amount,
    le.reference_type,
    m.display_name AS member_name
FROM ledger_entries le
LEFT JOIN members m ON m.id = le.member_id
WHERE le.tenant_id = :tenant_id
ORDER BY le.booked_at DESC
LIMIT 10
SQL;

    $memberOverviewSql = str_replace(
        ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'],
        [$coffeeActiveSql, $paymentActiveSql],
        <<<'SQL'
SELECT
    m.id,
    m.display_name,
    COALESCE(SUM(le.amount), 0) AS balance,
    COALESCE((
        SELECT SUM(ce.strokes)
        FROM coffee_entries ce
        WHERE ce.member_id = m.id
          AND ce.tenant_id = m.tenant_id
          AND __COFFEE_ACTIVE__
          AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE())
    ), 0) AS year_strokes,
    COALESCE((
        SELECT SUM(pe.amount)
        FROM payment_entries pe
        WHERE pe.member_id = m.id
          AND pe.tenant_id = m.tenant_id
          AND __PAYMENT_ACTIVE__
          AND YEAR(pe.booked_at) = YEAR(CURRENT_DATE())
    ), 0) AS year_payments,
    MAX(le.booked_at) AS last_booking_at
FROM members m
LEFT JOIN ledger_entries le ON le.member_id = m.id AND le.tenant_id = m.tenant_id
WHERE m.tenant_id = :tenant_id
  AND m.status = 'active'
GROUP BY m.id, m.display_name
ORDER BY balance ASC, m.display_name ASC
LIMIT 12
SQL
    );

    $memberRows = app_query_all($pdo, $memberOverviewSql, ['tenant_id' => $tenantId]);
    $largestDebtors = array_values(array_filter(
        $memberRows,
        static fn(array $row): bool => (float) ($row['balance'] ?? 0) < 0
    ));
    usort(
        $largestDebtors,
        static fn(array $left, array $right): int => ((float) ($left['balance'] ?? 0) <=> (float) ($right['balance'] ?? 0))
    );
    $largestDebtors = array_slice($largestDebtors, 0, 3);

    $largestCredits = array_values(array_filter(
        $memberRows,
        static fn(array $row): bool => (float) ($row['balance'] ?? 0) > 0
    ));
    usort(
        $largestCredits,
        static fn(array $left, array $right): int => ((float) ($right['balance'] ?? 0) <=> (float) ($left['balance'] ?? 0))
    );
    $largestCredits = array_slice($largestCredits, 0, 3);

    return [
        'active_members' => (string) ($summary['active_members'] ?? '0'),
        'coffee_volume' => (string) ($summary['coffee_volume'] ?? '0.00'),
        'payment_volume' => (string) ($summary['payment_volume'] ?? '0.00'),
        'open_balance' => (string) ($summary['open_balance'] ?? '0.00'),
        'year_strokes' => (string) ($yearSummary['year_strokes'] ?? '0'),
        'year_spend' => (string) ($yearSummary['year_spend'] ?? '0.00'),
        'year_payments' => (string) ($yearSummary['year_payments'] ?? '0.00'),
        'year_active_members' => (string) ($yearSummary['year_active_members'] ?? '0'),
        'member_rows' => $memberRows,
        'largest_debtors' => $largestDebtors,
        'largest_credits' => $largestCredits,
        'recent_entries' => app_query_all($pdo, $recentSql, ['tenant_id' => $tenantId]),
    ];
}

function app_member_summary(PDO $pdo, array $auth): array
{
    $tenantId = $auth['tenant_id'];
    $memberId = $auth['member_id'];
    $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce');
    $paymentActiveSql = app_finance_entry_active_sql($pdo, 'payment_entries', 'pe');

    if ($memberId === null || $memberId === '') {
        return [
            'balance' => '0.00',
            'coffee_strokes_this_month' => '0',
            'payments_this_month' => '0',
            'latest_booking_at' => null,
            'recent_entries' => [],
        ];
    }

    $summarySql = str_replace(
        ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'],
        [$coffeeActiveSql, $paymentActiveSql],
        <<<'SQL'
SELECT
    COALESCE(SUM(le.amount), 0) AS balance,
    (
        SELECT COALESCE(SUM(ce.strokes), 0)
        FROM coffee_entries ce
        WHERE ce.member_id = :member_id
          AND ce.tenant_id = :tenant_id
          AND __COFFEE_ACTIVE__
          AND DATE_FORMAT(ce.booked_at, '%Y-%m') = DATE_FORMAT(CURRENT_DATE(), '%Y-%m')
    ) AS coffee_strokes_this_month,
    (
        SELECT COUNT(*)
        FROM payment_entries pe
        WHERE pe.member_id = :member_id
          AND pe.tenant_id = :tenant_id
          AND __PAYMENT_ACTIVE__
          AND DATE_FORMAT(pe.booked_at, '%Y-%m') = DATE_FORMAT(CURRENT_DATE(), '%Y-%m')
    ) AS payments_this_month,
    MAX(le.booked_at) AS latest_booking_at
FROM ledger_entries le
WHERE le.member_id = :member_id
  AND le.tenant_id = :tenant_id
SQL
    );

    $summary = app_query_one($pdo, $summarySql, [
        'member_id' => $memberId,
        'tenant_id' => $tenantId,
    ]) ?? [];

    $recentSql = <<<'SQL'
SELECT
    le.booked_at,
    le.entry_type,
    le.amount,
    le.reference_type
FROM ledger_entries le
WHERE le.member_id = :member_id
  AND le.tenant_id = :tenant_id
ORDER BY le.booked_at DESC
LIMIT 8
SQL;

    $summary['recent_entries'] = app_query_all($pdo, $recentSql, [
        'member_id' => $memberId,
        'tenant_id' => $tenantId,
    ]);

    return $summary;
}

function app_member_report(PDO $pdo, string $tenantId, string $memberId): ?array
{
    if ($memberId === '') {
        return null;
    }

    $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce');
    $paymentActiveSql = app_finance_entry_active_sql($pdo, 'payment_entries', 'pe');

    $member = app_query_one(
        $pdo,
        'SELECT id, display_name, email, status FROM members WHERE tenant_id = :tenant_id AND id = :id LIMIT 1',
        ['tenant_id' => $tenantId, 'id' => $memberId]
    );

    if ($member === null) {
        return null;
    }

    $member['summary'] = app_query_one(
        $pdo,
        str_replace(
            ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'],
            [$coffeeActiveSql, $paymentActiveSql],
            <<<'SQL'
SELECT
    COALESCE(SUM(le.amount), 0) AS balance,
    COALESCE((
        SELECT SUM(ce.strokes)
        FROM coffee_entries ce
        WHERE ce.tenant_id = :tenant_id
          AND ce.member_id = :member_id
          AND __COFFEE_ACTIVE__
    ), 0) AS total_strokes,
    COALESCE((
        SELECT SUM(ce.total_cost)
        FROM coffee_entries ce
        WHERE ce.tenant_id = :tenant_id
          AND ce.member_id = :member_id
          AND __COFFEE_ACTIVE__
    ), 0) AS total_coffee_cost,
    COALESCE((
        SELECT SUM(pe.amount)
        FROM payment_entries pe
        WHERE pe.tenant_id = :tenant_id
          AND pe.member_id = :member_id
          AND __PAYMENT_ACTIVE__
    ), 0) AS total_payments,
    COALESCE((
        SELECT SUM(ce.strokes)
        FROM coffee_entries ce
        WHERE ce.tenant_id = :tenant_id
          AND ce.member_id = :member_id
          AND __COFFEE_ACTIVE__
          AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE())
    ), 0) AS year_strokes,
    COALESCE((
        SELECT SUM(ce.total_cost)
        FROM coffee_entries ce
        WHERE ce.tenant_id = :tenant_id
          AND ce.member_id = :member_id
          AND __COFFEE_ACTIVE__
          AND YEAR(ce.booked_at) = YEAR(CURRENT_DATE())
    ), 0) AS year_coffee_cost,
    COALESCE((
        SELECT SUM(pe.amount)
        FROM payment_entries pe
        WHERE pe.tenant_id = :tenant_id
          AND pe.member_id = :member_id
          AND __PAYMENT_ACTIVE__
          AND YEAR(pe.booked_at) = YEAR(CURRENT_DATE())
    ), 0) AS year_payments
FROM ledger_entries le
WHERE le.tenant_id = :tenant_id
  AND le.member_id = :member_id
SQL,
        ),
        ['tenant_id' => $tenantId, 'member_id' => $memberId]
    ) ?? [];

    $member['recent_entries'] = app_query_all(
        $pdo,
        <<<'SQL'
SELECT booked_at, entry_type, amount, reference_type
FROM ledger_entries
WHERE tenant_id = :tenant_id
  AND member_id = :member_id
ORDER BY booked_at DESC
LIMIT 20
SQL,
        ['tenant_id' => $tenantId, 'member_id' => $memberId]
    );

    return $member;
}

function app_year_end_year_range(int $year): array
{
    $year = max(1970, $year);

    return [
        sprintf('%04d-01-01 00:00:00', $year),
        sprintf('%04d-01-01 00:00:00', $year + 1),
    ];
}

function app_year_end_member_rows(PDO $pdo, string $tenantId, int $year): array
{
    [$yearStart, $yearEnd] = app_year_end_year_range($year);
    $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce');
    $paymentActiveSql = app_finance_entry_active_sql($pdo, 'payment_entries', 'pe');

    return app_query_all(
        $pdo,
        str_replace(
            ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'],
            [$coffeeActiveSql, $paymentActiveSql],
            <<<'SQL'
SELECT
    m.id,
    m.display_name,
    m.email,
    COALESCE(SUM(le.amount), 0) AS current_balance,
    COALESCE((
        SELECT SUM(ce.strokes)
        FROM coffee_entries ce
        WHERE ce.tenant_id = m.tenant_id
          AND ce.member_id = m.id
          AND __COFFEE_ACTIVE__
          AND ce.booked_at >= :year_start
          AND ce.booked_at < :year_end
    ), 0) AS year_strokes,
    COALESCE((
        SELECT SUM(ce.total_cost)
        FROM coffee_entries ce
        WHERE ce.tenant_id = m.tenant_id
          AND ce.member_id = m.id
          AND __COFFEE_ACTIVE__
          AND ce.booked_at >= :year_start
          AND ce.booked_at < :year_end
    ), 0) AS year_cost,
    COALESCE((
        SELECT SUM(pe.amount)
        FROM payment_entries pe
        WHERE pe.tenant_id = m.tenant_id
          AND pe.member_id = m.id
          AND __PAYMENT_ACTIVE__
          AND pe.booked_at >= :year_start
          AND pe.booked_at < :year_end
    ), 0) AS year_payments
FROM members m
LEFT JOIN ledger_entries le ON le.tenant_id = m.tenant_id AND le.member_id = m.id
WHERE m.tenant_id = :tenant_id
  AND m.status = 'active'
GROUP BY m.id, m.display_name, m.email
ORDER BY m.display_name ASC
SQL,
        ),
        [
            'tenant_id' => $tenantId,
            'year_start' => $yearStart,
            'year_end' => $yearEnd,
        ]
    );
}

/**
 * @param array<int, array<string, mixed>> $rows
 * @return array{allocations: array<int, int>, total_cents: int}
 */
function app_year_end_allocate_amounts(array $rows, string $mode, int $poolCents, int $flatCents): array
{
    $allocations = [];
    $totalCents = 0;

    if ($mode === 'flat') {
        foreach ($rows as $index => $_row) {
            $allocations[(int) $index] = $flatCents;
            $totalCents += $flatCents;
        }

        return ['allocations' => $allocations, 'total_cents' => $totalCents];
    }

    $totalStrokes = 0;
    foreach ($rows as $row) {
        $totalStrokes += max(0, (int) ($row['year_strokes'] ?? 0));
    }

    if ($totalStrokes <= 0) {
        foreach (array_keys($rows) as $index) {
            $allocations[(int) $index] = 0;
        }

        return ['allocations' => $allocations, 'total_cents' => 0];
    }

    $remainders = [];
    foreach ($rows as $index => $row) {
        $strokes = max(0, (int) ($row['year_strokes'] ?? 0));
        $rawCents = ($poolCents * $strokes) / $totalStrokes;
        $floorCents = (int) floor($rawCents);
        $allocations[(int) $index] = $floorCents;
        $remainders[(int) $index] = $rawCents - $floorCents;
        $totalCents += $floorCents;
    }

    $remaining = max(0, $poolCents - $totalCents);
    arsort($remainders);
    foreach (array_keys($remainders) as $index) {
        if ($remaining <= 0) {
            break;
        }

        $allocations[(int) $index]++;
        $remaining--;
        $totalCents++;
    }

    return ['allocations' => $allocations, 'total_cents' => $totalCents];
}

function app_year_end_run_exists(PDO $pdo, string $tenantId, int $year): ?array
{
    if (!scripts_table_exists($pdo, 'year_end_runs')) {
        return null;
    }

    return app_query_one(
        $pdo,
        'SELECT id, year, status, executed_at FROM year_end_runs WHERE tenant_id = :tenant_id AND year = :year LIMIT 1',
        [
            'tenant_id' => $tenantId,
            'year' => $year,
        ]
    );
}

function app_year_end_preview(PDO $pdo, string $tenantId, array $input): array
{
    $year = (int) ($input['year'] ?? ((int) date('Y') - 1));
    if ($year < 2000) {
        $year = (int) date('Y') - 1;
    }

    $mode = (string) ($input['bonus_mode'] ?? 'proportional');
    if (!in_array($mode, ['proportional', 'flat'], true)) {
        $mode = 'proportional';
    }

    $poolAmount = max(0.0, (float) ($input['bonus_pool_amount'] ?? 0));
    $flatAmount = max(0.0, (float) ($input['bonus_per_member_amount'] ?? 0));
    $bookedAt = app_normalize_datetime_input((string) ($input['booked_at'] ?? ''));
    $notes = trim((string) ($input['notes'] ?? ''));

    $rows = app_year_end_member_rows($pdo, $tenantId, $year);
    if ($rows === []) {
        throw new RuntimeException('Für den gewählten Zeitraum wurden keine aktiven Mitglieder gefunden.');
    }

    if ($mode === 'proportional' && $poolAmount <= 0) {
        throw new RuntimeException('Bitte gib für die proportionale Verteilung einen Bonus-Topf größer 0 an.');
    }

    if ($mode === 'flat' && $flatAmount <= 0) {
        throw new RuntimeException('Bitte gib für die fixe Verteilung einen Bonus pro Mitglied größer 0 an.');
    }

    $totalStrokes = 0;
    foreach ($rows as $row) {
        $totalStrokes += max(0, (int) ($row['year_strokes'] ?? 0));
    }

    if ($mode === 'proportional' && $totalStrokes <= 0) {
        throw new RuntimeException('Für die proportionale Verteilung braucht es mindestens einen Jahresstrich.');
    }

    $poolCents = (int) round($poolAmount * 100);
    $flatCents = (int) round($flatAmount * 100);
    $allocationResult = app_year_end_allocate_amounts($rows, $mode, $poolCents, $flatCents);
    $allocations = $allocationResult['allocations'];
    $assignedCents = $allocationResult['total_cents'];
    $recipientCount = 0;
    $previewRows = [];

    foreach ($rows as $index => $row) {
        $strokes = max(0, (int) ($row['year_strokes'] ?? 0));
        $bonusCents = (int) ($allocations[(int) $index] ?? 0);
        $currentBalance = (float) ($row['current_balance'] ?? 0);
        $bonusAmount = $bonusCents / 100;

        if ($bonusCents > 0) {
            $recipientCount++;
        }

        $sharePercent = $mode === 'proportional' && $totalStrokes > 0
            ? (($strokes / $totalStrokes) * 100)
            : null;

        $previewRows[] = [
            'member_id' => (string) ($row['id'] ?? ''),
            'display_name' => (string) ($row['display_name'] ?? ''),
            'email' => (string) ($row['email'] ?? ''),
            'year_strokes' => $strokes,
            'year_cost' => (float) ($row['year_cost'] ?? 0),
            'year_payments' => (float) ($row['year_payments'] ?? 0),
            'current_balance' => $currentBalance,
            'bonus_cents' => $bonusCents,
            'bonus_amount' => number_format($bonusAmount, 2, '.', ''),
            'projected_balance' => number_format($currentBalance + $bonusAmount, 2, '.', ''),
            'share_percent' => $sharePercent === null ? null : number_format($sharePercent, 2, '.', ''),
        ];
    }

    return [
        'year' => $year,
        'mode' => $mode,
        'bonus_pool_amount' => number_format($poolAmount, 2, '.', ''),
        'bonus_per_member_amount' => number_format($flatAmount, 2, '.', ''),
        'booked_at' => $bookedAt,
        'notes' => $notes,
        'total_strokes' => $totalStrokes,
        'member_count' => count($rows),
        'recipient_count' => $recipientCount,
        'total_amount' => number_format($assignedCents / 100, 2, '.', ''),
        'rows' => $previewRows,
    ];
}

function app_year_end_execute(PDO $pdo, array $auth, array $input): array
{
    $tenantId = (string) ($auth['tenant_id'] ?? '');
    $userId = (string) ($auth['user_id'] ?? '');

    if ($tenantId === '' || $userId === '') {
        throw new RuntimeException('Der Jahresabschluss konnte nicht gestartet werden.');
    }

    if (!scripts_table_exists($pdo, 'year_end_runs')) {
        throw new RuntimeException('Die Jahresabschluss-Tabelle fehlt noch. Bitte die Migrationen ausführen.');
    }

    $preview = app_year_end_preview($pdo, $tenantId, $input);

    if (app_year_end_run_exists($pdo, $tenantId, (int) $preview['year']) !== null) {
        throw new RuntimeException('Für dieses Jahr wurde bereits ein Jahresabschluss verbucht.');
    }

    $runId = app_uuid();
    $now = date('Y-m-d H:i:s');
    $paymentCount = 0;

    $pdo->beginTransaction();

    try {
        app_execute(
            $pdo,
            <<<'SQL'
INSERT INTO year_end_runs (
    id,
    tenant_id,
    year,
    bonus_mode,
    bonus_pool_amount,
    bonus_per_member_amount,
    booked_at,
    total_strokes,
    recipient_count,
    total_amount,
    preview_json,
    notes,
    status,
    created_by_user_id,
    created_at,
    updated_at,
    executed_at
) VALUES (
    :id,
    :tenant_id,
    :year,
    :bonus_mode,
    :bonus_pool_amount,
    :bonus_per_member_amount,
    :booked_at,
    :total_strokes,
    :recipient_count,
    :total_amount,
    :preview_json,
    :notes,
    :status,
    :created_by_user_id,
    :created_at,
    :updated_at,
    :executed_at
)
SQL,
            [
                'id' => $runId,
                'tenant_id' => $tenantId,
                'year' => (int) $preview['year'],
                'bonus_mode' => (string) $preview['mode'],
                'bonus_pool_amount' => $preview['bonus_pool_amount'],
                'bonus_per_member_amount' => $preview['bonus_per_member_amount'],
                'booked_at' => $preview['booked_at'],
                'total_strokes' => (int) $preview['total_strokes'],
                'recipient_count' => (int) $preview['recipient_count'],
                'total_amount' => $preview['total_amount'],
                'preview_json' => json_encode($preview, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
                'notes' => (string) ($preview['notes'] ?? ''),
                'status' => 'executed',
                'created_by_user_id' => $userId,
                'created_at' => $now,
                'updated_at' => $now,
                'executed_at' => $now,
            ]
        );

        foreach (($preview['rows'] ?? []) as $row) {
            $bonus = (float) ($row['bonus_amount'] ?? 0);
            if ($bonus <= 0) {
                continue;
            }

            app_create_payment_booking(
                $pdo,
                $tenantId,
                (string) ($row['member_id'] ?? ''),
                $bonus,
                (string) $preview['booked_at'],
                'year_end_bonus'
            );
            $paymentCount++;
        }

        $pdo->commit();
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        throw $exception;
    }

    return [
        'run_id' => $runId,
        'preview' => $preview,
        'payment_count' => $paymentCount,
    ];
}

function app_year_end_runs_for_tenant(PDO $pdo, string $tenantId): array
{
    if (!scripts_table_exists($pdo, 'year_end_runs')) {
        return [];
    }

    return app_query_all(
        $pdo,
        <<<'SQL'
SELECT
    yer.id,
    yer.year,
    yer.bonus_mode,
    yer.bonus_pool_amount,
    yer.bonus_per_member_amount,
    yer.total_strokes,
    yer.recipient_count,
    yer.total_amount,
    yer.status,
    yer.booked_at,
    yer.executed_at,
    yer.created_at,
    u.display_name AS created_by_name
FROM year_end_runs yer
LEFT JOIN users u ON u.id = yer.created_by_user_id
WHERE yer.tenant_id = :tenant_id
ORDER BY yer.year DESC, yer.created_at DESC
LIMIT 12
SQL,
        ['tenant_id' => $tenantId]
    );
}

function app_members_for_tenant(PDO $pdo, string $tenantId): array
{
    app_ensure_member_rows_for_tenant($pdo, $tenantId);

    $paymentReferenceSelect = app_members_support_payment_reference($pdo)
        ? 'm.payment_reference,'
        : 'NULL AS payment_reference,';

    $tenantUserSql = <<<'SQL'
SELECT
    m.id,
    tu.id AS tenant_user_id,
    tu.user_id,
    COALESCE(m.display_name, u.display_name) AS display_name,
    COALESCE(m.email, u.email) AS email,
    __PAYMENT_REFERENCE__
    COALESCE(m.status, tu.status) AS status,
    u.display_name AS user_display_name,
    GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS roles,
    GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ',') AS role_keys,
    COALESCE((
        SELECT SUM(le.amount)
        FROM ledger_entries le
        WHERE le.member_id = m.id
          AND le.tenant_id = tu.tenant_id
    ), 0) AS current_balance
FROM tenant_users tu
INNER JOIN users u ON u.id = tu.user_id
LEFT JOIN members m ON m.tenant_user_id = tu.id AND m.tenant_id = tu.tenant_id
LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id
LEFT JOIN roles r ON r.id = tur.role_id
WHERE tu.tenant_id = :tenant_id
GROUP BY m.id, tu.id, tu.user_id, m.display_name, m.email, payment_reference, m.status, tu.status, u.display_name, u.email
ORDER BY COALESCE(m.display_name, u.display_name) ASC
SQL;

    $memberOnlySql = <<<'SQL'
SELECT
    m.id,
    m.tenant_user_id,
    tu.user_id,
    m.display_name,
    m.email,
    __PAYMENT_REFERENCE__
    m.status,
    u.display_name AS user_display_name,
    GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS roles,
    GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ',') AS role_keys,
    COALESCE((
        SELECT SUM(le.amount)
        FROM ledger_entries le
        WHERE le.member_id = m.id
          AND le.tenant_id = m.tenant_id
    ), 0) AS current_balance
FROM members m
LEFT JOIN tenant_users tu ON tu.id = m.tenant_user_id AND tu.tenant_id = m.tenant_id
LEFT JOIN users u ON u.id = tu.user_id
LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id
LEFT JOIN roles r ON r.id = tur.role_id
WHERE m.tenant_id = :tenant_id
  AND (
    m.tenant_user_id IS NULL
    OR tu.id IS NULL
  )
GROUP BY m.id, m.tenant_user_id, tu.user_id, m.display_name, m.email, payment_reference, m.status, u.display_name
ORDER BY m.display_name ASC
SQL;

    $rows = app_query_all(
        $pdo,
        str_replace('__PAYMENT_REFERENCE__', $paymentReferenceSelect, $tenantUserSql),
        ['tenant_id' => $tenantId]
    );

    foreach (
        app_query_all(
            $pdo,
            str_replace('__PAYMENT_REFERENCE__', $paymentReferenceSelect, $memberOnlySql),
            ['tenant_id' => $tenantId]
        ) as $row
    ) {
        $rows[] = $row;
    }

    $deduped = [];
    foreach ($rows as $row) {
        $id = trim((string) ($row['id'] ?? ''));
        $tenantUserId = trim((string) ($row['tenant_user_id'] ?? ''));
        $email = strtolower(trim((string) ($row['email'] ?? '')));
        $displayName = trim((string) ($row['display_name'] ?? ''));
        $key = $id !== ''
            ? 'member:' . $id
            : ($tenantUserId !== ''
                ? 'tenant-user:' . $tenantUserId
                : ($email !== '' ? 'email:' . $email : 'name:' . $displayName));

        if (!isset($deduped[$key])) {
            $deduped[$key] = $row;
            continue;
        }

        $existing = $deduped[$key];
        $existingHasMemberId = trim((string) ($existing['id'] ?? '')) !== '';
        $rowHasMemberId = $id !== '';
        if (!$existingHasMemberId && $rowHasMemberId) {
            $deduped[$key] = $row;
        }
    }

    $result = array_values($deduped);
    usort(
        $result,
        static fn(array $left, array $right): int => strcasecmp(
            trim((string) ($left['display_name'] ?? '')),
            trim((string) ($right['display_name'] ?? ''))
        )
    );

    return $result;
}

function app_normalize_datetime_input(?string $value): string
{
    $value = trim((string) $value);

    if ($value === '') {
        return date('Y-m-d H:i:s');
    }

    $value = str_replace('T', ' ', $value);

    if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $value) === 1) {
        return $value . ':00';
    }

    return $value;
}

function app_member_detail(PDO $pdo, string $tenantId, string $memberId): ?array
{
    $member = app_member_exists($pdo, $tenantId, $memberId);
    $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce');
    $paymentActiveSql = app_finance_entry_active_sql($pdo, 'payment_entries', 'pe');

    if ($member === null) {
        return null;
    }

    $summary = app_query_one(
        $pdo,
        str_replace(
            ['__COFFEE_ACTIVE__', '__PAYMENT_ACTIVE__'],
            [$coffeeActiveSql, $paymentActiveSql],
            <<<'SQL'
SELECT
    COALESCE(SUM(le.amount), 0) AS balance,
    COALESCE((
        SELECT SUM(ce.total_cost)
        FROM coffee_entries ce
        WHERE ce.member_id = :member_id
          AND ce.tenant_id = :tenant_id
          AND __COFFEE_ACTIVE__
    ), 0) AS total_spend,
    COALESCE((
        SELECT SUM(ce.strokes)
        FROM coffee_entries ce
        WHERE ce.member_id = :member_id
          AND ce.tenant_id = :tenant_id
          AND __COFFEE_ACTIVE__
    ), 0) AS total_strokes,
    COALESCE((
        SELECT SUM(pe.amount)
        FROM payment_entries pe
        WHERE pe.member_id = :member_id
          AND pe.tenant_id = :tenant_id
          AND __PAYMENT_ACTIVE__
    ), 0) AS total_payments
FROM ledger_entries le
WHERE le.member_id = :member_id
  AND le.tenant_id = :tenant_id
SQL,
        ),
        ['member_id' => $memberId, 'tenant_id' => $tenantId]
    ) ?? [];

    $member['summary'] = $summary;
    $member['recent_entries'] = app_query_all(
        $pdo,
        <<<'SQL'
SELECT booked_at, entry_type, amount, reference_type
FROM ledger_entries
WHERE tenant_id = :tenant_id
  AND member_id = :member_id
ORDER BY booked_at DESC
LIMIT 20
SQL,
        ['tenant_id' => $tenantId, 'member_id' => $memberId]
    );

    return $member;
}

function app_members_for_scope(PDO $pdo, string $tenantId, string $scope = 'all'): array
{
    $coffeeActiveSql = app_finance_entry_active_sql($pdo, 'coffee_entries', 'ce');
    $members = array_values(array_filter(
        app_members_for_tenant($pdo, $tenantId),
        static function (array $member): bool {
            $status = strtolower(trim((string) ($member['status'] ?? 'active')));
            return !in_array($status, ['inactive', 'disabled', 'archived'], true);
        }
    ));

    if ($members === []) {
        return [];
    }

    $recentStrokeRows = app_query_all(
        $pdo,
        str_replace(
            '__COFFEE_ACTIVE__',
            $coffeeActiveSql,
            <<<'SQL'
SELECT
    ce.member_id,
    COALESCE(SUM(ce.strokes), 0) AS recent_strokes
FROM coffee_entries ce
WHERE ce.tenant_id = :tenant_id
  AND __COFFEE_ACTIVE__
  AND ce.booked_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 100 DAY)
GROUP BY ce.member_id
SQL
        ),
        ['tenant_id' => $tenantId]
    );

    $recentStrokeMap = [];
    foreach ($recentStrokeRows as $row) {
        $recentStrokeMap[(string) ($row['member_id'] ?? '')] = (int) ($row['recent_strokes'] ?? 0);
    }

    $rows = array_map(
        static function (array $member) use ($recentStrokeMap): array {
            $member['recent_strokes'] = $recentStrokeMap[(string) ($member['id'] ?? '')] ?? 0;
            return $member;
        },
        $members
    );

    if ($scope === 'front') {
        return array_values(array_filter($rows, static fn(array $row): bool => (int) ($row['recent_strokes'] ?? 0) >= 10));
    }

    if ($scope === 'back') {
        return array_values(array_filter($rows, static fn(array $row): bool => (int) ($row['recent_strokes'] ?? 0) < 10));
    }

    return $rows;
}

function app_paypal_amount_links(array $settings, float $balance): array
{
    $baseUrl = trim((string) (($settings['paypal_me_link'] ?? '') !== '' ? $settings['paypal_me_link'] : ($settings['paypal_me_url'] ?? '')));

    if ($baseUrl === '') {
        return [];
    }

    $baseUrl = rtrim($baseUrl, '/');
    $links = [];
    $fixedAmounts = preg_split('/[\s,;]+/', (string) ($settings['paypal_fixed_amounts'] ?? '2,5,10')) ?: [];

    foreach ($fixedAmounts as $amount) {
        if (!is_numeric($amount) || (float) $amount <= 0) {
            continue;
        }

        $normalized = rtrim(rtrim(number_format((float) $amount, 2, '.', ''), '0'), '.');
        $links[] = [
            'label' => $normalized . ' EUR',
            'href' => $baseUrl . '/' . $normalized . 'EUR',
        ];
    }

    if ($balance < 0) {
        $openAmount = number_format(abs($balance), 2, '.', '');
        $links[] = [
            'label' => 'Offenen Saldo zahlen',
            'href' => $baseUrl . '/' . rtrim(rtrim($openAmount, '0'), '.') . 'EUR',
        ];
    }

    return $links;
}

function app_ledger_for_tenant(PDO $pdo, string $tenantId): array
{
    $coffeeSupportsStatus = app_finance_entries_support_status($pdo, 'coffee_entries');
    $paymentSupportsStatus = app_finance_entries_support_status($pdo, 'payment_entries');

    $coffeeStatusSelect = $coffeeSupportsStatus
        ? 'ce.status AS coffee_reference_status,'
        : "'booked' AS coffee_reference_status,";
    $coffeeCancelledSelect = $coffeeSupportsStatus
        ? 'ce.cancelled_at AS coffee_reference_cancelled_at,'
        : 'NULL AS coffee_reference_cancelled_at,';
    $paymentStatusSelect = $paymentSupportsStatus
        ? 'pe.status AS payment_reference_status, pe.cancelled_at AS payment_cancelled_at,'
        : "'booked' AS payment_reference_status, NULL AS payment_cancelled_at,";
    $referenceCancelledAtSql = match (true) {
        $coffeeSupportsStatus && $paymentSupportsStatus => 'COALESCE(ce.cancelled_at, pe.cancelled_at) AS reference_cancelled_at,',
        $coffeeSupportsStatus => 'ce.cancelled_at AS reference_cancelled_at,',
        $paymentSupportsStatus => 'pe.cancelled_at AS reference_cancelled_at,',
        default => 'NULL AS reference_cancelled_at,',
    };

    $referenceStatusSql = $coffeeSupportsStatus || $paymentSupportsStatus
        ? <<<'SQL'
CASE
    WHEN le.reference_type = 'coffee_entry' THEN COALESCE(ce.status, 'booked')
    WHEN le.reference_type = 'payment_entry' THEN COALESCE(pe.status, 'booked')
    ELSE 'booked'
END AS reference_status
SQL
        : "'booked' AS reference_status";

    $sql = str_replace(
        ['__COFFEE_STATUS__', '__COFFEE_CANCELLED__', '__PAYMENT_STATUS__', '__REFERENCE_STATUS__', '__REFERENCE_CANCELLED_AT__'],
        [$coffeeStatusSelect, $coffeeCancelledSelect, $paymentStatusSelect, $referenceStatusSql, $referenceCancelledAtSql],
        <<<'SQL'
SELECT
    le.id,
    le.booked_at,
    le.entry_type,
    le.amount,
    le.reference_type,
    le.reference_id,
    m.display_name AS member_name,
    __COFFEE_STATUS__
    __COFFEE_CANCELLED__
    __PAYMENT_STATUS__
    __REFERENCE_CANCELLED_AT__
    __REFERENCE_STATUS__
FROM ledger_entries le
LEFT JOIN members m ON m.id = le.member_id
LEFT JOIN coffee_entries ce ON le.reference_type = 'coffee_entry' AND ce.id = le.reference_id AND ce.tenant_id = le.tenant_id
LEFT JOIN payment_entries pe ON le.reference_type = 'payment_entry' AND pe.id = le.reference_id AND pe.tenant_id = le.tenant_id
WHERE le.tenant_id = :tenant_id
ORDER BY le.booked_at DESC
LIMIT 25
SQL
    );

    return app_query_all($pdo, $sql, ['tenant_id' => $tenantId]);
}

function app_payments_for_tenant(PDO $pdo, string $tenantId): array
{
    $paymentStatusSelect = app_finance_entries_support_status($pdo, 'payment_entries')
        ? 'pe.status, pe.cancelled_at, pe.cancellation_reason'
        : "'booked' AS status, NULL AS cancelled_at, NULL AS cancellation_reason";

    $sql = str_replace(
        '__PAYMENT_STATUS__',
        $paymentStatusSelect,
        <<<'SQL'
SELECT
    pe.id,
    pe.booked_at,
    pe.amount,
    pe.payment_method,
    m.display_name AS member_name,
    pe.member_id,
    __PAYMENT_STATUS__
FROM payment_entries pe
LEFT JOIN members m ON m.id = pe.member_id
WHERE pe.tenant_id = :tenant_id
ORDER BY pe.booked_at DESC
LIMIT 25
SQL
    );

    return app_query_all($pdo, $sql, ['tenant_id' => $tenantId]);
}

function app_delete_coffee_entry(PDO $pdo, string $tenantId, string $referenceId, ?string $actorMemberId = null, ?string $reason = null): void
{
    if ($referenceId === '') {
        throw new RuntimeException('Der Kaffeeeintrag konnte nicht zugeordnet werden.');
    }

    if (!app_finance_entries_support_status($pdo, 'coffee_entries')) {
        $pdo->beginTransaction();

        try {
            app_execute(
                $pdo,
                'DELETE FROM ledger_entries WHERE tenant_id = :tenant_id AND reference_type = :reference_type AND reference_id = :reference_id',
                [
                    'tenant_id' => $tenantId,
                    'reference_type' => 'coffee_entry',
                    'reference_id' => $referenceId,
                ]
            );

            app_execute(
                $pdo,
                'DELETE FROM coffee_entries WHERE tenant_id = :tenant_id AND id = :id',
                [
                    'tenant_id' => $tenantId,
                    'id' => $referenceId,
                ]
            );

            $pdo->commit();
        } catch (Throwable $exception) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }

            throw $exception;
        }

        return;
    }

    $entry = app_query_one(
        $pdo,
        'SELECT id, member_id, total_cost, booked_at, status FROM coffee_entries WHERE tenant_id = :tenant_id AND id = :id LIMIT 1',
        ['tenant_id' => $tenantId, 'id' => $referenceId]
    );

    if ($entry === null) {
        throw new RuntimeException('Der Kaffeeeintrag konnte nicht gefunden werden.');
    }

    if ((string) ($entry['status'] ?? 'booked') === 'cancelled') {
        throw new RuntimeException('Dieser Kaffeeeintrag wurde bereits storniert.');
    }

    $pdo->beginTransaction();

    try {
        $ledgerId = app_uuid();
        $now = date('Y-m-d H:i:s');
        $amount = abs((float) ($entry['total_cost'] ?? 0));

        app_execute(
            $pdo,
            'UPDATE coffee_entries SET status = :status, cancelled_at = :cancelled_at, cancelled_by_member_id = :cancelled_by_member_id, cancellation_reason = :cancellation_reason, cancellation_ledger_entry_id = :cancellation_ledger_entry_id, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id',
            [
                'status' => 'cancelled',
                'cancelled_at' => $now,
                'cancelled_by_member_id' => $actorMemberId !== '' ? $actorMemberId : null,
                'cancellation_reason' => $reason !== null && trim($reason) !== '' ? trim($reason) : 'manual_reversal',
                'cancellation_ledger_entry_id' => $ledgerId,
                'updated_at' => $now,
                'tenant_id' => $tenantId,
                'id' => $referenceId,
            ]
        );

        app_execute(
            $pdo,
            'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)',
            [
                'id' => $ledgerId,
                'tenant_id' => $tenantId,
                'member_id' => (string) ($entry['member_id'] ?? ''),
                'entry_type' => 'coffee_cancellation',
                'amount' => number_format($amount, 2, '.', ''),
                'reference_type' => 'coffee_cancellation',
                'reference_id' => $referenceId,
                'booked_at' => $now,
                'created_at' => $now,
                'updated_at' => $now,
            ]
        );

        $pdo->commit();
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        throw $exception;
    }
}

function app_delete_payment_entry(PDO $pdo, string $tenantId, string $paymentId, ?string $actorMemberId = null, ?string $reason = null): void
{
    if ($paymentId === '') {
        throw new RuntimeException('Die Einzahlung konnte nicht zugeordnet werden.');
    }

    if (!app_finance_entries_support_status($pdo, 'payment_entries')) {
        $pdo->beginTransaction();

        try {
            app_execute(
                $pdo,
                'DELETE FROM ledger_entries WHERE tenant_id = :tenant_id AND reference_type = :reference_type AND reference_id = :reference_id',
                [
                    'tenant_id' => $tenantId,
                    'reference_type' => 'payment_entry',
                    'reference_id' => $paymentId,
                ]
            );

            app_execute(
                $pdo,
                'DELETE FROM payment_entries WHERE tenant_id = :tenant_id AND id = :id',
                [
                    'tenant_id' => $tenantId,
                    'id' => $paymentId,
                ]
            );

            $pdo->commit();
        } catch (Throwable $exception) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }

            throw $exception;
        }

        return;
    }

    $entry = app_query_one(
        $pdo,
        'SELECT id, member_id, amount, booked_at, status FROM payment_entries WHERE tenant_id = :tenant_id AND id = :id LIMIT 1',
        ['tenant_id' => $tenantId, 'id' => $paymentId]
    );

    if ($entry === null) {
        throw new RuntimeException('Die Einzahlung konnte nicht gefunden werden.');
    }

    if ((string) ($entry['status'] ?? 'booked') === 'cancelled') {
        throw new RuntimeException('Diese Einzahlung wurde bereits storniert.');
    }

    $pdo->beginTransaction();

    try {
        $ledgerId = app_uuid();
        $now = date('Y-m-d H:i:s');
        $amount = abs((float) ($entry['amount'] ?? 0));

        app_execute(
            $pdo,
            'UPDATE payment_entries SET status = :status, cancelled_at = :cancelled_at, cancelled_by_member_id = :cancelled_by_member_id, cancellation_reason = :cancellation_reason, cancellation_ledger_entry_id = :cancellation_ledger_entry_id, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id',
            [
                'status' => 'cancelled',
                'cancelled_at' => $now,
                'cancelled_by_member_id' => $actorMemberId !== '' ? $actorMemberId : null,
                'cancellation_reason' => $reason !== null && trim($reason) !== '' ? trim($reason) : 'manual_reversal',
                'cancellation_ledger_entry_id' => $ledgerId,
                'updated_at' => $now,
                'tenant_id' => $tenantId,
                'id' => $paymentId,
            ]
        );

        app_execute(
            $pdo,
            'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)',
            [
                'id' => $ledgerId,
                'tenant_id' => $tenantId,
                'member_id' => (string) ($entry['member_id'] ?? ''),
                'entry_type' => 'payment_reversal',
                'amount' => number_format($amount * -1, 2, '.', ''),
                'reference_type' => 'payment_cancellation',
                'reference_id' => $paymentId,
                'booked_at' => $now,
                'created_at' => $now,
                'updated_at' => $now,
            ]
        );

        $pdo->commit();
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        throw $exception;
    }
}

function app_content_for_tenant(PDO $pdo, string $tenantId): array
{
    $announcements = scripts_table_exists($pdo, 'announcements')
        ? app_query_all(
            $pdo,
            'SELECT id, title, message, visible_until, is_active FROM announcements WHERE tenant_id = :tenant_id ORDER BY is_active DESC, created_at DESC LIMIT 12',
            ['tenant_id' => $tenantId]
        )
        : [];

    $faq = scripts_table_exists($pdo, 'faq_items')
        ? app_query_all(
            $pdo,
            'SELECT id, question, answer, sort_order, is_active FROM faq_items WHERE tenant_id = :tenant_id ORDER BY is_active DESC, sort_order ASC, created_at DESC LIMIT 12',
            ['tenant_id' => $tenantId]
        )
        : [];

    return [
        'announcements' => $announcements,
        'faq' => $faq,
    ];
}

function app_notifications_supports_storage(PDO $pdo): bool
{
    return scripts_table_exists($pdo, 'notification_messages');
}

function app_notification_scope_label(string $scope): string
{
    return match ($scope) {
        'front' => 'Vorderseite',
        'back' => 'Rückseite',
        default => 'Alle Mitglieder',
    };
}

function app_notification_channel_label(string $channel): string
{
    return match ($channel) {
        'inapp' => 'In-App',
        'sms' => 'SMS',
        default => 'E-Mail',
    };
}

function app_notification_status_label(string $status): array
{
    return match ($status) {
        'sent' => ['label' => 'Gesendet', 'tone' => 'success'],
        'scheduled' => ['label' => 'Geplant', 'tone' => 'warning'],
        'draft' => ['label' => 'Entwurf', 'tone' => 'neutral'],
        'failed' => ['label' => 'Fehler', 'tone' => 'danger'],
        default => ['label' => ucfirst($status), 'tone' => 'neutral'],
    };
}

function app_notifications_demo_entries(string $tenantId): array
{
    $now = date('Y-m-d H:i:s');

    $rows = [
        [
            'id' => 'demo-notification-1',
            'tenant_id' => $tenantId,
            'title' => 'Saldo-Erinnerung',
            'message' => 'Alle Mitglieder mit negativem Kontostand erhalten eine freundliche Erinnerung.',
            'channel' => 'email',
            'recipient_scope' => 'back',
            'recipient_count' => 6,
            'status' => 'scheduled',
            'scheduled_at' => $now,
            'sent_at' => null,
            'created_at' => $now,
            'updated_at' => $now,
        ],
        [
            'id' => 'demo-notification-2',
            'tenant_id' => $tenantId,
            'title' => 'Service-Hinweis',
            'message' => 'Wichtige Hinweise zum Monatsabschluss werden am gleichen Tag an alle Mitglieder verschickt.',
            'channel' => 'inapp',
            'recipient_scope' => 'all',
            'recipient_count' => 18,
            'status' => 'draft',
            'scheduled_at' => null,
            'sent_at' => null,
            'created_at' => $now,
            'updated_at' => $now,
        ],
    ];

    return array_map(
        static function (array $row): array {
            $status = app_notification_status_label((string) ($row['status'] ?? 'draft'));

            return array_merge($row, [
                'scope_label' => app_notification_scope_label((string) ($row['recipient_scope'] ?? 'all')),
                'channel_label' => app_notification_channel_label((string) ($row['channel'] ?? 'email')),
                'status_label' => $status['label'],
                'status_tone' => $status['tone'],
            ]);
        },
        $rows
    );
}

function app_notifications_for_tenant(PDO $pdo, string $tenantId): array
{
    if (!app_notifications_supports_storage($pdo)) {
        return app_notifications_demo_entries($tenantId);
    }

    $rows = app_query_all(
        $pdo,
        <<<'SQL'
SELECT
    id,
    tenant_id,
    title,
    message,
    channel,
    recipient_scope,
    recipient_count,
    status,
    scheduled_at,
    sent_at,
    created_at,
    updated_at
FROM notification_messages
WHERE tenant_id = :tenant_id
ORDER BY
    CASE status
        WHEN 'draft' THEN 0
        WHEN 'scheduled' THEN 1
        WHEN 'sent' THEN 2
        ELSE 3
    END,
    created_at DESC
LIMIT 20
SQL,
        ['tenant_id' => $tenantId]
    );

    return array_map(
        static function (array $row): array {
            $status = app_notification_status_label((string) ($row['status'] ?? 'draft'));

            return array_merge($row, [
                'scope_label' => app_notification_scope_label((string) ($row['recipient_scope'] ?? 'all')),
                'channel_label' => app_notification_channel_label((string) ($row['channel'] ?? 'email')),
                'status_label' => $status['label'],
                'status_tone' => $status['tone'],
            ]);
        },
        $rows
    );
}

function app_notification_message_by_id(PDO $pdo, string $tenantId, string $messageId): ?array
{
    if ($messageId === '' || !app_notifications_supports_storage($pdo)) {
        return null;
    }

    $row = app_query_one(
        $pdo,
        <<<'SQL'
SELECT
    id,
    tenant_id,
    title,
    message,
    channel,
    recipient_scope,
    recipient_count,
    status,
    scheduled_at,
    sent_at,
    created_at,
    updated_at
FROM notification_messages
WHERE tenant_id = :tenant_id
  AND id = :id
LIMIT 1
SQL,
        ['tenant_id' => $tenantId, 'id' => $messageId]
    );

    if ($row === null) {
        return null;
    }

    $status = app_notification_status_label((string) ($row['status'] ?? 'draft'));

    return array_merge($row, [
        'scope_label' => app_notification_scope_label((string) ($row['recipient_scope'] ?? 'all')),
        'channel_label' => app_notification_channel_label((string) ($row['channel'] ?? 'email')),
        'status_label' => $status['label'],
        'status_tone' => $status['tone'],
    ]);
}

function app_notification_logs_for_tenant(PDO $pdo, string $tenantId): array
{
    if (!scripts_table_exists($pdo, 'notification_logs')) {
        return [];
    }

    $rows = app_query_all(
        $pdo,
        <<<'SQL'
SELECT id, channel, template_key, recipient, status, sent_at, created_at, updated_at
FROM notification_logs
WHERE tenant_id = :tenant_id
ORDER BY created_at DESC
LIMIT 25
SQL,
        ['tenant_id' => $tenantId]
    );

    return array_map(
        static function (array $row): array {
            $status = app_notification_status_label((string) ($row['status'] ?? 'planned'));

            return array_merge($row, [
                'status_label' => $status['label'],
                'status_tone' => $status['tone'],
                'channel_label' => app_notification_channel_label((string) ($row['channel'] ?? 'email')),
            ]);
        },
        $rows
    );
}

function app_notification_recipients_for_scope(PDO $pdo, string $tenantId, string $scope): array
{
    return app_members_for_scope($pdo, $tenantId, $scope);
}

function app_handle_notifications_action(PDO $pdo, array $auth): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        return;
    }

    $action = (string) ($_POST['action'] ?? '');

    if (!in_array($action, ['save-notification', 'send-notification'], true)) {
        return;
    }

    if (!hash_equals((string) ($_SESSION['notifications_csrf'] ?? ''), (string) ($_POST['csrf'] ?? ''))) {
        app_flash('Die Sitzung ist abgelaufen. Bitte lade die Benachrichtigungen neu.', 'error');
        app_redirect('/notifications/');
    }

    if (!app_can_manage_support($auth)) {
        app_flash('Für diese Aktion brauchst du Support- oder Tenant-Admin-Rechte.', 'warning');
        app_redirect('/notifications/');
    }

    $tenantId = (string) ($auth['tenant_id'] ?? '');

    if (!app_notifications_supports_storage($pdo)) {
        app_flash('Die Benachrichtigungsdatenbank ist noch nicht angelegt. Bitte zuerst die Migration ausführen.', 'warning');
        app_redirect('/notifications/');
    }

    $messageId = trim((string) ($_POST['notification_id'] ?? ''));
    $title = trim((string) ($_POST['title'] ?? ''));
    $message = trim((string) ($_POST['message'] ?? ''));
    $channel = in_array((string) ($_POST['channel'] ?? 'email'), ['email', 'inapp', 'sms'], true)
        ? (string) ($_POST['channel'] ?? 'email')
        : 'email';
    $scope = in_array((string) ($_POST['recipient_scope'] ?? 'all'), ['all', 'front', 'back'], true)
        ? (string) ($_POST['recipient_scope'] ?? 'all')
        : 'all';
    $scheduledAtRaw = trim((string) ($_POST['scheduled_at'] ?? ''));
    $scheduledAt = $scheduledAtRaw !== '' ? str_replace('T', ' ', $scheduledAtRaw) . ':00' : null;
    $status = $action === 'send-notification'
        ? 'sent'
        : ($scheduledAt !== null ? 'scheduled' : 'draft');
    $recipientRows = app_notification_recipients_for_scope($pdo, $tenantId, $scope);
    $recipientCount = count($recipientRows);
    $now = date('Y-m-d H:i:s');

    if ($title === '' || $message === '') {
        app_flash('Bitte gib Titel und Nachricht an.', 'error');
        app_redirect('/notifications/');
    }

    if ($action === 'send-notification' && $recipientCount === 0) {
        app_flash('Für die gewählte Zielgruppe wurden keine aktiven Mitglieder gefunden.', 'warning');
        app_redirect('/notifications/');
    }

    $pdo->beginTransaction();

    try {
        if ($messageId !== '') {
            app_execute(
                $pdo,
                'UPDATE notification_messages SET title = :title, message = :message, channel = :channel, recipient_scope = :recipient_scope, recipient_count = :recipient_count, status = :status, scheduled_at = :scheduled_at, sent_at = :sent_at, updated_at = :updated_at WHERE tenant_id = :tenant_id AND id = :id',
                [
                    'title' => $title,
                    'message' => $message,
                    'channel' => $channel,
                    'recipient_scope' => $scope,
                    'recipient_count' => $recipientCount,
                    'status' => $status,
                    'scheduled_at' => $scheduledAt,
                    'sent_at' => $status === 'sent' ? $now : null,
                    'updated_at' => $now,
                    'tenant_id' => $tenantId,
                    'id' => $messageId,
                ]
            );
        } else {
            $messageId = app_uuid();
            app_execute(
                $pdo,
                'INSERT INTO notification_messages (id, tenant_id, title, message, channel, recipient_scope, recipient_count, status, scheduled_at, sent_at, created_at, updated_at) VALUES (:id, :tenant_id, :title, :message, :channel, :recipient_scope, :recipient_count, :status, :scheduled_at, :sent_at, :created_at, :updated_at)',
                [
                    'id' => $messageId,
                    'tenant_id' => $tenantId,
                    'title' => $title,
                    'message' => $message,
                    'channel' => $channel,
                    'recipient_scope' => $scope,
                    'recipient_count' => $recipientCount,
                    'status' => $status,
                    'scheduled_at' => $scheduledAt,
                    'sent_at' => $status === 'sent' ? $now : null,
                    'created_at' => $now,
                    'updated_at' => $now,
                ]
            );
        }

        if ($status === 'sent' && scripts_table_exists($pdo, 'notification_logs')) {
            foreach ($recipientRows as $recipientRow) {
                app_execute(
                    $pdo,
                    'INSERT INTO notification_logs (id, tenant_id, channel, template_key, recipient, status, sent_at, created_at, updated_at) VALUES (:id, :tenant_id, :channel, :template_key, :recipient, :status, :sent_at, :created_at, :updated_at)',
                    [
                        'id' => app_uuid(),
                        'tenant_id' => $tenantId,
                        'channel' => $channel,
                        'template_key' => 'broadcast',
                        'recipient' => trim((string) ($recipientRow['display_name'] ?? 'Mitglied')),
                        'status' => 'sent',
                        'sent_at' => $now,
                        'created_at' => $now,
                        'updated_at' => $now,
                    ]
                );
            }
        }

        $pdo->commit();

        if ($status === 'sent') {
            app_flash('Die Benachrichtigung wurde an ' . $recipientCount . ' Mitglieder ausgeliefert.', 'success');
        } elseif ($status === 'scheduled') {
            app_flash('Die Benachrichtigung wurde für ' . app_notification_scope_label($scope) . ' geplant.', 'success');
        } else {
            app_flash('Der Entwurf wurde gespeichert.', 'success');
        }
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        app_flash($exception->getMessage(), 'error');
    }

    app_redirect('/notifications/');
}

/**
 * @return array<int, string>
 */
function app_survey_question_options(PDO $pdo, array $row): array
{
    if (scripts_table_exists($pdo, 'survey_question_options') && isset($row['id'])) {
        return array_map(
            static fn(array $option): string => (string) ($option['option_label'] ?? ''),
            app_query_all(
                $pdo,
                'SELECT option_label FROM survey_question_options WHERE question_id = :question_id ORDER BY sort_order ASC, created_at ASC',
                ['question_id' => (string) $row['id']]
            )
        );
    }

    if (!app_table_has_column($pdo, 'survey_questions', 'options_json')) {
        return [];
    }

    $decoded = json_decode((string) ($row['options_json'] ?? '[]'), true);

    return is_array($decoded) ? array_values(array_filter(array_map('strval', $decoded))) : [];
}

/**
 * @return array<int, array<string, mixed>>
 */
function app_surveys_for_tenant(PDO $pdo, string $tenantId): array
{
    if (!scripts_table_exists($pdo, 'surveys') || !scripts_table_exists($pdo, 'survey_questions')) {
        return [];
    }

    $surveys = app_query_all(
        $pdo,
        <<<'SQL'
SELECT
    s.id,
    s.title,
    s.status,
    s.starts_at,
    s.ends_at,
    s.created_at,
    COUNT(DISTINCT sq.id) AS question_count,
    COUNT(DISTINCT sa.tenant_user_id) AS response_count
FROM surveys s
LEFT JOIN survey_questions sq ON sq.survey_id = s.id
LEFT JOIN survey_answers sa ON sa.survey_id = s.id
WHERE s.tenant_id = :tenant_id
GROUP BY s.id, s.title, s.status, s.starts_at, s.ends_at, s.created_at
ORDER BY s.created_at DESC
SQL,
        ['tenant_id' => $tenantId]
    );

    foreach ($surveys as &$survey) {
        $survey['questions'] = app_query_all(
            $pdo,
            str_replace(
                '__OPTIONS__',
                app_table_has_column($pdo, 'survey_questions', 'options_json') ? 'sq.options_json' : 'NULL AS options_json',
                <<<'SQL'
SELECT
    sq.id,
    sq.question,
    sq.question_type,
    sq.is_required,
    sq.sort_order,
    __OPTIONS__
FROM survey_questions sq
WHERE sq.survey_id = :survey_id
ORDER BY sq.sort_order ASC, sq.created_at ASC
SQL
            ),
            ['survey_id' => $survey['id']]
        );

        foreach ($survey['questions'] as &$question) {
            $question['options'] = app_survey_question_options($pdo, $question);
        }
        unset($question);
    }
    unset($survey);

    return $surveys;
}

function app_survey_by_id(PDO $pdo, string $tenantId, string $surveyId): ?array
{
    foreach (app_surveys_for_tenant($pdo, $tenantId) as $survey) {
        if ((string) ($survey['id'] ?? '') === $surveyId) {
            return $survey;
        }
    }

    return null;
}

function app_survey_results(PDO $pdo, string $surveyId): array
{
    $results = [];
    $questions = app_query_all(
        $pdo,
        str_replace(
            '__OPTIONS__',
            app_table_has_column($pdo, 'survey_questions', 'options_json') ? 'options_json' : 'NULL AS options_json',
            <<<'SQL'
SELECT id, question, question_type, is_required, sort_order, __OPTIONS__
FROM survey_questions
WHERE survey_id = :survey_id
ORDER BY sort_order ASC, created_at ASC
SQL
        ),
        ['survey_id' => $surveyId]
    );

    foreach ($questions as $question) {
        $answers = app_query_all(
            $pdo,
            'SELECT answer_text FROM survey_answers WHERE survey_id = :survey_id AND question_id = :question_id ORDER BY created_at ASC',
            ['survey_id' => $surveyId, 'question_id' => $question['id']]
        );
        $options = app_survey_question_options($pdo, $question);
        $summary = [];
        $freeText = [];

        foreach ($answers as $answer) {
            $answerText = trim((string) ($answer['answer_text'] ?? ''));
            if ($answerText === '') {
                continue;
            }

            if (($question['question_type'] ?? 'text') === 'multi_select') {
                foreach (array_filter(array_map('trim', explode("\n", str_replace(["\r\n", ','], "\n", $answerText)))) as $item) {
                    $summary[$item] = ($summary[$item] ?? 0) + 1;
                }
                continue;
            }

            if (in_array((string) ($question['question_type'] ?? ''), ['scale', 'single_select'], true)) {
                $summary[$answerText] = ($summary[$answerText] ?? 0) + 1;
                continue;
            }

            $freeText[] = $answerText;
        }

        if ($options !== [] && $summary !== []) {
            $sortedSummary = [];
            foreach ($options as $option) {
                $sortedSummary[$option] = $summary[$option] ?? 0;
            }
            foreach ($summary as $label => $count) {
                if (!array_key_exists($label, $sortedSummary)) {
                    $sortedSummary[$label] = $count;
                }
            }
            $summary = $sortedSummary;
        } else {
            ksort($summary);
        }

        $results[] = [
            'question' => $question,
            'summary' => $summary,
            'free_text' => array_slice($freeText, -6),
            'answer_count' => count($answers),
        ];
    }

    return $results;
}

function app_handle_survey_action(PDO $pdo, array $auth): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        return;
    }

    $action = (string) ($_POST['action'] ?? '');

    if (!in_array($action, ['save-survey', 'submit-survey', 'set-survey-status'], true)) {
        return;
    }

    $tenantId = (string) ($auth['tenant_id'] ?? '');
    $now = date('Y-m-d H:i:s');

    try {
        if ($action === 'save-survey') {
            if (!app_can_manage_surveys($auth)) {
                throw new RuntimeException('Für diese Aktion brauchst du Umfrage-Rechte.');
            }

            $surveyId = trim((string) ($_POST['survey_id'] ?? ''));
            $title = trim((string) ($_POST['title'] ?? ''));
            $status = trim((string) ($_POST['status'] ?? 'draft'));
            $questionTexts = $_POST['question_text'] ?? [];
            $questionTypes = $_POST['question_type'] ?? [];
            $requiredFlags = $_POST['question_required'] ?? [];
            $optionValues = $_POST['question_options'] ?? [];

            if ($title === '') {
                throw new RuntimeException('Bitte gib einen Titel für die Umfrage an.');
            }

            if (!in_array($status, ['draft', 'active', 'published', 'closed'], true)) {
                $status = 'draft';
            }

            $questions = [];
            foreach ($questionTexts as $index => $questionText) {
                $text = trim((string) $questionText);
                if ($text === '') {
                    continue;
                }

                $type = (string) ($questionTypes[$index] ?? 'text');
                if (!in_array($type, ['text', 'scale', 'single_select', 'multi_select'], true)) {
                    $type = 'text';
                }

                $questions[] = [
                    'text' => $text,
                    'type' => $type,
                    'required' => !empty($requiredFlags[$index]),
                    'options' => array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', (string) ($optionValues[$index] ?? '')) ?: []))),
                ];
            }

            if ($questions === []) {
                throw new RuntimeException('Bitte lege mindestens eine Frage an.');
            }

            $pdo->beginTransaction();

            if ($surveyId === '') {
                $surveyId = app_uuid();
                app_execute(
                    $pdo,
                    'INSERT INTO surveys (id, tenant_id, title, status, starts_at, ends_at, created_at, updated_at) VALUES (:id, :tenant_id, :title, :status, NULL, NULL, :created_at, :updated_at)',
                    [
                        'id' => $surveyId,
                        'tenant_id' => $tenantId,
                        'title' => $title,
                        'status' => $status,
                        'created_at' => $now,
                        'updated_at' => $now,
                    ]
                );
            } else {
                $existingResponses = (int) app_query_value(
                    $pdo,
                    'SELECT COUNT(*) FROM survey_answers WHERE survey_id = :survey_id',
                    ['survey_id' => $surveyId],
                    0
                );

                if ($existingResponses > 0) {
                    throw new RuntimeException('Umfragen mit vorhandenen Antworten können nicht strukturell überschrieben werden. Bitte nur den Status ändern oder eine neue Umfrage anlegen.');
                }

                app_execute(
                    $pdo,
                    'UPDATE surveys SET title = :title, status = :status, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id',
                    [
                        'title' => $title,
                        'status' => $status,
                        'updated_at' => $now,
                        'id' => $surveyId,
                        'tenant_id' => $tenantId,
                    ]
                );
                if (scripts_table_exists($pdo, 'survey_question_options')) {
                    app_execute(
                        $pdo,
                        'DELETE sqo FROM survey_question_options sqo INNER JOIN survey_questions sq ON sq.id = sqo.question_id WHERE sq.survey_id = :survey_id',
                        ['survey_id' => $surveyId]
                    );
                }
                app_execute($pdo, 'DELETE FROM survey_questions WHERE survey_id = :survey_id', ['survey_id' => $surveyId]);
            }

            foreach ($questions as $index => $question) {
                $questionId = app_uuid();
                $params = [
                    'id' => $questionId,
                    'survey_id' => $surveyId,
                    'question' => $question['text'],
                    'question_type' => $question['type'],
                    'is_required' => $question['required'] ? 1 : 0,
                    'sort_order' => $index + 1,
                    'created_at' => $now,
                    'updated_at' => $now,
                ];

                if (app_table_has_column($pdo, 'survey_questions', 'options_json')) {
                    $params['options_json'] = json_encode($question['options'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                    app_execute(
                        $pdo,
                        'INSERT INTO survey_questions (id, survey_id, question, question_type, is_required, sort_order, options_json, created_at, updated_at) VALUES (:id, :survey_id, :question, :question_type, :is_required, :sort_order, :options_json, :created_at, :updated_at)',
                        $params
                    );
                } else {
                    app_execute(
                        $pdo,
                        'INSERT INTO survey_questions (id, survey_id, question, question_type, is_required, sort_order, created_at, updated_at) VALUES (:id, :survey_id, :question, :question_type, :is_required, :sort_order, :created_at, :updated_at)',
                        $params
                    );
                }

                if (scripts_table_exists($pdo, 'survey_question_options')) {
                    foreach ($question['options'] as $optionIndex => $optionLabel) {
                        app_execute(
                            $pdo,
                            'INSERT INTO survey_question_options (id, question_id, option_label, sort_order, created_at, updated_at) VALUES (:id, :question_id, :option_label, :sort_order, :created_at, :updated_at)',
                            [
                                'id' => app_uuid(),
                                'question_id' => $questionId,
                                'option_label' => $optionLabel,
                                'sort_order' => $optionIndex + 1,
                                'created_at' => $now,
                                'updated_at' => $now,
                            ]
                        );
                    }
                }
            }

            $pdo->commit();
            app_flash('Die Umfrage wurde gespeichert.', 'success');
            app_redirect('/surveys/?survey=' . rawurlencode($surveyId));
        }

        if ($action === 'set-survey-status') {
            if (!app_can_manage_surveys($auth)) {
                throw new RuntimeException('Für diese Aktion brauchst du Umfrage-Rechte.');
            }

            $surveyId = trim((string) ($_POST['survey_id'] ?? ''));
            $status = trim((string) ($_POST['status'] ?? 'draft'));
            if (!in_array($status, ['draft', 'active', 'published', 'closed'], true)) {
                $status = 'draft';
            }

            app_execute(
                $pdo,
                'UPDATE surveys SET status = :status, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id',
                ['status' => $status, 'updated_at' => $now, 'id' => $surveyId, 'tenant_id' => $tenantId]
            );
            app_flash('Der Umfrage-Status wurde aktualisiert.', 'success');
            app_redirect('/surveys/?survey=' . rawurlencode($surveyId));
        }

        if ($action === 'submit-survey') {
            $surveyId = trim((string) ($_POST['survey_id'] ?? ''));
            $survey = app_survey_by_id($pdo, $tenantId, $surveyId);

            if ($survey === null || !in_array((string) ($survey['status'] ?? ''), ['active', 'published'], true)) {
                throw new RuntimeException('Diese Umfrage ist aktuell nicht zur Teilnahme freigegeben.');
            }

            $tenantUserId = (string) ($auth['tenant_user_id'] ?? '');
            if ($tenantUserId === '') {
                throw new RuntimeException('Die Teilnahme ist ohne aktives Mitgliedskonto nicht möglich.');
            }

            $existingCount = (int) app_query_value(
                $pdo,
                'SELECT COUNT(*) FROM survey_answers WHERE survey_id = :survey_id AND tenant_user_id = :tenant_user_id',
                ['survey_id' => $surveyId, 'tenant_user_id' => $tenantUserId],
                0
            );

            if ($existingCount > 0) {
                throw new RuntimeException('Du hast an dieser Umfrage bereits teilgenommen.');
            }

            $answers = $_POST['answers'] ?? [];
            $pdo->beginTransaction();

            foreach (($survey['questions'] ?? []) as $question) {
                $questionId = (string) ($question['id'] ?? '');
                $value = $answers[$questionId] ?? null;
                $questionType = (string) ($question['question_type'] ?? 'text');

                if (is_array($value)) {
                    $value = implode("\n", array_values(array_filter(array_map('trim', $value))));
                }

                $value = trim((string) $value);

                if ((int) ($question['is_required'] ?? 0) === 1 && $value === '') {
                    throw new RuntimeException('Bitte beantworte alle Pflichtfragen.');
                }

                app_execute(
                    $pdo,
                    'INSERT INTO survey_answers (id, survey_id, question_id, tenant_user_id, answer_text, created_at, updated_at) VALUES (:id, :survey_id, :question_id, :tenant_user_id, :answer_text, :created_at, :updated_at)',
                    [
                        'id' => app_uuid(),
                        'survey_id' => $surveyId,
                        'question_id' => $questionId,
                        'tenant_user_id' => $tenantUserId,
                        'answer_text' => $value !== '' ? $value : null,
                        'created_at' => $now,
                        'updated_at' => $now,
                    ]
                );
            }

            $pdo->commit();
            app_flash('Danke, deine Antworten wurden gespeichert.', 'success');
            app_redirect('/surveys/?survey=' . rawurlencode($surveyId));
        }
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        app_flash($exception->getMessage(), 'error');
        app_redirect('/surveys/' . (isset($_POST['survey_id']) && $_POST['survey_id'] !== '' ? '?survey=' . rawurlencode((string) $_POST['survey_id']) : ''));
    }
}

function app_supports_license_features(PDO $pdo): bool
{
    return scripts_table_exists($pdo, 'tenant_licenses')
        && scripts_table_exists($pdo, 'license_plans')
        && scripts_table_exists($pdo, 'features');
}

function app_feature_defaults(): array
{
    return [
        'members' => true,
        'ledger' => true,
        'payments' => true,
        'content' => true,
        'tenant_settings' => true,
        'pdf_export' => true,
        'paper_strike_entry' => true,
        'basic_exports' => false,
        'oidc' => false,
        'imports' => false,
        'exports' => false,
        'notifications' => false,
        'surveys' => false,
        'advanced_reporting' => false,
        'white_label' => false,
        'custom_features' => false,
        'priority_updates' => false,
    ];
}

function app_tenant_license(PDO $pdo, string $tenantId): array
{
    if (!app_supports_license_features($pdo)) {
        return [
            'plan_key' => 'free',
            'plan_name' => 'Free',
            'member_limit' => 10,
            'features' => app_feature_defaults(),
        ];
    }

    $hasMemberLimitColumn = app_table_has_column($pdo, 'license_plans', 'member_limit');
    $row = app_query_one(
        $pdo,
        str_replace(
            '__MEMBER_LIMIT__',
            $hasMemberLimitColumn ? 'lp.member_limit' : 'NULL AS member_limit',
            <<<'SQL'
SELECT
    lp.id AS plan_id,
    lp.plan_key,
    lp.name AS plan_name,
    __MEMBER_LIMIT__
FROM tenant_licenses tl
INNER JOIN license_plans lp ON lp.id = tl.license_plan_id
WHERE tl.tenant_id = :tenant_id
  AND tl.status = 'active'
LIMIT 1
SQL,
        ),
        ['tenant_id' => $tenantId]
    );

    $features = app_feature_defaults();

    if ($row === null) {
        return [
            'plan_key' => 'free',
            'plan_name' => 'Free',
            'member_limit' => 10,
            'features' => $features,
        ];
    }

    foreach (app_query_all(
        $pdo,
        <<<'SQL'
SELECT f.feature_key
FROM license_plan_features lpf
INNER JOIN features f ON f.id = lpf.feature_id
WHERE lpf.license_plan_id = :plan_id
SQL,
        ['plan_id' => $row['plan_id']]
    ) as $featureRow) {
        $features[(string) $featureRow['feature_key']] = true;
    }

    foreach (app_query_all(
        $pdo,
        <<<'SQL'
SELECT f.feature_key, tfo.is_enabled
FROM tenant_feature_overrides tfo
INNER JOIN features f ON f.id = tfo.feature_id
WHERE tfo.tenant_id = :tenant_id
SQL,
        ['tenant_id' => $tenantId]
    ) as $override) {
        $features[(string) $override['feature_key']] = (int) ($override['is_enabled'] ?? 0) === 1;
    }

    return [
        'plan_key' => (string) $row['plan_key'],
        'plan_name' => (string) $row['plan_name'],
        'member_limit' => isset($row['member_limit']) ? (int) $row['member_limit'] : null,
        'features' => $features,
    ];
}

function app_tenant_has_feature(PDO $pdo, string $tenantId, string $featureKey): bool
{
    $license = app_tenant_license($pdo, $tenantId);

    return !empty($license['features'][$featureKey]);
}

function app_license_plan_options(PDO $pdo): array
{
    if (!app_supports_license_features($pdo)) {
        return [];
    }

    if (!app_table_has_column($pdo, 'license_plans', 'member_limit')) {
        return app_query_all(
            $pdo,
            'SELECT id, plan_key, name, NULL AS member_limit FROM license_plans WHERE is_active = 1 ORDER BY sort_order ASC, name ASC'
        );
    }

    return app_query_all(
        $pdo,
        'SELECT id, plan_key, name, member_limit FROM license_plans WHERE is_active = 1 ORDER BY sort_order ASC, name ASC'
    );
}

function app_assign_tenant_license(PDO $pdo, string $tenantId, string $planKey): void
{
    if (!app_supports_license_features($pdo)) {
        return;
    }

    $plan = app_query_one($pdo, 'SELECT id FROM license_plans WHERE plan_key = :plan_key LIMIT 1', ['plan_key' => $planKey]);

    if ($plan === null) {
        throw new RuntimeException('Der ausgewählte Lizenzplan ist nicht vorhanden.');
    }

    $existing = app_query_one($pdo, 'SELECT id FROM tenant_licenses WHERE tenant_id = :tenant_id LIMIT 1', ['tenant_id' => $tenantId]);
    $now = date('Y-m-d H:i:s');

    if ($existing !== null) {
        app_execute(
            $pdo,
            'UPDATE tenant_licenses SET license_plan_id = :license_plan_id, status = :status, updated_at = :updated_at WHERE id = :id',
            [
                'license_plan_id' => $plan['id'],
                'status' => 'active',
                'updated_at' => $now,
                'id' => $existing['id'],
            ]
        );

        return;
    }

    app_execute(
        $pdo,
        'INSERT INTO tenant_licenses (id, tenant_id, license_plan_id, status, starts_at, ends_at, created_at, updated_at) VALUES (:id, :tenant_id, :license_plan_id, :status, :starts_at, NULL, :created_at, :updated_at)',
        [
            'id' => app_uuid(),
            'tenant_id' => $tenantId,
            'license_plan_id' => $plan['id'],
            'status' => 'active',
            'starts_at' => $now,
            'created_at' => $now,
            'updated_at' => $now,
        ]
    );
}

function app_tenant_settings_defaults(): array
{
    return [
        'default_unit_price' => '0.50',
        'pdf_list_title' => 'Kaffeeliste',
        'front_page_label' => 'Vorderseite',
        'back_page_label' => 'Rückseite',
        'pdf_row_height_mm' => '4.00',
        'support_email' => '',
        'paypal_me_url' => '',
        'location_label' => '',
        'allow_self_service_booking' => '1',
        'payment_hint' => 'Bitte Einzahlungen zeitnah verbuchen.',
        'paypal_me_link' => '',
        'paypal_fixed_amounts' => '2,5,10',
        'brand_color' => '#005e3f',
        'brand_strong_color' => '#00452f',
        'accent_color' => '#c18a00',
        'background_color' => '#f4efe4',
        'card_color' => '#fffdf8',
    ];
}

function app_is_valid_hex_color(string $value): bool
{
    return preg_match('/^#[0-9A-Fa-f]{6}$/', $value) === 1;
}

/**
 * @return array{brand_color:string,brand_strong_color:string,accent_color:string,background_color:string,card_color:string,text_color:string,muted_color:string,brand_rgb:string,accent_rgb:string}
 */
function app_tenant_theme(array $settings): array
{
    $defaults = [
        'brand_color' => '#005e3f',
        'brand_strong_color' => '#00452f',
        'accent_color' => '#c18a00',
        'background_color' => '#f4efe4',
        'card_color' => '#fffdf8',
        'text_color' => '#25180f',
        'muted_color' => '#63584a',
    ];

    foreach ($defaults as $key => $default) {
        $candidate = strtoupper(trim((string) ($settings[$key] ?? $default)));
        $defaults[$key] = app_is_valid_hex_color($candidate) ? $candidate : $default;
    }

    $brandRgb = sscanf($defaults['brand_color'], '#%02x%02x%02x') ?: [0, 94, 63];
    $accentRgb = sscanf($defaults['accent_color'], '#%02x%02x%02x') ?: [193, 138, 0];

    return [
        'brand_color' => $defaults['brand_color'],
        'brand_strong_color' => $defaults['brand_strong_color'],
        'accent_color' => $defaults['accent_color'],
        'background_color' => $defaults['background_color'],
        'card_color' => $defaults['card_color'],
        'text_color' => $defaults['text_color'],
        'muted_color' => $defaults['muted_color'],
        'brand_rgb' => implode(', ', array_map('intval', $brandRgb)),
        'accent_rgb' => implode(', ', array_map('intval', $accentRgb)),
    ];
}

function app_tenant_theme_root_css(array $settings): string
{
    $theme = app_tenant_theme($settings);

    return implode('', [
        '--bg:', $theme['background_color'], ';',
        '--card:', $theme['card_color'], ';',
        '--bg-elevated:', $theme['card_color'], ';',
        '--bg-soft:', $theme['card_color'], ';',
        '--text:', $theme['text_color'], ';',
        '--ink:', $theme['text_color'], ';',
        '--muted:', $theme['muted_color'], ';',
        '--brand:', $theme['brand_color'], ';',
        '--brand-2:', $theme['brand_strong_color'], ';',
        '--brand-strong:', $theme['brand_strong_color'], ';',
        '--accent:', $theme['accent_color'], ';',
        '--brand-rgb:', $theme['brand_rgb'], ';',
        '--accent-rgb:', $theme['accent_rgb'], ';',
    ]);
}

function app_tenant_settings(PDO $pdo, string $tenantId): array
{
    $settings = app_tenant_settings_defaults();

    if (!scripts_table_exists($pdo, 'tenant_settings')) {
        return $settings;
    }

    foreach (app_query_all($pdo, 'SELECT setting_key, setting_value FROM tenant_settings WHERE tenant_id = :tenant_id', ['tenant_id' => $tenantId]) as $row) {
        $settings[(string) $row['setting_key']] = (string) ($row['setting_value'] ?? '');
    }

    return $settings;
}

function app_save_tenant_settings(PDO $pdo, string $tenantId, array $data): void
{
    if (!scripts_table_exists($pdo, 'tenant_settings')) {
        throw new RuntimeException('Die Tenant-Einstellungen sind noch nicht verfügbar. Bitte zuerst die System-Updates ausführen.');
    }

    $values = [
        'default_unit_price' => trim((string) ($data['default_unit_price'] ?? '0.50')),
        'pdf_list_title' => trim((string) ($data['pdf_list_title'] ?? 'Kaffeeliste')),
        'front_page_label' => trim((string) ($data['front_page_label'] ?? 'Vorderseite')),
        'back_page_label' => trim((string) ($data['back_page_label'] ?? 'Rückseite')),
        'pdf_row_height_mm' => trim((string) ($data['pdf_row_height_mm'] ?? '4.00')),
        'support_email' => trim((string) ($data['support_email'] ?? '')),
        'paypal_me_url' => trim((string) ($data['paypal_me_url'] ?? '')),
        'location_label' => trim((string) ($data['location_label'] ?? '')),
        'allow_self_service_booking' => !empty($data['allow_self_service_booking']) ? '1' : '0',
        'payment_hint' => trim((string) ($data['payment_hint'] ?? '')),
        'paypal_me_link' => trim((string) ($data['paypal_me_link'] ?? '')),
        'paypal_fixed_amounts' => trim((string) ($data['paypal_fixed_amounts'] ?? '2,5,10')),
        'brand_color' => strtoupper(trim((string) ($data['brand_color'] ?? '#005e3f'))),
        'brand_strong_color' => strtoupper(trim((string) ($data['brand_strong_color'] ?? '#00452f'))),
        'accent_color' => strtoupper(trim((string) ($data['accent_color'] ?? '#c18a00'))),
        'background_color' => strtoupper(trim((string) ($data['background_color'] ?? '#f4efe4'))),
        'card_color' => strtoupper(trim((string) ($data['card_color'] ?? '#fffdf8'))),
    ];

    if (!is_numeric($values['default_unit_price']) || (float) $values['default_unit_price'] <= 0) {
        throw new RuntimeException('Bitte gib einen gültigen Standardpreis pro Strich an.');
    }

    if (!is_numeric($values['pdf_row_height_mm']) || (float) $values['pdf_row_height_mm'] < 3.2 || (float) $values['pdf_row_height_mm'] > 12.0) {
        throw new RuntimeException('Bitte gib eine gültige Zeilenhöhe zwischen 3,2 mm und 12,0 mm an.');
    }

    if ($values['support_email'] !== '' && !filter_var($values['support_email'], FILTER_VALIDATE_EMAIL)) {
        throw new RuntimeException('Bitte gib eine gültige Support-E-Mail-Adresse an.');
    }

    if ($values['paypal_me_url'] !== '' && !filter_var($values['paypal_me_url'], FILTER_VALIDATE_URL)) {
        throw new RuntimeException('Bitte gib einen gültigen PayPal-Link an.');
    }

    if ($values['paypal_me_link'] !== '' && !filter_var($values['paypal_me_link'], FILTER_VALIDATE_URL)) {
        throw new RuntimeException('Bitte gib für PayPal einen vollständigen Link an.');
    }

    foreach (['brand_color', 'brand_strong_color', 'accent_color', 'background_color', 'card_color'] as $colorKey) {
        if (!app_is_valid_hex_color($values[$colorKey])) {
            throw new RuntimeException('Bitte gib für die Farbfelder gültige HEX-Werte an.');
        }
    }

    $now = date('Y-m-d H:i:s');

    foreach ($values as $key => $value) {
        $existing = app_query_one(
            $pdo,
            'SELECT id FROM tenant_settings WHERE tenant_id = :tenant_id AND setting_key = :setting_key LIMIT 1',
            ['tenant_id' => $tenantId, 'setting_key' => $key]
        );

        if ($existing !== null) {
            app_execute(
                $pdo,
                'UPDATE tenant_settings SET setting_value = :setting_value, updated_at = :updated_at WHERE id = :id',
                [
                    'setting_value' => $value,
                    'updated_at' => $now,
                    'id' => $existing['id'],
                ]
            );
            continue;
        }

        app_execute(
            $pdo,
            'INSERT INTO tenant_settings (id, tenant_id, setting_key, setting_value, created_at, updated_at) VALUES (:id, :tenant_id, :setting_key, :setting_value, :created_at, :updated_at)',
            [
                'id' => app_uuid(),
                'tenant_id' => $tenantId,
                'setting_key' => $key,
                'setting_value' => $value,
                'created_at' => $now,
                'updated_at' => $now,
            ]
        );
    }
}

function app_print_lists_for_tenant(PDO $pdo, string $tenantId): array
{
    if (!scripts_table_exists($pdo, 'print_lists')) {
        return [];
    }

    return app_query_all(
        $pdo,
        'SELECT id, title, unit_price, status, imported_at, created_at FROM print_lists WHERE tenant_id = :tenant_id ORDER BY created_at DESC LIMIT 20',
        ['tenant_id' => $tenantId]
    );
}

function app_print_list_detail(PDO $pdo, string $tenantId, string $listId): ?array
{
    if (!scripts_table_exists($pdo, 'print_lists') || !scripts_table_exists($pdo, 'print_list_items')) {
        return null;
    }

    $list = app_query_one(
        $pdo,
        'SELECT id, title, unit_price, status, notes, imported_at, created_at FROM print_lists WHERE tenant_id = :tenant_id AND id = :id LIMIT 1',
        ['tenant_id' => $tenantId, 'id' => $listId]
    );

    if ($list === null) {
        return null;
    }

    $list['items'] = app_query_all(
        $pdo,
        'SELECT id, member_id, member_name, list_position, front_strokes, back_strokes, total_strokes FROM print_list_items WHERE print_list_id = :print_list_id ORDER BY list_position ASC',
        ['print_list_id' => $listId]
    );

    return $list;
}

function app_create_print_list(PDO $pdo, array $auth, array $settings, array $data): void
{
    if (!scripts_table_exists($pdo, 'print_lists') || !scripts_table_exists($pdo, 'print_list_items')) {
        throw new RuntimeException('Die Drucklisten-Funktion ist noch nicht verfügbar. Bitte zuerst die System-Updates ausführen.');
    }

    $tenantId = (string) ($auth['tenant_id'] ?? '');
    $title = trim((string) ($data['title'] ?? ''));
    $notes = trim((string) ($data['notes'] ?? ''));
    $unitPrice = (float) ($data['unit_price'] ?? ($settings['default_unit_price'] ?? '0.50'));

    if ($title === '') {
        $title = (string) ($settings['pdf_list_title'] ?? 'Kaffeeliste') . ' ' . date('d.m.Y');
    }

    if ($unitPrice <= 0) {
        throw new RuntimeException('Bitte gib einen gültigen Preis pro Strich für die Liste an.');
    }

    $members = app_query_all(
        $pdo,
        'SELECT id, display_name FROM members WHERE tenant_id = :tenant_id AND status = :status ORDER BY display_name ASC',
        ['tenant_id' => $tenantId, 'status' => 'active']
    );

    if ($members === []) {
        throw new RuntimeException('Für diesen Mandanten sind keine aktiven Personen vorhanden.');
    }

    $listId = app_uuid();
    $now = date('Y-m-d H:i:s');
    $pdo->beginTransaction();

    try {
        app_execute(
            $pdo,
            'INSERT INTO print_lists (id, tenant_id, title, unit_price, status, created_by_user_id, notes, imported_at, created_at, updated_at) VALUES (:id, :tenant_id, :title, :unit_price, :status, :created_by_user_id, :notes, NULL, :created_at, :updated_at)',
            [
                'id' => $listId,
                'tenant_id' => $tenantId,
                'title' => $title,
                'unit_price' => number_format($unitPrice, 2, '.', ''),
                'status' => 'draft',
                'created_by_user_id' => $auth['user_id'] ?? null,
                'notes' => $notes,
                'created_at' => $now,
                'updated_at' => $now,
            ]
        );

        $rowNumber = 1;
        foreach ($members as $member) {
            app_execute(
                $pdo,
                'INSERT INTO print_list_items (id, print_list_id, member_id, member_name, list_position, front_strokes, back_strokes, total_strokes, created_at, updated_at) VALUES (:id, :print_list_id, :member_id, :member_name, :list_position, 0, 0, 0, :created_at, :updated_at)',
                [
                    'id' => app_uuid(),
                    'print_list_id' => $listId,
                    'member_id' => $member['id'],
                    'member_name' => $member['display_name'],
                    'list_position' => $rowNumber++,
                    'created_at' => $now,
                    'updated_at' => $now,
                ]
            );
        }

        $pdo->commit();
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        throw $exception;
    }
}

function app_apply_print_list(PDO $pdo, array $auth, array $data): void
{
    $tenantId = (string) ($auth['tenant_id'] ?? '');
    $listId = trim((string) ($data['print_list_id'] ?? ''));
    $list = app_print_list_detail($pdo, $tenantId, $listId);

    if ($list === null) {
        throw new RuntimeException('Die ausgewählte Druckliste wurde nicht gefunden.');
    }

    if ((string) ($list['status'] ?? 'draft') === 'imported') {
        throw new RuntimeException('Diese Druckliste wurde bereits verbucht.');
    }

    $bookedAt = trim((string) ($data['booked_at'] ?? ''));
    $bookedAt = $bookedAt !== '' ? str_replace('T', ' ', $bookedAt) . ':00' : date('Y-m-d H:i:s');
    $unitPrice = (float) ($list['unit_price'] ?? 0.50);
    $now = date('Y-m-d H:i:s');
    $entries = $data['strokes'] ?? [];

    $pdo->beginTransaction();

    try {
        foreach ($list['items'] as $item) {
            $itemId = (string) $item['id'];
            $front = max(0, (int) ($entries[$itemId]['front'] ?? 0));
            $back = max(0, (int) ($entries[$itemId]['back'] ?? 0));
            $total = $front + $back;

            app_execute(
                $pdo,
                'UPDATE print_list_items SET front_strokes = :front_strokes, back_strokes = :back_strokes, total_strokes = :total_strokes, updated_at = :updated_at WHERE id = :id',
                [
                    'front_strokes' => $front,
                    'back_strokes' => $back,
                    'total_strokes' => $total,
                    'updated_at' => $now,
                    'id' => $itemId,
                ]
            );

            if ($total < 1) {
                continue;
            }

            $entryId = app_uuid();
            $ledgerId = app_uuid();
            $amount = round($unitPrice * $total, 2);

            app_execute(
                $pdo,
                'INSERT INTO coffee_entries (id, tenant_id, member_id, strokes, unit_price, total_cost, booking_source, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :strokes, :unit_price, :total_cost, :booking_source, :booked_at, :created_at, :updated_at)',
                [
                    'id' => $entryId,
                    'tenant_id' => $tenantId,
                    'member_id' => $item['member_id'],
                    'strokes' => $total,
                    'unit_price' => number_format($unitPrice, 2, '.', ''),
                    'total_cost' => number_format($amount, 2, '.', ''),
                    'booking_source' => 'print-list',
                    'booked_at' => $bookedAt,
                    'created_at' => $now,
                    'updated_at' => $now,
                ]
            );

            app_execute(
                $pdo,
                'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)',
                [
                    'id' => $ledgerId,
                    'tenant_id' => $tenantId,
                    'member_id' => $item['member_id'],
                    'entry_type' => 'coffee_charge',
                    'amount' => number_format($amount * -1, 2, '.', ''),
                    'reference_type' => 'print_list',
                    'reference_id' => $listId,
                    'booked_at' => $bookedAt,
                    'created_at' => $now,
                    'updated_at' => $now,
                ]
            );
        }

        app_execute(
            $pdo,
            'UPDATE print_lists SET status = :status, imported_at = :imported_at, updated_at = :updated_at WHERE id = :id',
            [
                'status' => 'imported',
                'imported_at' => $now,
                'updated_at' => $now,
                'id' => $listId,
            ]
        );

        $pdo->commit();
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        throw $exception;
    }
}

function app_member_exists(PDO $pdo, string $tenantId, string $memberId): ?array
{
    return app_query_one(
        $pdo,
        'SELECT id, display_name, email FROM members WHERE tenant_id = :tenant_id AND id = :member_id LIMIT 1',
        ['tenant_id' => $tenantId, 'member_id' => $memberId]
    );
}

function app_create_coffee_booking(PDO $pdo, string $tenantId, string $memberId, int $strokes, float $unitPrice, string $bookedAt, string $source = 'manual'): void
{
    if ($strokes < 1 || $unitPrice <= 0) {
        throw new RuntimeException('Bitte gib mindestens einen Strich und einen gültigen Preis an.');
    }

    $entryId = app_uuid();
    $ledgerId = app_uuid();
    $now = date('Y-m-d H:i:s');
    $totalCost = round($strokes * $unitPrice, 2);

    app_execute(
        $pdo,
        'INSERT INTO coffee_entries (id, tenant_id, member_id, strokes, unit_price, total_cost, booking_source, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :strokes, :unit_price, :total_cost, :booking_source, :booked_at, :created_at, :updated_at)',
        [
            'id' => $entryId,
            'tenant_id' => $tenantId,
            'member_id' => $memberId,
            'strokes' => $strokes,
            'unit_price' => number_format($unitPrice, 2, '.', ''),
            'total_cost' => number_format($totalCost, 2, '.', ''),
            'booking_source' => $source,
            'booked_at' => $bookedAt,
            'created_at' => $now,
            'updated_at' => $now,
        ]
    );

    app_execute(
        $pdo,
        'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)',
        [
            'id' => $ledgerId,
            'tenant_id' => $tenantId,
            'member_id' => $memberId,
            'entry_type' => 'coffee_charge',
            'amount' => number_format($totalCost * -1, 2, '.', ''),
            'reference_type' => 'coffee_entry',
            'reference_id' => $entryId,
            'booked_at' => $bookedAt,
            'created_at' => $now,
            'updated_at' => $now,
        ]
    );
}

function app_create_payment_booking(PDO $pdo, string $tenantId, string $memberId, float $amount, string $bookedAt, string $method = 'manual'): void
{
    if ($amount <= 0) {
        throw new RuntimeException('Bitte gib einen gültigen Einzahlungsbetrag an.');
    }

    $entryId = app_uuid();
    $ledgerId = app_uuid();
    $now = date('Y-m-d H:i:s');

    app_execute(
        $pdo,
        'INSERT INTO payment_entries (id, tenant_id, member_id, amount, payment_method, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :amount, :payment_method, :booked_at, :created_at, :updated_at)',
        [
            'id' => $entryId,
            'tenant_id' => $tenantId,
            'member_id' => $memberId,
            'amount' => number_format($amount, 2, '.', ''),
            'payment_method' => $method !== '' ? $method : 'manual',
            'booked_at' => $bookedAt,
            'created_at' => $now,
            'updated_at' => $now,
        ]
    );

    app_execute(
        $pdo,
        'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)',
        [
            'id' => $ledgerId,
            'tenant_id' => $tenantId,
            'member_id' => $memberId,
            'entry_type' => 'payment_credit',
            'amount' => number_format($amount, 2, '.', ''),
            'reference_type' => 'payment_entry',
            'reference_id' => $entryId,
            'booked_at' => $bookedAt,
            'created_at' => $now,
            'updated_at' => $now,
        ]
    );
}

function app_find_member_for_payment_import(PDO $pdo, string $tenantId, string $name, string $paymentReference = ''): ?array
{
    $paymentReference = trim($paymentReference);

    if ($paymentReference !== '' && app_members_support_payment_reference($pdo)) {
        $row = app_query_one(
            $pdo,
            'SELECT id, display_name FROM members WHERE tenant_id = :tenant_id AND status = :status AND LOWER(COALESCE(payment_reference, "")) = LOWER(:payment_reference) LIMIT 1',
            ['tenant_id' => $tenantId, 'status' => 'active', 'payment_reference' => $paymentReference]
        );

        if ($row !== null) {
            return $row;
        }
    }

    return app_query_one(
        $pdo,
        'SELECT id, display_name FROM members WHERE tenant_id = :tenant_id AND status = :status AND LOWER(display_name) = LOWER(:display_name) LIMIT 1',
        ['tenant_id' => $tenantId, 'status' => 'active', 'display_name' => trim($name)]
    );
}

function app_import_payment_csv(PDO $pdo, string $tenantId, array $file): array
{
    $tmpName = (string) ($file['tmp_name'] ?? '');

    if ($tmpName === '' || !is_uploaded_file($tmpName)) {
        throw new RuntimeException('Bitte wähle eine CSV-Datei aus.');
    }

    $handle = fopen($tmpName, 'rb');

    if ($handle === false) {
        throw new RuntimeException('Die CSV-Datei konnte nicht gelesen werden.');
    }

    $header = fgetcsv($handle, 0, ';');
    if (!is_array($header) || $header === []) {
        fclose($handle);
        throw new RuntimeException('Die CSV-Datei enthält keinen lesbaren Header.');
    }

    $headerMap = [];
    foreach ($header as $index => $column) {
        $headerMap[strtolower(trim((string) $column))] = $index;
    }

    $created = 0;
    $duplicates = 0;
    $missing = 0;

    while (($row = fgetcsv($handle, 0, ';')) !== false) {
        $name = trim((string) ($row[$headerMap['name'] ?? $headerMap['mitarbeiter'] ?? -1] ?? ''));
        $paymentReference = trim((string) ($row[$headerMap['paypalname'] ?? $headerMap['payment_reference'] ?? -1] ?? ''));
        $amountRaw = str_replace(',', '.', trim((string) ($row[$headerMap['betrag'] ?? $headerMap['amount'] ?? -1] ?? '0')));
        $bookedAtRaw = trim((string) ($row[$headerMap['datum'] ?? $headerMap['booked_at'] ?? -1] ?? ''));
        $method = trim((string) ($row[$headerMap['payment_method'] ?? $headerMap['methode'] ?? -1] ?? 'import_csv')) ?: 'import_csv';
        $amount = (float) $amountRaw;

        if ($name === '' || $amount <= 0) {
            continue;
        }

        $member = app_find_member_for_payment_import($pdo, $tenantId, $name, $paymentReference);
        if ($member === null) {
            $missing++;
            continue;
        }

        $bookedAt = $bookedAtRaw !== '' ? app_normalize_datetime_input($bookedAtRaw) : date('Y-m-d H:i:s');
        $duplicateSql = 'SELECT COUNT(*) AS aggregate_value FROM payment_entries WHERE tenant_id = :tenant_id AND member_id = :member_id AND amount = :amount AND booked_at = :booked_at AND payment_method = :payment_method';
        if (app_finance_entries_support_status($pdo, 'payment_entries')) {
            $duplicateSql .= ' AND COALESCE(status, \'booked\') = \'booked\'';
        }
        $duplicateCount = (int) app_query_value(
            $pdo,
            $duplicateSql,
            [
                'tenant_id' => $tenantId,
                'member_id' => (string) $member['id'],
                'amount' => number_format($amount, 2, '.', ''),
                'booked_at' => $bookedAt,
                'payment_method' => $method,
            ],
            0
        );

        if ($duplicateCount > 0) {
            $duplicates++;
            continue;
        }

        app_create_payment_booking($pdo, $tenantId, (string) $member['id'], $amount, $bookedAt, $method);
        $created++;
    }

    fclose($handle);

    return ['created' => $created, 'duplicates' => $duplicates, 'missing' => $missing];
}

function app_scan_import_tables_ready(PDO $pdo): bool
{
    if (!scripts_table_exists($pdo, 'scan_import_jobs')) {
        return false;
    }

    foreach (['tenant_id', 'uploaded_by_user_id', 'source_filename', 'source_path', 'status', 'template_id', 'processed_at', 'created_at', 'updated_at'] as $columnName) {
        if (!app_table_has_column($pdo, 'scan_import_jobs', $columnName)) {
            return false;
        }
    }

    return true;
}

function app_scan_templates_for_tenant(PDO $pdo, string $tenantId): array
{
    if ($tenantId === '' || !scripts_table_exists($pdo, 'scan_templates')) {
        return [];
    }

    foreach (['tenant_id', 'id', 'name', 'page_width', 'page_height', 'anchor_json', 'grid_json', 'is_active'] as $columnName) {
        if (!app_table_has_column($pdo, 'scan_templates', $columnName)) {
            return [];
        }
    }

    return app_query_all(
        $pdo,
        'SELECT id, tenant_id, name, page_width, page_height, anchor_json, grid_json, is_active FROM scan_templates WHERE tenant_id = :tenant_id ORDER BY is_active DESC, name ASC, id ASC',
        ['tenant_id' => $tenantId]
    );
}

function app_scan_import_jobs_for_tenant(PDO $pdo, string $tenantId): array
{
    if ($tenantId === '' || !app_scan_import_tables_ready($pdo)) {
        return [];
    }

    $supportsTemplates = scripts_table_exists($pdo, 'scan_templates');
    $templateJoin = $supportsTemplates
        ? ' LEFT JOIN scan_templates st ON st.id = sj.template_id AND st.tenant_id = sj.tenant_id'
        : '';
    $templateSelect = $supportsTemplates
        ? 'st.name AS template_name'
        : 'NULL AS template_name';

    return app_query_all(
        $pdo,
        'SELECT sj.id, sj.tenant_id, sj.uploaded_by_user_id, sj.source_filename, sj.source_path, sj.status, sj.template_id, sj.error_message, sj.processed_at, sj.posted_at, sj.created_at, sj.updated_at, ' . $templateSelect . ' FROM scan_import_jobs sj' . $templateJoin . ' WHERE sj.tenant_id = :tenant_id ORDER BY sj.created_at DESC, sj.id DESC LIMIT 25',
        ['tenant_id' => $tenantId]
    );
}

function app_scan_import_store_uploaded_pdf(array $file, string $tenantId): array
{
    $tmpName = (string) ($file['tmp_name'] ?? '');
    $originalName = trim((string) ($file['name'] ?? ''));

    if ($tenantId === '') {
        throw new RuntimeException('Der Mandant fehlt für den PDF-Upload.');
    }

    if ($tmpName === '') {
        throw new RuntimeException('Die PDF-Datei konnte nicht verarbeitet werden.');
    }

    $baseDir = rtrim(dirname(__DIR__), '/\\');
    $directory = $baseDir . '/storage/scan-imports/' . $tenantId . '/' . date('Y') . '/' . date('m') . '/';
    $filename = app_uuid() . '.pdf';
    $targetPath = $directory . $filename;

    if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
        throw new RuntimeException('Das Upload-Verzeichnis konnte nicht angelegt werden.');
    }

    $stored = false;
    if (is_uploaded_file($tmpName)) {
        $stored = move_uploaded_file($tmpName, $targetPath);
    } else {
        $stored = @rename($tmpName, $targetPath);
        if (!$stored && is_file($tmpName)) {
            $stored = copy($tmpName, $targetPath);
            if ($stored) {
                @unlink($tmpName);
            }
        }
    }

    if (!$stored) {
        throw new RuntimeException('Die PDF-Datei konnte nicht gespeichert werden.');
    }

    return [
        'original_filename' => $originalName !== '' ? $originalName : $filename,
        'filename' => $filename,
        'path' => $targetPath,
    ];
}

function app_create_scan_import_job(PDO $pdo, array $auth, array $file, string $templateId = ''): string
{
    $tenantId = trim((string) ($auth['tenant_id'] ?? ''));

    if ($tenantId === '') {
        throw new RuntimeException('Der Mandant konnte für den Scan-Import nicht bestimmt werden.');
    }

    if (!app_scan_import_tables_ready($pdo)) {
        throw new RuntimeException('Die Scan-Import-Tabellen sind noch nicht angelegt. Bitte zuerst die Migrationen ausführen.');
    }

    $fileError = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
    if ($fileError !== UPLOAD_ERR_OK) {
        throw new RuntimeException('Bitte wähle eine gültige PDF-Datei aus.');
    }

    $fileSize = (int) ($file['size'] ?? 0);
    if ($fileSize <= 0 || $fileSize > 15 * 1024 * 1024) {
        throw new RuntimeException('Die PDF-Datei darf maximal 15 MB groß sein.');
    }

    $originalName = strtolower(trim((string) ($file['name'] ?? '')));
    $mimeType = strtolower(trim((string) ($file['type'] ?? '')));
    $fileExtension = pathinfo($originalName, PATHINFO_EXTENSION);
    if ($fileExtension !== 'pdf' && !str_contains($mimeType, 'pdf')) {
        throw new RuntimeException('Es werden nur PDF-Dateien akzeptiert.');
    }

    $templateId = trim($templateId);
    if ($templateId !== '') {
        if (!scripts_table_exists($pdo, 'scan_templates')) {
            throw new RuntimeException('Die Scan-Vorlagen-Tabelle ist noch nicht angelegt. Bitte zuerst die Migrationen ausführen.');
        }

        if (app_query_one(
            $pdo,
            'SELECT id FROM scan_templates WHERE tenant_id = :tenant_id AND id = :id AND is_active = 1 LIMIT 1',
            [
                'tenant_id' => $tenantId,
                'id' => $templateId,
            ]
        ) === null) {
            throw new RuntimeException('Die ausgewählte Scan-Vorlage wurde nicht gefunden.');
        }
    } else {
        $templateId = '';
    }

    $storedFile = app_scan_import_store_uploaded_pdf($file, $tenantId);
    $jobId = app_uuid();
    $now = date('Y-m-d H:i:s');
    $uploadedByUserId = trim((string) ($auth['user_id'] ?? ''));

    if ($uploadedByUserId === '') {
        throw new RuntimeException('Die Benutzerkennung für den Upload fehlt.');
    }

    $pdo->beginTransaction();

    try {
        app_execute(
            $pdo,
            'INSERT INTO scan_import_jobs (id, tenant_id, uploaded_by_user_id, source_filename, source_path, status, template_id, processed_at, created_at, updated_at) VALUES (:id, :tenant_id, :uploaded_by_user_id, :source_filename, :source_path, :status, :template_id, :processed_at, :created_at, :updated_at)',
            [
                'id' => $jobId,
                'tenant_id' => $tenantId,
                'uploaded_by_user_id' => $uploadedByUserId,
                'source_filename' => $storedFile['original_filename'],
                'source_path' => $storedFile['path'],
                'status' => 'uploaded',
                'template_id' => $templateId !== '' ? $templateId : null,
                'processed_at' => null,
                'created_at' => $now,
                'updated_at' => $now,
            ]
        );

        $pdo->commit();

        return $jobId;
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        if (is_file($storedFile['path'] ?? '')) {
            @unlink($storedFile['path']);
        }

        throw $exception;
    }
}

function app_scan_import_pages_table_ready(PDO $pdo): bool
{
    if (!scripts_table_exists($pdo, 'scan_import_pages')) {
        return false;
    }

    foreach (['id', 'job_id', 'page_number', 'image_path', 'image_width', 'image_height', 'processing_status', 'confidence', 'created_at', 'updated_at'] as $columnName) {
        if (!app_table_has_column($pdo, 'scan_import_pages', $columnName)) {
            return false;
        }
    }

    return true;
}

function app_scan_import_rows_table_ready(PDO $pdo): bool
{
    if (!scripts_table_exists($pdo, 'scan_import_rows')) {
        return false;
    }

    foreach (['id', 'job_id', 'page_id', 'row_index', 'member_id', 'member_name_raw', 'front_strokes_detected', 'back_strokes_detected', 'confidence', 'needs_review', 'corrected_front_strokes', 'corrected_back_strokes', 'posted_entry_id', 'created_at', 'updated_at'] as $columnName) {
        if (!app_table_has_column($pdo, 'scan_import_rows', $columnName)) {
            return false;
        }
    }

    return true;
}

function app_scan_import_job_for_tenant(PDO $pdo, string $tenantId, string $jobId): ?array
{
    $tenantId = trim($tenantId);
    $jobId = trim($jobId);

    if ($tenantId === '' || $jobId === '' || !app_scan_import_tables_ready($pdo)) {
        return null;
    }

    $supportsTemplates = scripts_table_exists($pdo, 'scan_templates');
    $templateJoin = $supportsTemplates
        ? ' LEFT JOIN scan_templates st ON st.id = sj.template_id AND st.tenant_id = sj.tenant_id'
        : '';
    $templateSelect = $supportsTemplates
        ? 'st.name AS template_name'
        : 'NULL AS template_name';

    return app_query_one(
        $pdo,
        'SELECT sj.id, sj.tenant_id, sj.uploaded_by_user_id, sj.source_filename, sj.source_path, sj.status, sj.template_id, sj.error_message, sj.processed_at, sj.posted_at, sj.created_at, sj.updated_at, ' . $templateSelect . ' FROM scan_import_jobs sj' . $templateJoin . ' WHERE sj.tenant_id = :tenant_id AND sj.id = :job_id LIMIT 1',
        ['tenant_id' => $tenantId, 'job_id' => $jobId]
    );
}

function app_scan_template_for_tenant(PDO $pdo, string $tenantId, string $templateId): ?array
{
    $tenantId = trim($tenantId);
    $templateId = trim($templateId);

    if ($tenantId === '' || $templateId === '' || !scripts_table_exists($pdo, 'scan_templates')) {
        return null;
    }

    foreach (['tenant_id', 'id', 'name', 'page_width', 'page_height', 'anchor_json', 'grid_json', 'is_active'] as $columnName) {
        if (!app_table_has_column($pdo, 'scan_templates', $columnName)) {
            return null;
        }
    }

    return app_query_one(
        $pdo,
        'SELECT id, tenant_id, name, page_width, page_height, anchor_json, grid_json, is_active, created_at, updated_at FROM scan_templates WHERE tenant_id = :tenant_id AND id = :id LIMIT 1',
        [
            'tenant_id' => $tenantId,
            'id' => $templateId,
        ]
    );
}

function app_scan_import_rows_for_job(PDO $pdo, string $tenantId, string $jobId): array
{
    $tenantId = trim($tenantId);
    $jobId = trim($jobId);

    if ($tenantId === '' || $jobId === '' || !app_scan_import_rows_table_ready($pdo) || !app_scan_import_tables_ready($pdo)) {
        return [];
    }

    return app_query_all(
        $pdo,
        <<<'SQL'
SELECT
    sr.id,
    sr.job_id,
    sr.page_id,
    sr.row_index,
    sr.member_id,
    sr.member_name_raw,
    sr.front_strokes_detected,
    sr.back_strokes_detected,
    sr.confidence,
    sr.needs_review,
    sr.corrected_front_strokes,
    sr.corrected_back_strokes,
    sr.posted_entry_id,
    sr.created_at,
    sr.updated_at,
    m.display_name
FROM scan_import_rows sr
INNER JOIN scan_import_jobs sj ON sj.id = sr.job_id
LEFT JOIN members m ON m.id = sr.member_id AND m.tenant_id = sj.tenant_id
LEFT JOIN scan_import_pages sp ON sp.id = sr.page_id
WHERE sj.tenant_id = :tenant_id
  AND sr.job_id = :job_id
ORDER BY COALESCE(sp.page_number, 1) ASC, sr.row_index ASC, sr.id ASC
SQL,
        [
            'tenant_id' => $tenantId,
            'job_id' => $jobId,
        ]
    );
}

function app_scan_import_shell_command_available(string $command): bool
{
    $command = preg_replace('/[^a-zA-Z0-9._-]+/', '', $command) ?? '';
    if ($command === '') {
        return false;
    }

    if (app_scan_import_php_function_available('exec')) {
        $output = [];
        $exitCode = 1;
        @exec('command -v ' . escapeshellarg($command) . ' 2>/dev/null', $output, $exitCode);

        return $exitCode === 0 && trim((string) ($output[0] ?? '')) !== '';
    }

    if (!app_scan_import_php_function_available('shell_exec')) {
        return false;
    }

    $output = shell_exec('command -v ' . escapeshellarg($command) . ' 2>/dev/null');

    return trim((string) $output) !== '';
}

function app_scan_import_php_function_available(string $functionName): bool
{
    if (!function_exists($functionName)) {
        return false;
    }

    $disabledRaw = strtolower((string) ini_get('disable_functions'));
    if ($disabledRaw === '') {
        return true;
    }

    $disabledList = array_filter(array_map('trim', explode(',', $disabledRaw)));

    return !in_array(strtolower($functionName), $disabledList, true);
}

function app_scan_import_processor_available(): array
{
    $missing = [];

    if (!app_scan_import_php_function_available('exec') && !app_scan_import_php_function_available('shell_exec')) {
        $missing[] = 'php-shell-exec';
    }

    if (!in_array('php-shell-exec', $missing, true) && !app_scan_import_shell_command_available('pdftoppm')) {
        $missing[] = 'pdftoppm';
    }

    if (!function_exists('imagecreatefrompng') || !function_exists('imagecolorat')) {
        $missing[] = 'php-gd';
    }

    return [
        'ok' => $missing === [],
        'missing' => $missing,
    ];
}

function app_scan_import_render_pdf_pages(string $jobId, string $sourcePdfPath): array
{
    $jobId = trim($jobId);
    $sourcePdfPath = trim($sourcePdfPath);

    if ($jobId === '' || $sourcePdfPath === '') {
        throw new RuntimeException('Die Job-Informationen für die Scan-Verarbeitung sind unvollständig.');
    }

    if (!is_file($sourcePdfPath)) {
        throw new RuntimeException('Die hochgeladene PDF-Datei konnte nicht gefunden werden.');
    }

    $processor = app_scan_import_processor_available();
    if (empty($processor['ok'])) {
        $missing = implode(', ', (array) ($processor['missing'] ?? []));
        throw new RuntimeException('Die Scan-Verarbeitung benötigt zusätzliche System-Komponenten: ' . $missing . '.');
    }

    $baseDir = rtrim(dirname(__DIR__), '/\\');
    $renderDirectory = $baseDir . '/storage/scan-imports/rendered/' . $jobId . '/';

    if (!is_dir($renderDirectory) && !mkdir($renderDirectory, 0775, true) && !is_dir($renderDirectory)) {
        throw new RuntimeException('Das Render-Verzeichnis konnte nicht angelegt werden.');
    }

    foreach (glob($renderDirectory . 'page-*.png') ?: [] as $existingFile) {
        if (is_file($existingFile)) {
            @unlink($existingFile);
        }
    }

    $prefix = $renderDirectory . 'page';
    $command = 'pdftoppm -r 300 -png ' . escapeshellarg($sourcePdfPath) . ' ' . escapeshellarg($prefix);
    $commandOutput = [];

    if (app_scan_import_php_function_available('exec')) {
        $exitCode = 1;
        @exec($command . ' 2>&1', $commandOutput, $exitCode);

        if ($exitCode !== 0) {
            $reason = trim((string) ($commandOutput[0] ?? ''));
            throw new RuntimeException('Die PDF-Seiten konnten nicht gerendert werden.' . ($reason !== '' ? ' (' . $reason . ')' : ''));
        }
    } elseif (app_scan_import_php_function_available('shell_exec')) {
        $shellOutput = (string) shell_exec($command . ' 2>&1');
        if ($shellOutput !== '') {
            $commandOutput = preg_split('/\r?\n/', trim($shellOutput)) ?: [];
        }
    } else {
        throw new RuntimeException('Die Scan-Verarbeitung benötigt eine erlaubte Shell-Funktion (exec oder shell_exec).');
    }

    $imageFiles = glob($prefix . '-*.png') ?: [];
    natsort($imageFiles);

    if ($imageFiles === []) {
        $reason = trim((string) ($commandOutput[0] ?? ''));
        throw new RuntimeException('Es wurden keine Bildseiten aus dem PDF erzeugt.' . ($reason !== '' ? ' (' . $reason . ')' : ''));
    }

    $pages = [];
    foreach (array_values($imageFiles) as $index => $imagePath) {
        $pageNumber = $index + 1;
        if (preg_match('/-(\d+)\.png$/', (string) $imagePath, $matches) === 1) {
            $pageNumber = max(1, (int) ($matches[1] ?? $pageNumber));
        }

        $width = null;
        $height = null;
        $imageSize = @getimagesize($imagePath);
        if (is_array($imageSize) && isset($imageSize[0], $imageSize[1])) {
            $width = (int) $imageSize[0];
            $height = (int) $imageSize[1];
        }

        $pages[] = [
            'page_number' => $pageNumber,
            'image_path' => $imagePath,
            'image_width' => $width,
            'image_height' => $height,
        ];
    }

    usort(
        $pages,
        static fn(array $left, array $right): int => ((int) ($left['page_number'] ?? 0)) <=> ((int) ($right['page_number'] ?? 0))
    );

    return $pages;
}

function app_scan_import_parse_box_definition(mixed $value): ?array
{
    if (!is_array($value)) {
        return null;
    }

    $x = $value['x'] ?? $value['left'] ?? $value[0] ?? null;
    $y = $value['y'] ?? $value['top'] ?? $value[1] ?? null;
    $width = $value['width'] ?? $value['w'] ?? $value[2] ?? null;
    $height = $value['height'] ?? $value['h'] ?? $value[3] ?? null;

    if (!is_numeric($x) || !is_numeric($y) || !is_numeric($width) || !is_numeric($height)) {
        return null;
    }

    $parsed = [
        'x' => (int) round((float) $x),
        'y' => (int) round((float) $y),
        'width' => (int) round((float) $width),
        'height' => (int) round((float) $height),
    ];

    if ($parsed['width'] <= 0 || $parsed['height'] <= 0) {
        return null;
    }

    return $parsed;
}

function app_scan_import_parse_template_grid(string $gridJson): array
{
    $gridJson = trim($gridJson);
    if ($gridJson === '') {
        return [];
    }

    $decoded = json_decode($gridJson, true);
    if (!is_array($decoded)) {
        return [];
    }

    $normalizedRows = [];

    $appendRows = static function (array $rows, int $defaultPageNumber = 1) use (&$normalizedRows): void {
        foreach ($rows as $index => $row) {
            if (!is_array($row)) {
                continue;
            }

            $rowIndex = $row['row_index'] ?? $row['index'] ?? $index;
            if (!is_numeric($rowIndex)) {
                $rowIndex = $index;
            }

            $normalizedRows[] = [
                'page_number' => max(1, (int) ($row['page_number'] ?? $defaultPageNumber)),
                'row_index' => max(0, (int) $rowIndex),
                'member_id' => trim((string) ($row['member_id'] ?? '')),
                'member_name_raw' => trim((string) ($row['member_name_raw'] ?? $row['member_name'] ?? $row['name'] ?? '')),
                'front_box' => app_scan_import_parse_box_definition($row['front_box'] ?? $row['front_rect'] ?? $row['front'] ?? null),
                'back_box' => app_scan_import_parse_box_definition($row['back_box'] ?? $row['back_rect'] ?? $row['back'] ?? null),
            ];
        }
    };

    if (isset($decoded['rows']) && is_array($decoded['rows'])) {
        $appendRows($decoded['rows'], max(1, (int) ($decoded['page_number'] ?? 1)));
    }

    if (isset($decoded['pages']) && is_array($decoded['pages'])) {
        foreach ($decoded['pages'] as $pageIndex => $page) {
            if (!is_array($page)) {
                continue;
            }

            $pageNumber = max(1, (int) ($page['page_number'] ?? ($pageIndex + 1)));
            $rows = $page['rows'] ?? [];
            if (!is_array($rows)) {
                continue;
            }
            $appendRows($rows, $pageNumber);
        }
    }

    usort(
        $normalizedRows,
        static function (array $left, array $right): int {
            $pageComparison = ((int) ($left['page_number'] ?? 0)) <=> ((int) ($right['page_number'] ?? 0));
            if ($pageComparison !== 0) {
                return $pageComparison;
            }

            return ((int) ($left['row_index'] ?? 0)) <=> ((int) ($right['row_index'] ?? 0));
        }
    );

    return $normalizedRows;
}

function app_scan_import_count_strokes_in_box(mixed $image, array $box): array
{
    if (!is_resource($image) && !is_object($image)) {
        return ['count' => 0, 'confidence' => 0.0];
    }

    $imageWidth = (int) imagesx($image);
    $imageHeight = (int) imagesy($image);

    if ($imageWidth <= 0 || $imageHeight <= 0) {
        return ['count' => 0, 'confidence' => 0.0];
    }

    $x0 = max(0, min($imageWidth - 1, (int) ($box['x'] ?? 0)));
    $y0 = max(0, min($imageHeight - 1, (int) ($box['y'] ?? 0)));
    $x1 = max($x0 + 1, min($imageWidth, $x0 + (int) ($box['width'] ?? 0)));
    $y1 = max($y0 + 1, min($imageHeight, $y0 + (int) ($box['height'] ?? 0)));

    $width = $x1 - $x0;
    $height = $y1 - $y0;
    if ($width <= 1 || $height <= 1) {
        return ['count' => 0, 'confidence' => 0.0];
    }

    $pixelCount = $width * $height;
    $binary = array_fill(0, $pixelCount, false);
    $visited = array_fill(0, $pixelCount, false);
    $darkPixels = 0;
    $threshold = 150;

    for ($y = 0; $y < $height; $y++) {
        for ($x = 0; $x < $width; $x++) {
            $rgb = (int) imagecolorat($image, $x0 + $x, $y0 + $y);
            $red = ($rgb >> 16) & 0xFF;
            $green = ($rgb >> 8) & 0xFF;
            $blue = $rgb & 0xFF;
            $gray = (int) (($red * 30 + $green * 59 + $blue * 11) / 100);

            if ($gray < $threshold) {
                $index = $y * $width + $x;
                $binary[$index] = true;
                $darkPixels++;
            }
        }
    }

    $minComponentPixels = max(8, (int) round($pixelCount * 0.001));
    $strokeCount = 0;

    for ($index = 0; $index < $pixelCount; $index++) {
        if (!$binary[$index] || $visited[$index]) {
            continue;
        }

        $stack = [$index];
        $visited[$index] = true;
        $componentPixels = 0;
        $minX = $width;
        $maxX = 0;
        $minY = $height;
        $maxY = 0;

        while ($stack !== []) {
            $current = array_pop($stack);
            if (!is_int($current)) {
                continue;
            }

            $currentY = intdiv($current, $width);
            $currentX = $current - ($currentY * $width);
            $componentPixels++;

            if ($currentX < $minX) {
                $minX = $currentX;
            }
            if ($currentX > $maxX) {
                $maxX = $currentX;
            }
            if ($currentY < $minY) {
                $minY = $currentY;
            }
            if ($currentY > $maxY) {
                $maxY = $currentY;
            }

            $neighbors = [];
            if ($currentX > 0) {
                $neighbors[] = $current - 1;
            }
            if ($currentX < $width - 1) {
                $neighbors[] = $current + 1;
            }
            if ($currentY > 0) {
                $neighbors[] = $current - $width;
            }
            if ($currentY < $height - 1) {
                $neighbors[] = $current + $width;
            }

            foreach ($neighbors as $neighbor) {
                if (!$binary[$neighbor] || $visited[$neighbor]) {
                    continue;
                }

                $visited[$neighbor] = true;
                $stack[] = $neighbor;
            }
        }

        $componentWidth = ($maxX - $minX) + 1;
        $componentHeight = ($maxY - $minY) + 1;
        if ($componentPixels >= $minComponentPixels && ($componentHeight >= 7 || $componentWidth >= 3)) {
            $strokeCount++;
        }
    }

    $inkRatio = $darkPixels / max(1, $pixelCount);
    if ($strokeCount === 0) {
        $confidence = $inkRatio <= 0.01 ? 95.0 : 56.0;
    } else {
        $confidence = min(98.0, 62.0 + ($strokeCount * 6.5) + min(18.0, $inkRatio * 160));
    }

    return [
        'count' => $strokeCount,
        'confidence' => round($confidence, 2),
    ];
}

function app_scan_import_process_job(PDO $pdo, string $tenantId, string $jobId): array
{
    $tenantId = trim($tenantId);
    $jobId = trim($jobId);

    if ($tenantId === '' || $jobId === '') {
        throw new RuntimeException('Der Scan-Job konnte nicht verarbeitet werden, weil die Kontextdaten fehlen.');
    }

    if (!app_scan_import_tables_ready($pdo) || !app_scan_import_pages_table_ready($pdo) || !app_scan_import_rows_table_ready($pdo)) {
        throw new RuntimeException('Die Scan-Import-Tabellen sind nicht vollständig verfügbar. Bitte Migrationen prüfen.');
    }

    $job = app_scan_import_job_for_tenant($pdo, $tenantId, $jobId);
    if ($job === null) {
        throw new RuntimeException('Der ausgewählte Scan-Job wurde nicht gefunden.');
    }

    $status = strtolower(trim((string) ($job['status'] ?? 'uploaded')));
    if ($status === 'posted') {
        throw new RuntimeException('Dieser Scan-Job wurde bereits verbucht und kann nicht erneut verarbeitet werden.');
    }

    $now = date('Y-m-d H:i:s');
    app_execute(
        $pdo,
        'UPDATE scan_import_jobs SET status = :status, error_message = :error_message, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id',
        [
            'status' => 'processing',
            'error_message' => null,
            'updated_at' => $now,
            'id' => $jobId,
            'tenant_id' => $tenantId,
        ]
    );

    $openImages = [];

    try {
        $renderedPages = app_scan_import_render_pdf_pages($jobId, (string) ($job['source_path'] ?? ''));
        $templateId = trim((string) ($job['template_id'] ?? ''));
        $template = $templateId !== '' ? app_scan_template_for_tenant($pdo, $tenantId, $templateId) : null;
        $templateRows = app_scan_import_parse_template_grid((string) ($template['grid_json'] ?? ''));

        $activeMembers = array_values(array_filter(
            app_members_for_tenant($pdo, $tenantId),
            static function (array $member): bool {
                $status = strtolower(trim((string) ($member['status'] ?? 'active')));
                return !in_array($status, ['inactive', 'disabled', 'archived'], true) && trim((string) ($member['id'] ?? '')) !== '';
            }
        ));
        $membersById = [];
        $membersByName = [];
        foreach ($activeMembers as $member) {
            $memberId = trim((string) ($member['id'] ?? ''));
            $memberName = trim((string) ($member['display_name'] ?? ''));
            if ($memberId !== '') {
                $membersById[$memberId] = $member;
            }
            if ($memberName !== '') {
                $membersByName[strtolower($memberName)] = $member;
            }
        }

        $pdo->beginTransaction();

        app_execute($pdo, 'DELETE FROM scan_import_rows WHERE job_id = :job_id', ['job_id' => $jobId]);
        app_execute($pdo, 'DELETE FROM scan_import_pages WHERE job_id = :job_id', ['job_id' => $jobId]);

        $pageIdMap = [];
        foreach ($renderedPages as $page) {
            $pageId = app_uuid();
            $pageNumber = max(1, (int) ($page['page_number'] ?? 1));
            $pageIdMap[$pageNumber] = $pageId;

            app_execute(
                $pdo,
                'INSERT INTO scan_import_pages (id, job_id, page_number, image_path, image_width, image_height, processing_status, confidence, created_at, updated_at) VALUES (:id, :job_id, :page_number, :image_path, :image_width, :image_height, :processing_status, :confidence, :created_at, :updated_at)',
                [
                    'id' => $pageId,
                    'job_id' => $jobId,
                    'page_number' => $pageNumber,
                    'image_path' => (string) ($page['image_path'] ?? ''),
                    'image_width' => isset($page['image_width']) ? (int) $page['image_width'] : null,
                    'image_height' => isset($page['image_height']) ? (int) $page['image_height'] : null,
                    'processing_status' => 'processed',
                    'confidence' => null,
                    'created_at' => $now,
                    'updated_at' => $now,
                ]
            );
        }

        $rowsToInsert = [];
        $pageConfidenceBuckets = [];
        $defaultPageNumber = (int) ($renderedPages[0]['page_number'] ?? 1);
        $rowCounter = 0;

        foreach ($templateRows as $templateRow) {
            $pageNumber = max(1, (int) ($templateRow['page_number'] ?? $defaultPageNumber));
            $pageId = $pageIdMap[$pageNumber] ?? ($pageIdMap[$defaultPageNumber] ?? null);
            if ($pageId === null) {
                continue;
            }

            $renderedPage = null;
            foreach ($renderedPages as $candidatePage) {
                if ((int) ($candidatePage['page_number'] ?? 0) === $pageNumber) {
                    $renderedPage = $candidatePage;
                    break;
                }
            }
            if (!is_array($renderedPage)) {
                $renderedPage = $renderedPages[0] ?? null;
            }
            if (!is_array($renderedPage)) {
                continue;
            }

            $imagePath = (string) ($renderedPage['image_path'] ?? '');
            if ($imagePath === '' || !is_file($imagePath)) {
                continue;
            }

            if (!isset($openImages[$imagePath])) {
                $imageResource = @imagecreatefrompng($imagePath);
                if (!is_resource($imageResource) && !is_object($imageResource)) {
                    throw new RuntimeException('Die gerenderte Seite konnte nicht als PNG geladen werden.');
                }
                $openImages[$imagePath] = $imageResource;
            }

            $imageResource = $openImages[$imagePath];
            $frontResult = ['count' => 0, 'confidence' => 0.0];
            $backResult = ['count' => 0, 'confidence' => 0.0];
            if (is_array($templateRow['front_box'] ?? null)) {
                $frontResult = app_scan_import_count_strokes_in_box($imageResource, (array) $templateRow['front_box']);
            }
            if (is_array($templateRow['back_box'] ?? null)) {
                $backResult = app_scan_import_count_strokes_in_box($imageResource, (array) $templateRow['back_box']);
            }

            $memberId = trim((string) ($templateRow['member_id'] ?? ''));
            $memberNameRaw = trim((string) ($templateRow['member_name_raw'] ?? ''));
            if ($memberId === '' && $memberNameRaw !== '') {
                $mappedMember = $membersByName[strtolower($memberNameRaw)] ?? null;
                if (is_array($mappedMember)) {
                    $memberId = trim((string) ($mappedMember['id'] ?? ''));
                }
            } elseif ($memberId !== '' && !isset($membersById[$memberId])) {
                $memberId = '';
            }

            $rowIndex = (int) ($templateRow['row_index'] ?? $rowCounter);
            if ($rowIndex < 0) {
                $rowIndex = $rowCounter;
            }
            $rowCounter++;

            $combinedConfidence = round((((float) ($frontResult['confidence'] ?? 0)) + ((float) ($backResult['confidence'] ?? 0))) / 2, 2);
            $frontDetected = max(0, (int) ($frontResult['count'] ?? 0));
            $backDetected = max(0, (int) ($backResult['count'] ?? 0));
            $needsReview = ($combinedConfidence < 80.0 || $memberId === '') ? 1 : 0;

            $rowsToInsert[] = [
                'id' => app_uuid(),
                'job_id' => $jobId,
                'page_id' => $pageId,
                'row_index' => $rowIndex,
                'member_id' => $memberId !== '' ? $memberId : null,
                'member_name_raw' => $memberNameRaw !== '' ? $memberNameRaw : null,
                'front_strokes_detected' => $frontDetected,
                'back_strokes_detected' => $backDetected,
                'confidence' => number_format($combinedConfidence, 2, '.', ''),
                'needs_review' => $needsReview,
                'corrected_front_strokes' => $frontDetected,
                'corrected_back_strokes' => $backDetected,
                'posted_entry_id' => null,
                'created_at' => $now,
                'updated_at' => $now,
            ];

            if (!isset($pageConfidenceBuckets[$pageId])) {
                $pageConfidenceBuckets[$pageId] = [];
            }
            $pageConfidenceBuckets[$pageId][] = $combinedConfidence;
        }

        if ($rowsToInsert === []) {
            foreach ($activeMembers as $memberIndex => $member) {
                $memberId = trim((string) ($member['id'] ?? ''));
                if ($memberId === '') {
                    continue;
                }

                $rowsToInsert[] = [
                    'id' => app_uuid(),
                    'job_id' => $jobId,
                    'page_id' => $pageIdMap[$defaultPageNumber] ?? null,
                    'row_index' => $memberIndex,
                    'member_id' => $memberId,
                    'member_name_raw' => trim((string) ($member['display_name'] ?? '')),
                    'front_strokes_detected' => 0,
                    'back_strokes_detected' => 0,
                    'confidence' => number_format(0, 2, '.', ''),
                    'needs_review' => 1,
                    'corrected_front_strokes' => 0,
                    'corrected_back_strokes' => 0,
                    'posted_entry_id' => null,
                    'created_at' => $now,
                    'updated_at' => $now,
                ];
            }
        }

        foreach ($rowsToInsert as $row) {
            app_execute(
                $pdo,
                'INSERT INTO scan_import_rows (id, job_id, page_id, row_index, member_id, member_name_raw, front_strokes_detected, back_strokes_detected, confidence, needs_review, corrected_front_strokes, corrected_back_strokes, posted_entry_id, created_at, updated_at) VALUES (:id, :job_id, :page_id, :row_index, :member_id, :member_name_raw, :front_strokes_detected, :back_strokes_detected, :confidence, :needs_review, :corrected_front_strokes, :corrected_back_strokes, :posted_entry_id, :created_at, :updated_at)',
                $row
            );
        }

        foreach ($pageConfidenceBuckets as $pageId => $confidences) {
            if ($confidences === []) {
                continue;
            }

            $avgConfidence = array_sum($confidences) / count($confidences);
            app_execute(
                $pdo,
                'UPDATE scan_import_pages SET confidence = :confidence, updated_at = :updated_at WHERE id = :id AND job_id = :job_id',
                [
                    'confidence' => number_format($avgConfidence, 2, '.', ''),
                    'updated_at' => $now,
                    'id' => $pageId,
                    'job_id' => $jobId,
                ]
            );
        }

        app_execute(
            $pdo,
            'UPDATE scan_import_jobs SET status = :status, error_message = :error_message, processed_at = :processed_at, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id',
            [
                'status' => 'review',
                'error_message' => null,
                'processed_at' => $now,
                'updated_at' => $now,
                'id' => $jobId,
                'tenant_id' => $tenantId,
            ]
        );

        $pdo->commit();

        foreach ($openImages as $imageResource) {
            if (is_resource($imageResource) || is_object($imageResource)) {
                imagedestroy($imageResource);
            }
        }

        return [
            'page_count' => count($renderedPages),
            'row_count' => count($rowsToInsert),
            'used_template' => $template !== null,
        ];
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        foreach ($openImages as $imageResource) {
            if (is_resource($imageResource) || is_object($imageResource)) {
                imagedestroy($imageResource);
            }
        }

        app_execute(
            $pdo,
            'UPDATE scan_import_jobs SET status = :status, error_message = :error_message, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id',
            [
                'status' => 'failed',
                'error_message' => mb_substr($exception->getMessage(), 0, 8000),
                'updated_at' => date('Y-m-d H:i:s'),
                'id' => $jobId,
                'tenant_id' => $tenantId,
            ]
        );

        throw $exception;
    }
}

function app_scan_import_save_review(PDO $pdo, string $tenantId, string $jobId, array $correctedFront, array $correctedBack): int
{
    $tenantId = trim($tenantId);
    $jobId = trim($jobId);

    if ($tenantId === '' || $jobId === '') {
        throw new RuntimeException('Der Scan-Job konnte nicht für das Review geladen werden.');
    }

    if (!app_scan_import_rows_table_ready($pdo)) {
        throw new RuntimeException('Die Scan-Import-Zeilentabelle fehlt. Bitte zuerst die Migrationen ausführen.');
    }

    $job = app_scan_import_job_for_tenant($pdo, $tenantId, $jobId);
    if ($job === null) {
        throw new RuntimeException('Der ausgewählte Scan-Job wurde nicht gefunden.');
    }

    if (strtolower(trim((string) ($job['status'] ?? ''))) === 'posted') {
        throw new RuntimeException('Der Scan-Job wurde bereits verbucht und kann nicht mehr geändert werden.');
    }

    $rows = app_scan_import_rows_for_job($pdo, $tenantId, $jobId);
    if ($rows === []) {
        throw new RuntimeException('Für diesen Scan-Job gibt es noch keine Review-Zeilen.');
    }

    $now = date('Y-m-d H:i:s');
    $updated = 0;
    $pdo->beginTransaction();

    try {
        foreach ($rows as $row) {
            $rowId = trim((string) ($row['id'] ?? ''));
            if ($rowId === '') {
                continue;
            }

            $detectedFront = max(0, (int) ($row['front_strokes_detected'] ?? 0));
            $detectedBack = max(0, (int) ($row['back_strokes_detected'] ?? 0));

            $existingCorrectedFront = $row['corrected_front_strokes'];
            $existingCorrectedBack = $row['corrected_back_strokes'];
            $front = $existingCorrectedFront !== null ? max(0, (int) $existingCorrectedFront) : $detectedFront;
            $back = $existingCorrectedBack !== null ? max(0, (int) $existingCorrectedBack) : $detectedBack;

            if (array_key_exists($rowId, $correctedFront)) {
                $front = max(0, (int) $correctedFront[$rowId]);
            }
            if (array_key_exists($rowId, $correctedBack)) {
                $back = max(0, (int) $correctedBack[$rowId]);
            }

            $memberId = trim((string) ($row['member_id'] ?? ''));
            $needsReview = ($memberId === '' && ($front > 0 || $back > 0)) ? 1 : 0;

            app_execute(
                $pdo,
                'UPDATE scan_import_rows SET corrected_front_strokes = :corrected_front_strokes, corrected_back_strokes = :corrected_back_strokes, needs_review = :needs_review, updated_at = :updated_at WHERE id = :id AND job_id = :job_id',
                [
                    'corrected_front_strokes' => $front,
                    'corrected_back_strokes' => $back,
                    'needs_review' => $needsReview,
                    'updated_at' => $now,
                    'id' => $rowId,
                    'job_id' => $jobId,
                ]
            );
            $updated++;
        }

        app_execute(
            $pdo,
            'UPDATE scan_import_jobs SET status = :status, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id AND status <> :posted_status',
            [
                'status' => 'review',
                'updated_at' => $now,
                'id' => $jobId,
                'tenant_id' => $tenantId,
                'posted_status' => 'posted',
            ]
        );

        $pdo->commit();

        return $updated;
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        throw $exception;
    }
}

function app_scan_import_mark_approved(PDO $pdo, string $tenantId, string $jobId): array
{
    $tenantId = trim($tenantId);
    $jobId = trim($jobId);

    if ($tenantId === '' || $jobId === '') {
        throw new RuntimeException('Der Scan-Job konnte nicht freigegeben werden.');
    }

    if (!app_scan_import_rows_table_ready($pdo)) {
        throw new RuntimeException('Die Scan-Import-Zeilentabelle fehlt. Bitte zuerst die Migrationen ausführen.');
    }

    $job = app_scan_import_job_for_tenant($pdo, $tenantId, $jobId);
    if ($job === null) {
        throw new RuntimeException('Der ausgewählte Scan-Job wurde nicht gefunden.');
    }

    if (strtolower(trim((string) ($job['status'] ?? ''))) === 'posted') {
        throw new RuntimeException('Der Scan-Job wurde bereits verbucht und kann nicht erneut freigegeben werden.');
    }

    $rows = app_scan_import_rows_for_job($pdo, $tenantId, $jobId);
    if ($rows === []) {
        throw new RuntimeException('Der Scan-Job enthält keine Zeilen für die Freigabe.');
    }

    $unresolved = 0;
    foreach ($rows as $row) {
        $front = $row['corrected_front_strokes'] !== null
            ? max(0, (int) $row['corrected_front_strokes'])
            : max(0, (int) ($row['front_strokes_detected'] ?? 0));
        $back = $row['corrected_back_strokes'] !== null
            ? max(0, (int) $row['corrected_back_strokes'])
            : max(0, (int) ($row['back_strokes_detected'] ?? 0));
        $memberId = trim((string) ($row['member_id'] ?? ''));

        if (($front > 0 || $back > 0) && $memberId === '') {
            $unresolved++;
        }
    }

    if ($unresolved > 0) {
        throw new RuntimeException('Es gibt noch ' . $unresolved . ' Zeile(n) ohne Mitgliedszuordnung mit Strichwerten. Bitte erst im Review klären.');
    }

    $now = date('Y-m-d H:i:s');
    $pdo->beginTransaction();

    try {
        foreach ($rows as $row) {
            $rowId = trim((string) ($row['id'] ?? ''));
            if ($rowId === '') {
                continue;
            }

            $front = $row['corrected_front_strokes'] !== null
                ? max(0, (int) $row['corrected_front_strokes'])
                : max(0, (int) ($row['front_strokes_detected'] ?? 0));
            $back = $row['corrected_back_strokes'] !== null
                ? max(0, (int) $row['corrected_back_strokes'])
                : max(0, (int) ($row['back_strokes_detected'] ?? 0));

            app_execute(
                $pdo,
                'UPDATE scan_import_rows SET corrected_front_strokes = :corrected_front_strokes, corrected_back_strokes = :corrected_back_strokes, needs_review = :needs_review, updated_at = :updated_at WHERE id = :id AND job_id = :job_id',
                [
                    'corrected_front_strokes' => $front,
                    'corrected_back_strokes' => $back,
                    'needs_review' => 0,
                    'updated_at' => $now,
                    'id' => $rowId,
                    'job_id' => $jobId,
                ]
            );
        }

        app_execute(
            $pdo,
            'UPDATE scan_import_jobs SET status = :status, updated_at = :updated_at WHERE id = :id AND tenant_id = :tenant_id',
            [
                'status' => 'approved',
                'updated_at' => $now,
                'id' => $jobId,
                'tenant_id' => $tenantId,
            ]
        );

        $pdo->commit();

        return [
            'row_count' => count($rows),
            'status' => 'approved',
        ];
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        throw $exception;
    }
}

function app_handle_scan_import_action(PDO $pdo, array $auth): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        return;
    }

    $action = trim((string) ($_POST['action'] ?? ''));
    if (!in_array($action, ['create-scan-import-job', 'process-scan-import-job', 'save-scan-import-review', 'approve-scan-import-review'], true)) {
        return;
    }

    if (!app_can_manage_finance($auth)) {
        app_flash('Für diesen PDF-Upload brauchst du Finanz- oder Tenant-Rechte.', 'warning');
        app_redirect('/imports/');
    }

    try {
        if (!app_scan_import_tables_ready($pdo)) {
            throw new RuntimeException('Die Scan-Import-Tabellen sind noch nicht angelegt. Bitte zuerst die Migrationen ausführen.');
        }

        $tenantId = trim((string) ($auth['tenant_id'] ?? ''));
        if ($tenantId === '') {
            throw new RuntimeException('Der Mandant konnte für den Scan-Import nicht bestimmt werden.');
        }

        if ($action === 'create-scan-import-job') {
            $file = $_FILES['pdf_file'] ?? $_FILES['scan_pdf'] ?? [];
            if ($file === [] && !empty($_FILES)) {
                foreach ($_FILES as $candidateFile) {
                    if (is_array($candidateFile)) {
                        $file = $candidateFile;
                        break;
                    }
                }
            }

            $templateId = trim((string) ($_POST['template_id'] ?? ''));
            app_create_scan_import_job($pdo, $auth, $file, $templateId);

            app_flash('Die PDF-Datei wurde hochgeladen und als Import-Job angelegt.', 'success');
            app_redirect('/imports/');
        }

        $jobId = trim((string) ($_POST['job_id'] ?? ''));
        if ($jobId === '') {
            throw new RuntimeException('Bitte wähle einen Scan-Job aus.');
        }

        if ($action === 'process-scan-import-job') {
            $result = app_scan_import_process_job($pdo, $tenantId, $jobId);
            app_flash(
                'Der Scan-Job wurde verarbeitet: ' . (int) ($result['page_count'] ?? 0) . ' Seite(n), ' . (int) ($result['row_count'] ?? 0) . ' Zeile(n).',
                'success'
            );
            app_redirect('/imports/?scan_job=' . rawurlencode($jobId));
        }

        if ($action === 'save-scan-import-review') {
            $changedRows = app_scan_import_save_review(
                $pdo,
                $tenantId,
                $jobId,
                (array) ($_POST['corrected_front'] ?? []),
                (array) ($_POST['corrected_back'] ?? [])
            );
            app_flash($changedRows . ' Review-Zeile(n) wurden gespeichert.', 'success');
            app_redirect('/imports/?scan_job=' . rawurlencode($jobId));
        }

        if ($action === 'approve-scan-import-review') {
            $result = app_scan_import_mark_approved($pdo, $tenantId, $jobId);
            app_flash('Der Scan-Job wurde freigegeben (' . (int) ($result['row_count'] ?? 0) . ' Zeile(n)).', 'success');
            app_redirect('/imports/?scan_job=' . rawurlencode($jobId));
        }
    } catch (Throwable $exception) {
        app_flash($exception->getMessage(), 'error');
        $jobId = trim((string) ($_POST['job_id'] ?? ''));
        if ($jobId !== '') {
            app_redirect('/imports/?scan_job=' . rawurlencode($jobId));
        }
        app_redirect('/imports/');
    }
}

function app_handle_tenant_action(PDO $pdo, array $auth): void
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        return;
    }

    $action = (string) ($_POST['action'] ?? '');
    $tenantId = (string) ($auth['tenant_id'] ?? '');

    if (!in_array($action, ['record-coffee', 'record-payment', 'delete-coffee', 'delete-payment', 'bulk-record-coffee', 'bulk-record-payment', 'import-payments-csv'], true) || $tenantId === '') {
        return;
    }

    $settings = app_tenant_settings($pdo, $tenantId);
    $canManageFinance = app_can_manage_finance($auth);
    if (in_array($action, ['delete-coffee', 'delete-payment'], true)) {
        if (!$canManageFinance) {
            app_flash('Für diese Aktion brauchst du Finanz- oder Tenant-Rechte.', 'warning');
            app_redirect('/dashboard/');
        }

        try {
            if ($action === 'delete-coffee') {
                app_delete_coffee_entry(
                    $pdo,
                    $tenantId,
                    (string) ($_POST['reference_id'] ?? ''),
                    (string) ($auth['member_id'] ?? ''),
                    'manual_reversal'
                );
                app_flash('Der Stricheintrag wurde storniert.', 'success');
                app_redirect('/ledger/');
            }

            app_delete_payment_entry(
                $pdo,
                $tenantId,
                (string) ($_POST['payment_id'] ?? ''),
                (string) ($auth['member_id'] ?? ''),
                'manual_reversal'
            );
            app_flash('Die Einzahlung wurde storniert.', 'success');
            app_redirect('/payments/');
        } catch (Throwable $exception) {
            app_flash($exception->getMessage(), 'error');
            app_redirect(app_request_path());
        }
    }

    $isSelfServiceCoffee = $action === 'record-coffee'
        && !$canManageFinance
        && ($settings['allow_self_service_booking'] ?? '1') === '1';

    if (!$canManageFinance && !$isSelfServiceCoffee) {
        app_flash('Für diese Aktion brauchst du Finanz- oder Tenant-Rechte.', 'warning');
        app_redirect('/dashboard/');
    }

    try {
        if ($action === 'record-coffee') {
            $memberId = trim((string) ($_POST['member_id'] ?? ''));
            $strokes = max(0, (int) ($_POST['strokes'] ?? 0));
            $member = app_member_exists($pdo, $tenantId, $memberId);

            if ($member === null) {
                throw new RuntimeException('Das ausgewählte Mitglied konnte in diesem Tenant nicht gefunden werden.');
            }

            if ($isSelfServiceCoffee && (string) ($auth['member_id'] ?? '') !== $memberId) {
                throw new RuntimeException('Im Self-Service kannst du nur deine eigenen Striche buchen.');
            }

            app_create_coffee_booking(
                $pdo,
                $tenantId,
                $memberId,
                $strokes,
                (float) ($_POST['unit_price'] ?? 0),
                app_normalize_datetime_input((string) ($_POST['booked_at'] ?? '')),
                trim((string) ($_POST['booking_source'] ?? 'manual')) ?: 'manual'
            );
            app_flash($strokes . ' Strich(e) für ' . $member['display_name'] . ' wurden eingetragen.', 'success');
            app_redirect($isSelfServiceCoffee ? '/dashboard/' : '/ledger/');
        }

        if ($action === 'record-payment') {
            $memberId = trim((string) ($_POST['member_id'] ?? ''));
            $member = app_member_exists($pdo, $tenantId, $memberId);

            if ($member === null) {
                throw new RuntimeException('Das ausgewählte Mitglied konnte in diesem Tenant nicht gefunden werden.');
            }

            app_create_payment_booking(
                $pdo,
                $tenantId,
                $memberId,
                (float) ($_POST['amount'] ?? 0),
                app_normalize_datetime_input((string) ($_POST['booked_at'] ?? '')),
                trim((string) ($_POST['payment_method'] ?? 'manual')) ?: 'manual'
            );
            app_flash('Die Einzahlung für ' . $member['display_name'] . ' wurde gebucht.', 'success');
            app_redirect('/payments/');
        }

        if ($action === 'bulk-record-coffee') {
            $unitPrice = (float) ($_POST['unit_price'] ?? 0);
            $bookedAt = app_normalize_datetime_input((string) ($_POST['booked_at'] ?? ''));
            $entries = $_POST['strokes'] ?? [];
            $created = 0;

            $pdo->beginTransaction();
            foreach ($entries as $memberId => $strokes) {
                $strokeCount = max(0, (int) $strokes);
                if ($strokeCount < 1) {
                    continue;
                }

                if (app_member_exists($pdo, $tenantId, (string) $memberId) === null) {
                    continue;
                }

                app_create_coffee_booking($pdo, $tenantId, (string) $memberId, $strokeCount, $unitPrice, $bookedAt, 'bulk');
                $created++;
            }
            $pdo->commit();
            app_flash($created . ' Kaffee-Buchung(en) wurden erfasst.', 'success');
            app_redirect('/ledger/?scope=' . rawurlencode((string) ($_POST['scope'] ?? 'all')));
        }

        if ($action === 'bulk-record-payment') {
            $bookedAt = app_normalize_datetime_input((string) ($_POST['booked_at'] ?? ''));
            $method = trim((string) ($_POST['payment_method'] ?? 'manual')) ?: 'manual';
            $entries = $_POST['amounts'] ?? [];
            $created = 0;

            $pdo->beginTransaction();
            foreach ($entries as $memberId => $amount) {
                $paymentAmount = (float) str_replace(',', '.', (string) $amount);
                if ($paymentAmount <= 0) {
                    continue;
                }

                if (app_member_exists($pdo, $tenantId, (string) $memberId) === null) {
                    continue;
                }

                app_create_payment_booking($pdo, $tenantId, (string) $memberId, $paymentAmount, $bookedAt, $method);
                $created++;
            }
            $pdo->commit();
            app_flash($created . ' Einzahlung(en) wurden erfasst.', 'success');
            app_redirect('/payments/?scope=' . rawurlencode((string) ($_POST['scope'] ?? 'all')));
        }

        if ($action === 'import-payments-csv') {
            $pdo->beginTransaction();
            $result = app_import_payment_csv($pdo, $tenantId, $_FILES['csv_file'] ?? []);
            $pdo->commit();
            app_flash($result['created'] . ' Zahlungen importiert, ' . $result['duplicates'] . ' Dubletten übersprungen, ' . $result['missing'] . ' ohne Zuordnung.', 'success');
            app_redirect('/payments/');
        }
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        app_flash($exception->getMessage(), 'error');
        app_redirect(app_request_path());
    }
}

function app_marketing_messages(): array
{
    return [
        'hero' => 'Die Kaffeeliste für Teams, Standorte und gemeinsame Abläufe.',
        'subhero' => 'Eine klare Weboberfläche für Anmeldung, Buchungen, Einzahlungen und Hinweise. Schnell verständlich, auch am Handy.',
        'benefits' => [
            'Mitglieder melden sich ohne Umwege an.',
            'Verantwortliche behalten Buchungen und Einzahlungen im Blick.',
            'Standorte und Rollen bleiben sauber getrennt.',
        ],
    ];
}

function app_h(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES);
}

require_once __DIR__ . '/app-extensions.php';
