<?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_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_platform_admin_by_email(PDO $pdo, string $email): ?array
{
    return app_query_one(
        $pdo,
        'SELECT id, email, password_hash, display_name, is_platform_admin, 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'],
        '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_tenant_user_by_id(PDO $pdo, string $tenantUserId, string $tenantId): ?array
{
    return app_query_one(
        $pdo,
        <<<'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,
    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, 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
{
    return app_query_one(
        $pdo,
        <<<'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,
    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, 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'] ?? ''),
        'status' => (string) ($member['member_status'] ?? $member['status'] ?? 'active'),
        'password' => '',
        'is_tenant_admin' => in_array('tenant_admin', $roleKeys, true),
    ];
}

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'] ?? '')));
    $status = trim((string) ($data['status'] ?? 'active'));
    $password = (string) ($data['password'] ?? '');
    $isTenantAdmin = !empty($data['is_tenant_admin']);

    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.'
            );
        }
    }

    scripts_ensure_core_roles($pdo);
    $tenantAdminRoleId = scripts_role_id('tenant_admin', 'tenant');
    $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,
                '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',
                [
                    'display_name' => $displayName,
                    'email' => $email,
                    'status' => $status,
                    'updated_at' => $now,
                    'tenant_user_id' => $tenantUserId,
                    'tenant_id' => $tenantId,
                ]
            );
        } 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,
                '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)',
                [
                    'id' => app_uuid(),
                    'tenant_id' => $tenantId,
                    'tenant_user_id' => $tenantUserId,
                    'display_name' => $displayName,
                    'email' => $email,
                    'status' => $status,
                    'created_at' => $now,
                    'updated_at' => $now,
                ]
            );
        }

        $existingRoleAssignment = app_query_one(
            $pdo,
            'SELECT id FROM tenant_user_roles WHERE tenant_user_id = :tenant_user_id AND role_id = :role_id LIMIT 1',
            ['tenant_user_id' => $tenantUserId, 'role_id' => $tenantAdminRoleId]
        );

        if ($isTenantAdmin && $existingRoleAssignment === null) {
            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' => $tenantAdminRoleId,
                    'created_at' => $now,
                ]
            );
        }

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

        $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'] ?? '');

    if ($action !== 'save-member') {
        return;
    }

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

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

    try {
        app_upsert_member($pdo, $tenantId, [
            'tenant_user_id' => (string) ($_POST['tenant_user_id'] ?? ''),
            'display_name' => (string) ($_POST['display_name'] ?? ''),
            'email' => (string) ($_POST['email'] ?? ''),
            'status' => (string) ($_POST['status'] ?? 'active'),
            'password' => (string) ($_POST['password'] ?? ''),
            'is_tenant_admin' => isset($_POST['is_tenant_admin']),
        ]);
        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_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 === '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
{
    $sql = <<<'SQL'
SELECT
    u.id AS user_id,
    u.email AS user_email,
    u.password_hash,
    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, 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, $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'],
        '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_tenant_dashboard(PDO $pdo, string $tenantId): array
{
    $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]) ?? [];

    $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;

    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'),
        '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'];

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

    $summarySql = <<<'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 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 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_members_for_tenant(PDO $pdo, string $tenantId): array
{
    $sql = <<<'SQL'
SELECT
    m.id,
    tu.id AS tenant_user_id,
    tu.user_id,
    m.display_name,
    m.email,
    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
FROM members m
LEFT JOIN tenant_users tu ON tu.id = m.tenant_user_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
GROUP BY m.id, tu.id, tu.user_id, m.display_name, m.email, m.status, u.display_name
ORDER BY m.display_name ASC
SQL;

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

function app_ledger_for_tenant(PDO $pdo, string $tenantId): array
{
    $sql = <<<'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 25
SQL;

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

function app_payments_for_tenant(PDO $pdo, string $tenantId): array
{
    $sql = <<<'SQL'
SELECT
    pe.booked_at,
    pe.amount,
    pe.payment_method,
    m.display_name AS member_name
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_content_for_tenant(PDO $pdo, string $tenantId): array
{
    $announcements = app_query_all(
        $pdo,
        'SELECT title, message, visible_until FROM announcements WHERE tenant_id = :tenant_id AND is_active = 1 ORDER BY created_at DESC LIMIT 8',
        ['tenant_id' => $tenantId]
    );

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

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

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' => '',
        'location_label' => '',
        'allow_self_service_booking' => '1',
        'payment_hint' => 'Bitte Einzahlungen zeitnah verbuchen.',
    ];
}

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'] ?? '')),
        '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'] ?? '')),
    ];

    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.');
    }

    $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_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'], true) || $tenantId === '') {
        return;
    }

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

    $memberId = trim((string) ($_POST['member_id'] ?? ''));
    $member = app_member_exists($pdo, $tenantId, $memberId);

    if ($member === null) {
        app_flash('Das ausgewählte Mitglied konnte in diesem Tenant nicht gefunden werden.', 'error');
        app_redirect(app_request_path());
    }

    $bookedAt = trim((string) ($_POST['booked_at'] ?? ''));
    $bookedAt = $bookedAt !== '' ? str_replace('T', ' ', $bookedAt) . ':00' : date('Y-m-d H:i:s');
    $now = date('Y-m-d H:i:s');

    try {
        $pdo->beginTransaction();

        if ($action === 'record-coffee') {
            $strokes = max(0, (int) ($_POST['strokes'] ?? 0));
            $unitPrice = (float) ($_POST['unit_price'] ?? 0);

            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();
            $totalCost = round($strokes * $unitPrice, 2);
            $source = trim((string) ($_POST['booking_source'] ?? 'manual'));
            $source = $source !== '' ? $source : 'manual';

            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,
                ]
            );

            $pdo->commit();
            app_flash($strokes . ' Strich(e) für ' . $member['display_name'] . ' wurden eingetragen.', 'success');
            app_redirect('/ledger/');
        }

        if ($action === 'record-payment') {
            $amount = (float) ($_POST['amount'] ?? 0);
            $method = trim((string) ($_POST['payment_method'] ?? 'manual'));
            $method = $method !== '' ? $method : 'manual';

            if ($amount <= 0) {
                throw new RuntimeException('Bitte gib einen gültigen Einzahlungsbetrag an.');
            }

            $entryId = app_uuid();
            $ledgerId = app_uuid();

            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,
                    '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,
                ]
            );

            $pdo->commit();
            app_flash('Die Einzahlung für ' . $member['display_name'] . ' wurde gebucht.', 'success');
            app_redirect('/payments/');
        }

        $pdo->commit();
    } 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 moderne Kaffeeliste für Teams, Standorte und gemeinsame Services.',
        'subhero' => 'Zentrale Anmeldung, transparente Kontostände, Einzahlungen, Buchungen und Hinweise in einer webbasierten SaaS-Lösung, die sich leicht betreiben lässt.',
        'benefits' => [
            'Ein Login für alle Mitgliedschaften statt verteilter Tenant-URLs.',
            'Eine zentrale Verwaltung für Tenants, Zugänge und Betriebsübersicht.',
            'Klare Abläufe für Buchungen, Einzahlungen, Hinweise und Mitgliederverwaltung.',
        ],
    ];
}

function app_h(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES);
}
