<?php

declare(strict_types=1);

namespace OCA\Mitgliederverwaltung\Service;

use OCA\Mitgliederverwaltung\Db\PlatformHelper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;

/**
 * Translates JSON AST query definitions into Doctrine DBAL QueryBuilder
 * conditions, executes them against the members table, and returns results.
 *
 * AST format:
 *   { "and": [{ "field": "alter", "op": "<", "value": 14 }, ...] }
 *   { "or":  [{ "and": [...] }, { "field": "stufe", "op": "=", "value": "W" }] }
 *   { "not": true, "and": [...] }                 // NOT wrapping a group
 *   { "not": true, "field": "status", "op": "=", "value": "aktiv" }  // NOT on leaf
 *
 * Supports nested AND/OR/NOT groups, a whitelist of allowed fields,
 * EXISTS-based filters on related entities (phones, emails, fees, camps,
 * injuries, family) and server-side-decrypted filtering on allergien.
 */
class QueryService {

    /** Sentinel prefix to mark a field as an EXISTS-based relation. */
    private const RELATION_PREFIX = '__rel_';
    /** Sentinel prefix to mark a field as computed at SQL level. */
    private const COMPUTED_PREFIX = '__computed_';
    /** Sentinel prefix to mark a field as requiring PHP-side decrypt-and-filter. */
    private const DECRYPT_PREFIX = '__decrypt_';

    /**
     * Whitelist of allowed query fields mapped to their DB column or expression.
     */
    private const FIELD_MAP = [
        // ── Core member columns ────────────────────────────────────
        'vorname' => 'm.vorname',
        'nachname' => 'm.nachname',
        'geburtsdatum' => 'm.geburtsdatum',
        'geschlecht' => 'm.geschlecht',
        'rolle' => 'm.rolle',
        'stufe_id' => 'm.stufe_id',
        'eintritt' => 'm.eintritt',
        'austritt' => 'm.austritt',
        'status' => 'm.status',
        'kv_typ' => 'm.kv_typ',
        'kv_name' => 'm.kv_name',
        'family_id' => 'm.family_id',
        'notizen' => 'm.notizen',
        'zusatz_notizen' => 'm.zusatz_notizen',
        'einwilligung_datum' => 'm.einwilligung_datum',
        'juleica_nummer' => 'm.juleica_nummer',
        'juleica_ablaufdatum' => 'm.juleica_ablaufdatum',

        // ── Address sub-fields (joined) ────────────────────────────
        'adresse_plz' => 'a.plz',
        'adresse_ort' => 'a.ort',
        'adresse_strasse' => 'a.strasse',
        'adresse_land' => 'a.land',

        // ── Computed fields (platform-specific SQL) ─────────────────
        'alter' => self::COMPUTED_PREFIX . 'age',
        'mitgliedsdauer' => self::COMPUTED_PREFIX . 'membership_years',

        // ── Encrypted field (PHP-side filter) ───────────────────────
        'allergien' => self::DECRYPT_PREFIX . 'allergien',

        // ── Related entities (EXISTS subqueries) ───────────────────
        'telefon' => self::RELATION_PREFIX . 'phone',
        'email' => self::RELATION_PREFIX . 'email',
        'familie_name' => self::RELATION_PREFIX . 'family_name',
        'beitrag_bezahlt_jahr' => self::RELATION_PREFIX . 'fee_paid_year',
        'beitrag_offen_jahr' => self::RELATION_PREFIX . 'fee_unpaid_year',
        'beitrag_betrag' => self::RELATION_PREFIX . 'fee_amount',
        'lager_teilnahme' => self::RELATION_PREFIX . 'camp_any',
        'lager_name' => self::RELATION_PREFIX . 'camp_name',
        'verletzung_vorhanden' => self::RELATION_PREFIX . 'injury_any',
    ];

    /** Allowed comparison operators. */
    private const ALLOWED_OPERATORS = [
        '=', '!=', '<', '>', '<=', '>=',
        'contains', 'starts_with',
        'is_empty', 'is_not_empty',
    ];

    private IDBConnection $db;
    private LoggerInterface $logger;
    private ?EncryptionService $encryptionService;
    private ?IUserSession $userSession;

    public function __construct(
        IDBConnection $db,
        LoggerInterface $logger,
        ?EncryptionService $encryptionService = null,
        ?IUserSession $userSession = null
    ) {
        $this->db = $db;
        $this->logger = $logger;
        $this->encryptionService = $encryptionService;
        $this->userSession = $userSession;
    }

    /**
     * Execute a query AST and return matching members.
     *
     * @param array $ast The query AST (JSON-decoded)
     * @param array|null $sort Sort configuration
     * @param int $limit Maximum results
     * @param int $offset Offset for pagination
     * @return array{data: array, total: int}
     * @throws ValidationException
     */
    public function execute(array $ast, ?array $sort = null, int $limit = 500, int $offset = 0): array {
        $this->validateAst($ast);

        $allergienLeaves = $this->extractAllergienContentLeaves($ast);
        $this->auditAllergienQueries($allergienLeaves);

        if (!empty($allergienLeaves)) {
            return $this->executeWithAllergienFilter($ast, $allergienLeaves, $sort, $limit, $offset);
        }

        return $this->executeSql($ast, $sort, $limit, $offset);
    }

    /**
     * Build and run the SQL query for an AST that needs no PHP-side filtering.
     *
     * @param array $ast
     * @param array|null $sort
     * @param int $limit
     * @param int $offset
     * @return array{data: array, total: int}
     */
    private function executeSql(array $ast, ?array $sort, int $limit, int $offset): array {
        $qb = $this->db->getQueryBuilder();
        $needsAddressJoin = $this->astReferencesAddress($ast);

        $qb->select('m.*')
            ->from('mv_members', 'm')
            ->where($qb->expr()->isNull('m.deleted_at'));

        if ($needsAddressJoin) {
            $qb->leftJoin('m', 'mv_addresses', 'a',
                $qb->expr()->andX(
                    $qb->expr()->eq('a.member_id', 'm.id'),
                    $qb->expr()->eq('a.is_primary', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT))
                )
            );
        }

        $conditions = $this->buildConditions($qb, $ast);
        if ($conditions !== null) {
            $qb->andWhere($conditions);
        }

        $countQb = clone $qb;
        $countQb->select($countQb->createFunction('COUNT(DISTINCT m.id) as cnt'));
        $countResult = $countQb->executeQuery();
        $total = (int)$countResult->fetch()['cnt'];
        $countResult->closeCursor();

        $this->applySorting($qb, $sort);
        $qb->setFirstResult($offset)->setMaxResults($limit);
        $qb->groupBy('m.id');

        $result = $qb->executeQuery();
        $rows = [];
        while ($row = $result->fetch()) {
            $rows[] = $this->formatRow($row);
        }
        $result->closeCursor();

        return ['data' => $rows, 'total' => $total];
    }

    /**
     * Execute a query whose AST references the encrypted allergien field.
     *
     * Strategy:
     *  1. Build SQL with the allergien content-operator leaves replaced by
     *     a cheap allergien IS NOT NULL guard (minimises candidate set).
     *  2. Fetch all candidate rows (no pagination) — preserves correctness
     *     because total depends on the decrypted filter.
     *  3. Decrypt allergien per row and filter by the full original AST.
     *  4. Apply sort + pagination in PHP.
     *
     * For small / mid-sized scout-group datasets this is plenty fast.
     *
     * @param array $ast
     * @param list<array> $allergienLeaves Leaves with allergien content ops
     * @param array|null $sort
     * @param int $limit
     * @param int $offset
     * @return array{data: array, total: int}
     */
    private function executeWithAllergienFilter(
        array $ast,
        array $allergienLeaves,
        ?array $sort,
        int $limit,
        int $offset
    ): array {
        if ($this->encryptionService === null) {
            throw new ValidationException('Allergien-Abfrage nicht verfuegbar: EncryptionService fehlt');
        }

        $sqlAst = $this->replaceAllergienContentWithGuard($ast);

        $qb = $this->db->getQueryBuilder();
        $needsAddressJoin = $this->astReferencesAddress($sqlAst);

        $qb->select('m.*', 'm.allergien_encrypted AS _allergien_ct')
            ->from('mv_members', 'm')
            ->where($qb->expr()->isNull('m.deleted_at'));

        if ($needsAddressJoin) {
            $qb->leftJoin('m', 'mv_addresses', 'a',
                $qb->expr()->andX(
                    $qb->expr()->eq('a.member_id', 'm.id'),
                    $qb->expr()->eq('a.is_primary', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT))
                )
            );
        }

        $conditions = $this->buildConditions($qb, $sqlAst);
        if ($conditions !== null) {
            $qb->andWhere($conditions);
        }
        $qb->groupBy('m.id');

        $result = $qb->executeQuery();
        $allRows = [];
        while ($row = $result->fetch()) {
            $allRows[] = $row;
        }
        $result->closeCursor();

        $filtered = [];
        foreach ($allRows as $row) {
            $plaintext = $this->decryptAllergien($row['allergien_encrypted'] ?? null);
            if ($this->matchesAllergienLeaves($plaintext, $allergienLeaves)) {
                $filtered[] = $row;
            }
        }

        $total = count($filtered);

        usort($filtered, $this->buildRowComparator($sort));

        $paged = array_slice($filtered, $offset, $limit);
        $data = array_map(fn($row) => $this->formatRow($row), $paged);

        return ['data' => $data, 'total' => $total];
    }

    /**
     * Apply ORDER BY based on sort spec or default nachname,vorname.
     */
    private function applySorting(IQueryBuilder $qb, ?array $sort): void {
        if ($sort !== null && !empty($sort)) {
            $applied = false;
            foreach ($sort as $sortItem) {
                $field = $sortItem['field'] ?? null;
                $dir = strtoupper($sortItem['dir'] ?? 'ASC');
                if ($field !== null && isset(self::FIELD_MAP[$field]) && in_array($dir, ['ASC', 'DESC'])) {
                    $column = self::FIELD_MAP[$field];
                    if ($this->isPlainColumn($column)) {
                        $qb->addOrderBy($column, $dir);
                        $applied = true;
                    }
                }
            }
            if ($applied) {
                return;
            }
        }
        $qb->orderBy('m.nachname', 'ASC')->addOrderBy('m.vorname', 'ASC');
    }

    /**
     * Get the list of available query fields with their metadata.
     */
    public function getAvailableFields(): array {
        return [
            // Mitglied
            ['key' => 'vorname', 'label' => 'Vorname', 'type' => 'string', 'group' => 'Mitglied'],
            ['key' => 'nachname', 'label' => 'Nachname', 'type' => 'string', 'group' => 'Mitglied'],
            ['key' => 'geburtsdatum', 'label' => 'Geburtsdatum', 'type' => 'date', 'group' => 'Mitglied'],
            ['key' => 'geschlecht', 'label' => 'Geschlecht', 'type' => 'string', 'group' => 'Mitglied'],
            ['key' => 'rolle', 'label' => 'Rolle', 'type' => 'string', 'group' => 'Mitglied'],
            ['key' => 'stufe_id', 'label' => 'Stufe', 'type' => 'number', 'group' => 'Mitglied'],
            ['key' => 'eintritt', 'label' => 'Eintritt', 'type' => 'date', 'group' => 'Mitglied'],
            ['key' => 'austritt', 'label' => 'Austritt', 'type' => 'date', 'group' => 'Mitglied'],
            ['key' => 'status', 'label' => 'Status', 'type' => 'string', 'group' => 'Mitglied'],
            ['key' => 'kv_typ', 'label' => 'KV Typ', 'type' => 'string', 'group' => 'Mitglied'],
            ['key' => 'kv_name', 'label' => 'KV Name', 'type' => 'string', 'group' => 'Mitglied'],
            ['key' => 'notizen', 'label' => 'Notizen', 'type' => 'string', 'group' => 'Mitglied'],
            ['key' => 'zusatz_notizen', 'label' => 'Zusätzliche Notizen', 'type' => 'string', 'group' => 'Mitglied'],
            ['key' => 'einwilligung_datum', 'label' => 'Einwilligungsdatum', 'type' => 'date', 'group' => 'Mitglied'],
            ['key' => 'juleica_nummer', 'label' => 'Juleica-Nummer', 'type' => 'string', 'group' => 'Mitglied'],
            ['key' => 'juleica_ablaufdatum', 'label' => 'Juleica-Ablaufdatum', 'type' => 'date', 'group' => 'Mitglied'],
            ['key' => 'alter', 'label' => 'Alter (Jahre)', 'type' => 'number', 'group' => 'Mitglied'],
            ['key' => 'mitgliedsdauer', 'label' => 'Mitgliedsdauer (Jahre)', 'type' => 'number', 'group' => 'Mitglied'],
            // Gesundheit
            ['key' => 'allergien', 'label' => 'Allergien (verschlüsselt)', 'type' => 'string', 'group' => 'Gesundheit', 'encrypted' => true],
            ['key' => 'verletzung_vorhanden', 'label' => 'Verletzung vermerkt', 'type' => 'string', 'group' => 'Gesundheit'],
            // Adresse
            ['key' => 'adresse_plz', 'label' => 'PLZ', 'type' => 'string', 'group' => 'Adresse'],
            ['key' => 'adresse_ort', 'label' => 'Ort', 'type' => 'string', 'group' => 'Adresse'],
            ['key' => 'adresse_strasse', 'label' => 'Straße', 'type' => 'string', 'group' => 'Adresse'],
            ['key' => 'adresse_land', 'label' => 'Land', 'type' => 'string', 'group' => 'Adresse'],
            // Kontakt
            ['key' => 'telefon', 'label' => 'Telefonnummer', 'type' => 'string', 'group' => 'Kontakt'],
            ['key' => 'email', 'label' => 'E-Mail', 'type' => 'string', 'group' => 'Kontakt'],
            // Familie
            ['key' => 'family_id', 'label' => 'Familien-ID', 'type' => 'number', 'group' => 'Familie'],
            ['key' => 'familie_name', 'label' => 'Familienname', 'type' => 'string', 'group' => 'Familie'],
            // Beiträge
            ['key' => 'beitrag_bezahlt_jahr', 'label' => 'Beitrag bezahlt in Jahr', 'type' => 'number', 'group' => 'Beiträge'],
            ['key' => 'beitrag_offen_jahr', 'label' => 'Beitrag offen in Jahr', 'type' => 'number', 'group' => 'Beiträge'],
            ['key' => 'beitrag_betrag', 'label' => 'Beitragsbetrag (aktuelles Jahr)', 'type' => 'number', 'group' => 'Beiträge'],
            // Lager
            ['key' => 'lager_teilnahme', 'label' => 'Lagerteilnahme (irgendein)', 'type' => 'string', 'group' => 'Lager'],
            ['key' => 'lager_name', 'label' => 'Lagername', 'type' => 'string', 'group' => 'Lager'],
        ];
    }

    // ── AST → SQL translation ───────────────────────────────────────

    /**
     * Build Doctrine DBAL composite expression from AST node.
     *
     * Returns an Expression/string SQL fragment, or null when the node
     * contributes no condition (empty and/or group).
     *
     * @return \OCP\DB\QueryBuilder\ICompositeExpression|string|null
     */
    private function buildConditions(IQueryBuilder $qb, array $node) {
        $inner = $this->buildInner($qb, $node);
        if ($inner === null) {
            return null;
        }
        if (!empty($node['not'])) {
            $innerSql = (string)$inner;
            if ($innerSql === '') {
                return null;
            }
            return $qb->createFunction('NOT (' . $innerSql . ')');
        }
        return $inner;
    }

    /**
     * Build the inner (un-negated) expression for a node.
     */
    private function buildInner(IQueryBuilder $qb, array $node) {
        if (isset($node['and'])) {
            $parts = [];
            foreach ($node['and'] as $child) {
                $c = $this->buildConditions($qb, $child);
                if ($c !== null) {
                    $parts[] = $c;
                }
            }
            return empty($parts) ? null : $qb->expr()->andX(...$parts);
        }

        if (isset($node['or'])) {
            $parts = [];
            foreach ($node['or'] as $child) {
                $c = $this->buildConditions($qb, $child);
                if ($c !== null) {
                    $parts[] = $c;
                }
            }
            return empty($parts) ? null : $qb->expr()->orX(...$parts);
        }

        if (isset($node['field']) && isset($node['op'])) {
            return $this->buildLeafCondition($qb, $node);
        }

        return null;
    }

    /**
     * Build a single condition from a leaf AST node.
     */
    private function buildLeafCondition(IQueryBuilder $qb, array $node): ?string {
        $field = $node['field'];
        $op = $node['op'];
        $value = $node['value'] ?? null;

        if (!isset(self::FIELD_MAP[$field])) {
            throw new ValidationException("Unbekanntes Feld: $field");
        }
        if (!in_array($op, self::ALLOWED_OPERATORS)) {
            throw new ValidationException("Unbekannter Operator: $op");
        }

        $column = self::FIELD_MAP[$field];

        if (str_starts_with($column, self::COMPUTED_PREFIX)) {
            return $this->buildComputedCondition($qb, $column, $op, $value);
        }
        if (str_starts_with($column, self::RELATION_PREFIX)) {
            return $this->buildRelationCondition($qb, $column, $op, $value);
        }
        if (str_starts_with($column, self::DECRYPT_PREFIX)) {
            return $this->buildDecryptCondition($qb, $column, $op);
        }

        return $this->buildColumnCondition($qb, $column, $op, $value);
    }

    /**
     * Build a standard column-comparison condition.
     */
    private function buildColumnCondition(IQueryBuilder $qb, string $column, string $op, $value): ?string {
        if ($op === 'is_empty') {
            return (string)$qb->expr()->orX(
                $qb->expr()->isNull($column),
                $qb->expr()->eq($column, $qb->createNamedParameter(''))
            );
        }
        if ($op === 'is_not_empty') {
            return (string)$qb->expr()->andX(
                $qb->expr()->isNotNull($column),
                $qb->expr()->neq($column, $qb->createNamedParameter(''))
            );
        }
        if ($op === 'contains') {
            return (string)$qb->expr()->like(
                $column,
                $qb->createNamedParameter('%' . $this->escapeLike((string)$value) . '%')
            );
        }
        if ($op === 'starts_with') {
            return (string)$qb->expr()->like(
                $column,
                $qb->createNamedParameter($this->escapeLike((string)$value) . '%')
            );
        }

        $paramType = is_int($value) ? IQueryBuilder::PARAM_INT : IQueryBuilder::PARAM_STR;
        $param = $qb->createNamedParameter($value, $paramType);

        return match ($op) {
            '=' => (string)$qb->expr()->eq($column, $param),
            '!=' => (string)$qb->expr()->neq($column, $param),
            '<' => (string)$qb->expr()->lt($column, $param),
            '>' => (string)$qb->expr()->gt($column, $param),
            '<=' => (string)$qb->expr()->lte($column, $param),
            '>=' => (string)$qb->expr()->gte($column, $param),
            default => null,
        };
    }

    /**
     * Build a platform-dependent computed-column condition (alter / mitgliedsdauer).
     */
    private function buildComputedCondition(IQueryBuilder $qb, string $marker, string $op, $value): string {
        $baseColumn = $marker === self::COMPUTED_PREFIX . 'age' ? 'm.geburtsdatum' : 'm.eintritt';
        $expr = PlatformHelper::getYearDiffExpression($this->db, $baseColumn);

        if ($op === 'is_empty' || $op === 'is_not_empty') {
            throw new ValidationException("Operator $op nicht fuer berechnete Felder unterstuetzt");
        }

        $param = $qb->createNamedParameter((int)$value, IQueryBuilder::PARAM_INT);

        return match ($op) {
            '=' => "$expr = $param",
            '!=' => "$expr != $param",
            '<' => "$expr < $param",
            '>' => "$expr > $param",
            '<=' => "$expr <= $param",
            '>=' => "$expr >= $param",
            default => throw new ValidationException("Operator $op nicht fuer berechnete Felder unterstuetzt"),
        };
    }

    /**
     * Build a relation (EXISTS) condition for a related-entity field.
     */
    private function buildRelationCondition(IQueryBuilder $qb, string $marker, string $op, $value): string {
        $rel = substr($marker, strlen(self::RELATION_PREFIX));

        return match ($rel) {
            'phone' => $this->buildExistsSubquery($qb, 'mv_phones', 'p', 'p.number_e164', $op, $value),
            'email' => $this->buildExistsSubquery($qb, 'mv_emails', 'e', 'e.email', $op, $value),
            'family_name' => $this->buildFamilyNameSubquery($qb, $op, $value),
            'fee_paid_year' => $this->buildFeePaidSubquery($qb, (int)$value, true),
            'fee_unpaid_year' => $this->buildFeePaidSubquery($qb, (int)$value, false),
            'fee_amount' => $this->buildFeeAmountSubquery($qb, $op, $value),
            'camp_any' => $this->buildCampAnySubquery($qb, $op),
            'camp_name' => $this->buildCampNameSubquery($qb, $op, $value),
            'injury_any' => $this->buildInjuryAnySubquery($qb, $op),
            default => throw new ValidationException("Unbekannte Relation: $rel"),
        };
    }

    /**
     * Return an SQL fragment that is always TRUE for decrypt-marker leaves.
     *
     * For is_empty / is_not_empty we evaluate against allergien_encrypted directly,
     * because null/empty doesn't require decryption. For other operators this
     * branch is never called from executeSql(); executeWithAllergienFilter()
     * replaces such leaves with a guard before falling back to SQL.
     */
    private function buildDecryptCondition(IQueryBuilder $qb, string $marker, string $op): string {
        if ($marker !== self::DECRYPT_PREFIX . 'allergien') {
            throw new ValidationException('Unbekanntes verschluesseltes Feld');
        }
        if ($op === 'is_empty') {
            return (string)$qb->expr()->orX(
                $qb->expr()->isNull('m.allergien_encrypted'),
                $qb->expr()->eq('m.allergien_encrypted', $qb->createNamedParameter(''))
            );
        }
        if ($op === 'is_not_empty') {
            return (string)$qb->expr()->andX(
                $qb->expr()->isNotNull('m.allergien_encrypted'),
                $qb->expr()->neq('m.allergien_encrypted', $qb->createNamedParameter(''))
            );
        }
        // Fallback guard: require allergien to be present. Actual content match
        // happens in PHP after decryption.
        return (string)$qb->expr()->isNotNull('m.allergien_encrypted');
    }

    // ── Relation subquery builders ───────────────────────────────────

    /**
     * EXISTS (SELECT 1 FROM {table} {alias} WHERE {alias}.member_id = m.id AND {column} {op} ?)
     */
    private function buildExistsSubquery(IQueryBuilder $qb, string $table, string $alias, string $column, string $op, $value): string {
        $memberFk = $alias . '.member_id = m.id';

        if ($op === 'is_empty') {
            return 'NOT EXISTS (SELECT 1 FROM ' . $this->tableName($table) . ' ' . $alias
                . ' WHERE ' . $memberFk . ')';
        }
        if ($op === 'is_not_empty') {
            return 'EXISTS (SELECT 1 FROM ' . $this->tableName($table) . ' ' . $alias
                . ' WHERE ' . $memberFk . ')';
        }

        $comparison = $this->scalarComparison($qb, $column, $op, $value);

        return 'EXISTS (SELECT 1 FROM ' . $this->tableName($table) . ' ' . $alias
            . ' WHERE ' . $memberFk . ' AND ' . $comparison . ')';
    }

    private function buildFamilyNameSubquery(IQueryBuilder $qb, string $op, $value): string {
        if ($op === 'is_empty') {
            return '(m.family_id IS NULL OR NOT EXISTS (SELECT 1 FROM '
                . $this->tableName('mv_families') . ' f WHERE f.id = m.family_id))';
        }
        if ($op === 'is_not_empty') {
            return 'EXISTS (SELECT 1 FROM ' . $this->tableName('mv_families')
                . ' f WHERE f.id = m.family_id)';
        }
        $comparison = $this->scalarComparison($qb, 'f.name', $op, $value);
        return 'EXISTS (SELECT 1 FROM ' . $this->tableName('mv_families')
            . ' f WHERE f.id = m.family_id AND ' . $comparison . ')';
    }

    private function buildFeePaidSubquery(IQueryBuilder $qb, int $year, bool $paid): string {
        $yearParam = $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT);
        $paidParam = $qb->createNamedParameter($paid ? 1 : 0, IQueryBuilder::PARAM_INT);
        return 'EXISTS (SELECT 1 FROM ' . $this->tableName('mv_fee_records') . ' fr '
            . 'WHERE fr.member_id = m.id AND fr.year = ' . $yearParam
            . ' AND fr.paid = ' . $paidParam . ')';
    }

    private function buildFeeAmountSubquery(IQueryBuilder $qb, string $op, $value): string {
        $yearParam = $qb->createNamedParameter((int)date('Y'), IQueryBuilder::PARAM_INT);
        if ($op === 'is_empty') {
            return 'NOT EXISTS (SELECT 1 FROM ' . $this->tableName('mv_fee_records')
                . ' fr WHERE fr.member_id = m.id AND fr.year = ' . $yearParam . ')';
        }
        if ($op === 'is_not_empty') {
            return 'EXISTS (SELECT 1 FROM ' . $this->tableName('mv_fee_records')
                . ' fr WHERE fr.member_id = m.id AND fr.year = ' . $yearParam . ')';
        }
        $comparison = $this->scalarComparison($qb, 'fr.amount', $op, $value);
        return 'EXISTS (SELECT 1 FROM ' . $this->tableName('mv_fee_records') . ' fr '
            . 'WHERE fr.member_id = m.id AND fr.year = ' . $yearParam
            . ' AND ' . $comparison . ')';
    }

    private function buildCampAnySubquery(IQueryBuilder $qb, string $op): string {
        $base = 'SELECT 1 FROM ' . $this->tableName('mv_lager_teilnehmer')
            . ' lt WHERE lt.member_id = m.id';
        return match ($op) {
            'is_not_empty', '=' => 'EXISTS (' . $base . ')',
            'is_empty', '!=' => 'NOT EXISTS (' . $base . ')',
            default => throw new ValidationException("Operator $op fuer Lagerteilnahme nicht unterstuetzt"),
        };
    }

    private function buildCampNameSubquery(IQueryBuilder $qb, string $op, $value): string {
        $base = 'SELECT 1 FROM ' . $this->tableName('mv_lager_teilnehmer') . ' lt '
            . 'JOIN ' . $this->tableName('mv_lager') . ' l ON l.id = lt.lager_id '
            . 'WHERE lt.member_id = m.id';
        if ($op === 'is_empty') {
            return 'NOT EXISTS (' . $base . ')';
        }
        if ($op === 'is_not_empty') {
            return 'EXISTS (' . $base . ')';
        }
        $comparison = $this->scalarComparison($qb, 'l.name', $op, $value);
        return 'EXISTS (' . $base . ' AND ' . $comparison . ')';
    }

    private function buildInjuryAnySubquery(IQueryBuilder $qb, string $op): string {
        $base = 'SELECT 1 FROM ' . $this->tableName('mv_injury_involved')
            . ' ii WHERE ii.member_id = m.id';
        return match ($op) {
            'is_not_empty', '=' => 'EXISTS (' . $base . ')',
            'is_empty', '!=' => 'NOT EXISTS (' . $base . ')',
            default => throw new ValidationException("Operator $op fuer Verletzung nicht unterstuetzt"),
        };
    }

    /**
     * Return a scalar SQL fragment `{column} {op} ?` using named parameters.
     */
    private function scalarComparison(IQueryBuilder $qb, string $column, string $op, $value): string {
        if ($op === 'contains') {
            return $column . ' LIKE ' . $qb->createNamedParameter('%' . $this->escapeLike((string)$value) . '%');
        }
        if ($op === 'starts_with') {
            return $column . ' LIKE ' . $qb->createNamedParameter($this->escapeLike((string)$value) . '%');
        }
        $paramType = is_int($value) ? IQueryBuilder::PARAM_INT : IQueryBuilder::PARAM_STR;
        $param = $qb->createNamedParameter($value, $paramType);
        return match ($op) {
            '=' => "$column = $param",
            '!=' => "$column <> $param",
            '<' => "$column < $param",
            '>' => "$column > $param",
            '<=' => "$column <= $param",
            '>=' => "$column >= $param",
            default => throw new ValidationException("Operator $op nicht unterstuetzt"),
        };
    }

    /**
     * Qualify a table name with the Nextcloud oc_ prefix via the platform's
     * {@see IDBConnection::getQueryBuilder()} quoting rules.
     *
     * Nextcloud's IDBConnection is configured with the `oc_` table prefix
     * so references like `mv_members` need the *prefix applied*. We rely on
     * the conventional `oc_` prefix used by setSchemaWrapper. Using the raw
     * name in subqueries works because the QB's `from()` quotes it — but in
     * hand-written EXISTS strings we must use the same naming. Nextcloud's
     * table names in raw SQL use the `oc_` prefix, so we emit that here.
     */
    private function tableName(string $logicalName): string {
        return '*PREFIX*' . $logicalName;
    }

    // ── Validation ──────────────────────────────────────────────────

    /**
     * Validate that an AST node is structurally valid.
     *
     * @throws ValidationException
     */
    private function validateAst(array $node, int $depth = 0): void {
        if ($depth > 10) {
            throw new ValidationException('Abfrage ist zu tief verschachtelt (max 10 Ebenen)');
        }

        if (array_key_exists('not', $node) && !is_bool($node['not'])) {
            throw new ValidationException('Flag "not" muss boolean sein');
        }

        if (isset($node['and'])) {
            if (!is_array($node['and'])) {
                throw new ValidationException('AND-Gruppe muss ein Array sein');
            }
            foreach ($node['and'] as $child) {
                if (!is_array($child)) {
                    throw new ValidationException('AND-Kind muss ein Objekt sein');
                }
                $this->validateAst($child, $depth + 1);
            }
            return;
        }

        if (isset($node['or'])) {
            if (!is_array($node['or'])) {
                throw new ValidationException('OR-Gruppe muss ein Array sein');
            }
            foreach ($node['or'] as $child) {
                if (!is_array($child)) {
                    throw new ValidationException('OR-Kind muss ein Objekt sein');
                }
                $this->validateAst($child, $depth + 1);
            }
            return;
        }

        if (isset($node['field']) && isset($node['op'])) {
            if (!isset(self::FIELD_MAP[$node['field']])) {
                throw new ValidationException("Unbekanntes Feld: {$node['field']}");
            }
            if (!in_array($node['op'], self::ALLOWED_OPERATORS)) {
                throw new ValidationException("Unbekannter Operator: {$node['op']}");
            }
            if (!in_array($node['op'], ['is_empty', 'is_not_empty']) && !array_key_exists('value', $node)) {
                throw new ValidationException("Wert fehlt fuer Feld {$node['field']}");
            }
            return;
        }

        // Pure NOT wrapper without body is invalid
        if (array_key_exists('not', $node) && count($node) === 1) {
            throw new ValidationException('NOT-Knoten ohne Bedingung');
        }

        throw new ValidationException('Ungueltiger AST-Knoten: muss "and", "or" oder "field"+"op" enthalten');
    }

    /**
     * Check if AST references any address fields (requires JOIN).
     */
    private function astReferencesAddress(array $node): bool {
        foreach ($this->walkLeaves($node) as $leaf) {
            if (str_starts_with($leaf['field'], 'adresse_')) {
                return true;
            }
        }
        return false;
    }

    /**
     * Iterate all leaf nodes in the AST.
     *
     * @return iterable<array>
     */
    private function walkLeaves(array $node): iterable {
        if (isset($node['and'])) {
            foreach ($node['and'] as $child) {
                yield from $this->walkLeaves($child);
            }
            return;
        }
        if (isset($node['or'])) {
            foreach ($node['or'] as $child) {
                yield from $this->walkLeaves($child);
            }
            return;
        }
        if (isset($node['field'])) {
            yield $node;
        }
    }

    // ── Allergien filtering ──────────────────────────────────────────

    /**
     * Extract all leaves that query the allergien field with a content operator
     * (i.e. an operator that requires server-side decryption to evaluate).
     *
     * is_empty / is_not_empty do NOT need decryption.
     *
     * @return list<array> Each leaf carries `op`, `value`, and `negated` flag
     */
    private function extractAllergienContentLeaves(array $ast): array {
        $out = [];
        $this->collectAllergienLeaves($ast, false, $out);
        return $out;
    }

    private function collectAllergienLeaves(array $node, bool $negated, array &$out): void {
        $effNeg = $negated !== !empty($node['not']);
        if (isset($node['and'])) {
            foreach ($node['and'] as $child) {
                $this->collectAllergienLeaves($child, $effNeg, $out);
            }
            return;
        }
        if (isset($node['or'])) {
            foreach ($node['or'] as $child) {
                $this->collectAllergienLeaves($child, $effNeg, $out);
            }
            return;
        }
        if (isset($node['field']) && $node['field'] === 'allergien'
            && !in_array($node['op'], ['is_empty', 'is_not_empty'], true)) {
            $out[] = [
                'op' => $node['op'],
                'value' => (string)($node['value'] ?? ''),
                'negated' => $effNeg,
            ];
        }
    }

    /**
     * Rewrite the AST, replacing allergien content-leaves with a guard that
     * evaluates to the SQL-level "allergien is not null" test.
     *
     * The guard keeps the SQL query's shape intact (AND/OR/NOT structure
     * preserved, pagination math roughly proportional) while the real filter
     * happens in PHP against the decrypted plaintext.
     */
    private function replaceAllergienContentWithGuard(array $node): array {
        if (isset($node['and'])) {
            $children = [];
            foreach ($node['and'] as $child) {
                $children[] = $this->replaceAllergienContentWithGuard($child);
            }
            $out = ['and' => $children];
            if (!empty($node['not'])) {
                $out['not'] = true;
            }
            return $out;
        }
        if (isset($node['or'])) {
            $children = [];
            foreach ($node['or'] as $child) {
                $children[] = $this->replaceAllergienContentWithGuard($child);
            }
            $out = ['or' => $children];
            if (!empty($node['not'])) {
                $out['not'] = true;
            }
            return $out;
        }
        if (isset($node['field']) && $node['field'] === 'allergien'
            && !in_array($node['op'], ['is_empty', 'is_not_empty'], true)) {
            return [
                'field' => 'allergien',
                'op' => 'is_not_empty',
                'not' => !empty($node['not']),
            ];
        }
        return $node;
    }

    /**
     * Test whether the decrypted allergien plaintext matches every allergien
     * leaf collected from the AST.
     *
     * @param list<array{op:string,value:string,negated:bool}> $leaves
     */
    private function matchesAllergienLeaves(?string $plaintext, array $leaves): bool {
        $haystack = $plaintext ?? '';
        foreach ($leaves as $leaf) {
            $ok = $this->evaluateAllergienLeaf($haystack, $leaf['op'], $leaf['value']);
            if ($leaf['negated']) {
                $ok = !$ok;
            }
            if (!$ok) {
                return false;
            }
        }
        return true;
    }

    private function evaluateAllergienLeaf(string $haystack, string $op, string $needle): bool {
        return match ($op) {
            '=' => $haystack === $needle,
            '!=' => $haystack !== $needle,
            'contains' => $needle === '' ? true : str_contains(mb_strtolower($haystack), mb_strtolower($needle)),
            'starts_with' => $needle === '' ? true : str_starts_with(mb_strtolower($haystack), mb_strtolower($needle)),
            default => false,
        };
    }

    private function decryptAllergien(?string $ciphertext): ?string {
        if ($ciphertext === null || $ciphertext === '') {
            return null;
        }
        if ($this->encryptionService === null) {
            return null;
        }
        return $this->encryptionService->decrypt($ciphertext);
    }

    /**
     * Audit-log every allergien query leaf, recording field + operator + user
     * but never the value.
     *
     * @param list<array{op:string,value:string,negated:bool}> $leaves
     */
    private function auditAllergienQueries(array $leaves): void {
        if (empty($leaves)) {
            return;
        }
        $userId = null;
        if ($this->userSession !== null) {
            $user = $this->userSession->getUser();
            $userId = $user !== null ? $user->getUID() : null;
        }
        foreach ($leaves as $leaf) {
            $this->logger->info('Abfrage auf verschluesseltem Feld allergien', [
                'app' => 'mitgliederverwaltung',
                'field' => 'allergien',
                'op' => $leaf['op'],
                'negated' => $leaf['negated'],
                'user' => $userId,
            ]);
        }
    }

    // ── Helpers ──────────────────────────────────────────────────────

    /**
     * Whether a FIELD_MAP value is a plain column reference (safe for ORDER BY).
     */
    private function isPlainColumn(string $column): bool {
        return !str_starts_with($column, self::COMPUTED_PREFIX)
            && !str_starts_with($column, self::RELATION_PREFIX)
            && !str_starts_with($column, self::DECRYPT_PREFIX);
    }

    /**
     * Build a usort()-compatible comparator for rows given a sort spec.
     *
     * Only plain columns are honoured — same rule as SQL sorting.
     *
     * @return \Closure(array,array):int
     */
    private function buildRowComparator(?array $sort): \Closure {
        $fields = [];
        if ($sort !== null) {
            foreach ($sort as $item) {
                $field = $item['field'] ?? null;
                $dir = strtoupper($item['dir'] ?? 'ASC');
                if ($field !== null && isset(self::FIELD_MAP[$field])
                    && $this->isPlainColumn(self::FIELD_MAP[$field])
                    && in_array($dir, ['ASC', 'DESC'])) {
                    $fields[] = [$this->columnToRowKey(self::FIELD_MAP[$field]), $dir];
                }
            }
        }
        if (empty($fields)) {
            $fields = [['nachname', 'ASC'], ['vorname', 'ASC']];
        }

        return function (array $a, array $b) use ($fields): int {
            foreach ($fields as [$key, $dir]) {
                $va = $a[$key] ?? null;
                $vb = $b[$key] ?? null;
                if ($va == $vb) continue;
                $cmp = ($va <=> $vb);
                return $dir === 'DESC' ? -$cmp : $cmp;
            }
            return 0;
        };
    }

    /**
     * Map a table.column reference back to the row key we fetch under.
     */
    private function columnToRowKey(string $column): string {
        if (str_starts_with($column, 'm.')) {
            return substr($column, 2);
        }
        return $column;
    }

    /** Escape special characters for LIKE queries. */
    private function escapeLike(string $value): string {
        return str_replace(['%', '_'], ['\\%', '\\_'], $value);
    }

    /** Format a raw DB row into a clean member response. */
    private function formatRow(array $row): array {
        return [
            'id' => (int)$row['id'],
            'vorname' => $row['vorname'],
            'nachname' => $row['nachname'],
            'geburtsdatum' => $row['geburtsdatum'] ?? null,
            'geschlecht' => $row['geschlecht'] ?? null,
            'rolle' => $row['rolle'] ?? null,
            'stufeId' => isset($row['stufe_id']) && $row['stufe_id'] !== null ? (int)$row['stufe_id'] : null,
            'eintritt' => $row['eintritt'] ?? null,
            'austritt' => $row['austritt'] ?? null,
            'status' => $row['status'] ?? null,
        ];
    }
}
