<?php

declare(strict_types=1);

namespace OCA\Mitgliederverwaltung\Tests\Unit;

use OCA\Mitgliederverwaltung\Service\EncryptionService;
use OCA\Mitgliederverwaltung\Service\QueryService;
use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

/**
 * Unit tests for QueryService AST validation and field handling.
 *
 * Part of Issue #53.
 */
class QueryServiceTest extends TestCase {

    private QueryService $service;
    private IDBConnection|MockObject $db;
    private LoggerInterface|MockObject $logger;

    protected function setUp(): void {
        $this->db = $this->createMock(IDBConnection::class);
        $this->logger = $this->createMock(LoggerInterface::class);

        $this->service = new QueryService($this->db, $this->logger);
    }

    // ── Available fields ────────────────────────────────────────────

    public function testGetAvailableFieldsReturnsAllFields(): void {
        $fields = $this->service->getAvailableFields();

        $this->assertIsArray($fields);
        $this->assertNotEmpty($fields);

        $keys = array_column($fields, 'key');
        $this->assertContains('vorname', $keys);
        $this->assertContains('nachname', $keys);
        $this->assertContains('alter', $keys);
        $this->assertContains('status', $keys);
        $this->assertContains('adresse_plz', $keys);
        $this->assertContains('mitgliedsdauer', $keys);
    }

    public function testGetAvailableFieldsHaveLabelsAndTypes(): void {
        $fields = $this->service->getAvailableFields();

        foreach ($fields as $field) {
            $this->assertArrayHasKey('key', $field);
            $this->assertArrayHasKey('label', $field);
            $this->assertArrayHasKey('type', $field);
            $this->assertContains($field['type'], ['string', 'number', 'date']);
        }
    }

    // ── AST validation (via reflection to test private method) ──────

    public function testValidateAstRejectsUnknownField(): void {
        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('Unbekanntes Feld');

        // Execute with unknown field should throw
        $ast = ['and' => [['field' => 'nonexistent_field', 'op' => '=', 'value' => 'test']]];
        $this->invokeValidateAst($ast);
    }

    public function testValidateAstRejectsUnknownOperator(): void {
        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('Unbekannter Operator');

        $ast = ['and' => [['field' => 'vorname', 'op' => 'LIKE_INJECTION', 'value' => 'test']]];
        $this->invokeValidateAst($ast);
    }

    public function testValidateAstRejectsTooDeepNesting(): void {
        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('zu tief verschachtelt');

        // Build deeply nested AST (12 levels)
        $ast = ['field' => 'vorname', 'op' => '=', 'value' => 'test'];
        for ($i = 0; $i < 12; $i++) {
            $ast = ['and' => [$ast]];
        }
        $this->invokeValidateAst($ast);
    }

    public function testValidateAstAcceptsValidSimpleQuery(): void {
        $ast = [
            'and' => [
                ['field' => 'vorname', 'op' => '=', 'value' => 'Max'],
                ['field' => 'status', 'op' => '=', 'value' => 'aktiv'],
            ],
        ];

        // Should not throw
        $this->invokeValidateAst($ast);
        $this->assertTrue(true); // If we get here, validation passed
    }

    public function testValidateAstAcceptsNestedAndOr(): void {
        $ast = [
            'or' => [
                [
                    'and' => [
                        ['field' => 'alter', 'op' => '<', 'value' => 14],
                        ['field' => 'status', 'op' => '=', 'value' => 'aktiv'],
                    ],
                ],
                ['field' => 'rolle', 'op' => '=', 'value' => 'leiter'],
            ],
        ];

        $this->invokeValidateAst($ast);
        $this->assertTrue(true);
    }

    public function testValidateAstAcceptsIsEmptyWithoutValue(): void {
        $ast = ['and' => [['field' => 'austritt', 'op' => 'is_empty']]];
        $this->invokeValidateAst($ast);
        $this->assertTrue(true);
    }

    public function testValidateAstRejectsMissingValueForEqualOp(): void {
        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('Wert fehlt');

        $ast = ['and' => [['field' => 'vorname', 'op' => '=']]];
        $this->invokeValidateAst($ast);
    }

    public function testValidateAstRejectsInvalidNode(): void {
        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('Ungueltiger AST-Knoten');

        $ast = ['invalid_key' => 'something'];
        $this->invokeValidateAst($ast);
    }

    public function testValidateAstRejectsNonArrayAndGroup(): void {
        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('AND-Gruppe muss ein Array sein');

        $ast = ['and' => 'not_an_array'];
        $this->invokeValidateAst($ast);
    }

    public function testValidateAstRejectsNonArrayOrGroup(): void {
        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('OR-Gruppe muss ein Array sein');

        $ast = ['or' => 'not_an_array'];
        $this->invokeValidateAst($ast);
    }

    public function testValidateAstAcceptsIsNotEmptyWithoutValue(): void {
        $ast = ['and' => [['field' => 'austritt', 'op' => 'is_not_empty']]];
        $this->invokeValidateAst($ast);
        $this->assertTrue(true);
    }

    public function testValidateAstAcceptsContainsOp(): void {
        $ast = ['and' => [['field' => 'vorname', 'op' => 'contains', 'value' => 'Ma']]];
        $this->invokeValidateAst($ast);
        $this->assertTrue(true);
    }

    public function testValidateAstAcceptsStartsWithOp(): void {
        $ast = ['and' => [['field' => 'nachname', 'op' => 'starts_with', 'value' => 'M']]];
        $this->invokeValidateAst($ast);
        $this->assertTrue(true);
    }

    public function testValidateAstAcceptsAllComparisonOps(): void {
        $ops = ['=', '!=', '<', '>', '<=', '>='];
        foreach ($ops as $op) {
            $ast = ['and' => [['field' => 'vorname', 'op' => $op, 'value' => 'test']]];
            $this->invokeValidateAst($ast);
        }
        $this->assertTrue(true);
    }

    // ── execute() with mocked query builder ───────────────────────

    public function testExecuteWithSimpleAst(): void {
        $qb = $this->createFullQueryBuilderMock([
            ['id' => 1, 'vorname' => 'Max', 'nachname' => 'Mueller', 'geburtsdatum' => '2010-01-01',
             'geschlecht' => 'm', 'rolle' => 'mitglied', 'stufe_id' => null,
             'eintritt' => '2020-01-01', 'austritt' => null, 'status' => 'aktiv'],
        ]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
        $this->assertArrayHasKey('total', $result);
        $this->assertEquals(1, $result['total']);
        $this->assertCount(1, $result['data']);
        $this->assertEquals('Max', $result['data'][0]['vorname']);
    }

    public function testExecuteWithSorting(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
        $sort = [['field' => 'nachname', 'dir' => 'desc']];
        $result = $this->service->execute($ast, $sort);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithDefaultSorting(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
        $result = $this->service->execute($ast, null);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithComputedFieldSortingIsSkipped(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
        // Sort by computed field 'alter' should be skipped for ORDER BY
        $sort = [['field' => 'alter', 'dir' => 'asc']];
        $result = $this->service->execute($ast, $sort);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithAddressField(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $qb->method('leftJoin')->willReturnSelf();

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'adresse_plz', 'op' => '=', 'value' => '12345']]];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithOrGroup(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['or' => [
            ['field' => 'vorname', 'op' => '=', 'value' => 'Max'],
            ['field' => 'vorname', 'op' => '=', 'value' => 'Anna'],
        ]];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithContainsOperator(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'vorname', 'op' => 'contains', 'value' => 'Ma']]];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithStartsWithOperator(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'nachname', 'op' => 'starts_with', 'value' => 'M']]];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithIsEmptyOperator(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'austritt', 'op' => 'is_empty']]];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithIsNotEmptyOperator(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'austritt', 'op' => 'is_not_empty']]];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithAgeComparison(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'alter', 'op' => '<', 'value' => 14]]];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithMembershipDuration(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'mitgliedsdauer', 'op' => '>=', 'value' => 5]]];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithAllComparisonOperators(): void {
        $ops = ['=', '!=', '<', '>', '<=', '>='];
        foreach ($ops as $op) {
            $qb = $this->createFullQueryBuilderMock([]);
            $this->db->method('getQueryBuilder')->willReturn($qb);

            $ast = ['and' => [['field' => 'stufe_id', 'op' => $op, 'value' => 1]]];
            $result = $this->service->execute($ast);

            $this->assertArrayHasKey('data', $result);

            // Re-create service for fresh db mock state
            $this->db = $this->createMock(IDBConnection::class);
            $this->service = new QueryService($this->db, $this->logger);
        }
    }

    public function testExecuteWithAllAgeOps(): void {
        $ops = ['=', '!=', '<', '>', '<=', '>='];
        foreach ($ops as $op) {
            $qb = $this->createFullQueryBuilderMock([]);
            $this->db->method('getQueryBuilder')->willReturn($qb);

            $ast = ['and' => [['field' => 'alter', 'op' => $op, 'value' => 10]]];
            $result = $this->service->execute($ast);

            $this->assertArrayHasKey('data', $result);

            $this->db = $this->createMock(IDBConnection::class);
            $this->service = new QueryService($this->db, $this->logger);
        }
    }

    public function testExecuteWithEmptyAndGroup(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => []];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithEmptyOrGroup(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['or' => []];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithPagination(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
        $result = $this->service->execute($ast, null, 10, 5);

        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithInvalidFieldThrows(): void {
        $this->expectException(ValidationException::class);

        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'nonexistent', 'op' => '=', 'value' => 'x']]];
        $this->service->execute($ast);
    }

    public function testExecuteWithNestedGroups(): void {
        $qb = $this->createFullQueryBuilderMock([]);

        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [
            ['or' => [
                ['field' => 'vorname', 'op' => '=', 'value' => 'Max'],
                ['field' => 'vorname', 'op' => '=', 'value' => 'Anna'],
            ]],
            ['field' => 'status', 'op' => '=', 'value' => 'aktiv'],
        ]];
        $result = $this->service->execute($ast);

        $this->assertArrayHasKey('data', $result);
    }

    // ── astReferencesAddress via reflection ────────────────────────

    public function testAstReferencesAddressDetectsAddressField(): void {
        $method = new \ReflectionMethod(QueryService::class, 'astReferencesAddress');
        $method->setAccessible(true);

        $ast = ['and' => [['field' => 'adresse_plz', 'op' => '=', 'value' => '12345']]];
        $this->assertTrue($method->invoke($this->service, $ast));
    }

    public function testAstReferencesAddressReturnsFalseForNonAddress(): void {
        $method = new \ReflectionMethod(QueryService::class, 'astReferencesAddress');
        $method->setAccessible(true);

        $ast = ['and' => [['field' => 'vorname', 'op' => '=', 'value' => 'Max']]];
        $this->assertFalse($method->invoke($this->service, $ast));
    }

    public function testAstReferencesAddressInOrGroup(): void {
        $method = new \ReflectionMethod(QueryService::class, 'astReferencesAddress');
        $method->setAccessible(true);

        $ast = ['or' => [['field' => 'adresse_ort', 'op' => '=', 'value' => 'Berlin']]];
        $this->assertTrue($method->invoke($this->service, $ast));
    }

    // ── NOT operator ─────────────────────────────────────────────────

    public function testValidateAstAcceptsNotOnLeaf(): void {
        $ast = ['and' => [['not' => true, 'field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
        $this->invokeValidateAst($ast);
        $this->assertTrue(true);
    }

    public function testValidateAstAcceptsNotOnGroup(): void {
        $ast = [
            'and' => [
                ['not' => true, 'or' => [
                    ['field' => 'juleica_nummer', 'op' => 'is_not_empty'],
                ]],
            ],
        ];
        $this->invokeValidateAst($ast);
        $this->assertTrue(true);
    }

    public function testValidateAstRejectsNonBooleanNotFlag(): void {
        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('Flag "not"');

        $ast = ['and' => [['not' => 'yes', 'field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
        $this->invokeValidateAst($ast);
    }

    public function testExecuteWithNegatedLeaf(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['not' => true, 'field' => 'status', 'op' => '=', 'value' => 'aktiv']]];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithNegatedGroup(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = [
            'not' => true,
            'and' => [
                ['field' => 'vorname', 'op' => '=', 'value' => 'Max'],
                ['field' => 'status', 'op' => '=', 'value' => 'aktiv'],
            ],
        ];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithDoubleNegationNested(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = [
            'not' => true,
            'and' => [
                ['not' => true, 'field' => 'status', 'op' => '=', 'value' => 'aktiv'],
            ],
        ];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testBackwardCompatibleAstWithoutNotStillExecutes(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        // Legacy saved query (no `not` keys anywhere)
        $ast = [
            'and' => [
                ['field' => 'vorname', 'op' => 'contains', 'value' => 'M'],
                ['or' => [
                    ['field' => 'status', 'op' => '=', 'value' => 'aktiv'],
                    ['field' => 'rolle', 'op' => '=', 'value' => 'leiter'],
                ]],
            ],
        ];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    // ── New fields ───────────────────────────────────────────────────

    public function testGetAvailableFieldsIncludesNewMemberFields(): void {
        $keys = array_column($this->service->getAvailableFields(), 'key');
        $this->assertContains('notizen', $keys);
        $this->assertContains('zusatz_notizen', $keys);
        $this->assertContains('einwilligung_datum', $keys);
        $this->assertContains('juleica_nummer', $keys);
        $this->assertContains('juleica_ablaufdatum', $keys);
    }

    public function testGetAvailableFieldsIncludesAllergienFlaggedEncrypted(): void {
        $fields = $this->service->getAvailableFields();
        $allergien = null;
        foreach ($fields as $f) {
            if ($f['key'] === 'allergien') {
                $allergien = $f;
                break;
            }
        }
        $this->assertNotNull($allergien);
        $this->assertTrue($allergien['encrypted'] ?? false);
    }

    public function testGetAvailableFieldsIncludesRelatedEntityFields(): void {
        $keys = array_column($this->service->getAvailableFields(), 'key');
        $this->assertContains('telefon', $keys);
        $this->assertContains('email', $keys);
        $this->assertContains('familie_name', $keys);
        $this->assertContains('beitrag_bezahlt_jahr', $keys);
        $this->assertContains('beitrag_offen_jahr', $keys);
        $this->assertContains('lager_teilnahme', $keys);
        $this->assertContains('lager_name', $keys);
        $this->assertContains('verletzung_vorhanden', $keys);
    }

    public function testGetAvailableFieldsHaveGroupProperty(): void {
        foreach ($this->service->getAvailableFields() as $field) {
            $this->assertArrayHasKey('group', $field);
            $this->assertNotEmpty($field['group']);
        }
    }

    public function testExecuteWithTelefonField(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'telefon', 'op' => 'contains', 'value' => '+49']]];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithEmailField(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'email', 'op' => 'contains', 'value' => '@']]];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithFamilyNameField(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'familie_name', 'op' => '=', 'value' => 'Müller']]];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithBeitragPaidYearField(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'beitrag_bezahlt_jahr', 'op' => '=', 'value' => 2026]]];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithBeitragUnpaidYearField(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'beitrag_offen_jahr', 'op' => '=', 'value' => 2026]]];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithLagerTeilnahmeExistsOp(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'lager_teilnahme', 'op' => 'is_not_empty']]];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithLagerNameField(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'lager_name', 'op' => 'contains', 'value' => 'Sommer']]];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithInjuryAnyField(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'verletzung_vorhanden', 'op' => 'is_not_empty']]];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithMemberNotizenField(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'notizen', 'op' => 'contains', 'value' => 'wichtig']]];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteWithJuleicaAblaufdatumField(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $ast = ['and' => [['field' => 'juleica_ablaufdatum', 'op' => '<', 'value' => '2027-01-01']]];
        $result = $this->service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    // ── Allergien: encrypted field handling ─────────────────────────

    public function testExecuteAllergienIsEmptyUsesSqlNoDecrypt(): void {
        $qb = $this->createFullQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $encryption = $this->createMock(EncryptionService::class);
        $encryption->expects($this->never())->method('decrypt');

        $service = new QueryService($this->db, $this->logger, $encryption, null);
        $ast = ['and' => [['field' => 'allergien', 'op' => 'is_empty']]];
        $result = $service->execute($ast);
        $this->assertArrayHasKey('data', $result);
    }

    public function testExecuteAllergienContainsDecryptsAndFilters(): void {
        $rows = [
            ['id' => 1, 'vorname' => 'Max', 'nachname' => 'Mueller', 'geburtsdatum' => '2010-01-01',
             'geschlecht' => 'm', 'rolle' => 'mitglied', 'stufe_id' => null,
             'eintritt' => '2020-01-01', 'austritt' => null, 'status' => 'aktiv',
             'allergien_encrypted' => 'CT_nuss'],
            ['id' => 2, 'vorname' => 'Anna', 'nachname' => 'Schmidt', 'geburtsdatum' => '2012-05-05',
             'geschlecht' => 'w', 'rolle' => 'mitglied', 'stufe_id' => null,
             'eintritt' => '2021-01-01', 'austritt' => null, 'status' => 'aktiv',
             'allergien_encrypted' => 'CT_laktose'],
        ];
        $qb = $this->createAllergienQueryBuilderMock($rows);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $encryption = $this->createMock(EncryptionService::class);
        $encryption->method('decrypt')->willReturnCallback(fn($ct) => match ($ct) {
            'CT_nuss' => 'Nussallergie',
            'CT_laktose' => 'Laktoseintoleranz',
            default => null,
        });

        $service = new QueryService($this->db, $this->logger, $encryption, null);
        $ast = ['and' => [['field' => 'allergien', 'op' => 'contains', 'value' => 'Nuss']]];
        $result = $service->execute($ast);

        $this->assertEquals(1, $result['total']);
        $this->assertEquals('Max', $result['data'][0]['vorname']);
    }

    public function testExecuteAllergienContentOpWithoutEncryptionServiceThrows(): void {
        $this->expectException(ValidationException::class);

        $qb = $this->createAllergienQueryBuilderMock([]);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        // No encryption service
        $ast = ['and' => [['field' => 'allergien', 'op' => 'contains', 'value' => 'Nuss']]];
        $this->service->execute($ast);
    }

    public function testExecuteAllergienQueryAuditsWithoutValue(): void {
        $rows = [];
        $qb = $this->createAllergienQueryBuilderMock($rows);
        $this->db->method('getQueryBuilder')->willReturn($qb);

        $encryption = $this->createMock(EncryptionService::class);

        $user = $this->createMock(IUser::class);
        $user->method('getUID')->willReturn('alice');
        $userSession = $this->createMock(IUserSession::class);
        $userSession->method('getUser')->willReturn($user);

        $this->logger->expects($this->atLeastOnce())
            ->method('info')
            ->with(
                $this->stringContains('allergien'),
                $this->callback(function ($ctx) {
                    // Must NOT contain the value
                    $flat = json_encode($ctx);
                    return !str_contains($flat, 'Nussallergie')
                        && !str_contains($flat, 'Nuss');
                })
            );

        $service = new QueryService($this->db, $this->logger, $encryption, $userSession);
        $ast = ['and' => [['field' => 'allergien', 'op' => 'contains', 'value' => 'Nuss']]];
        $service->execute($ast);
    }

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

    /**
     * Invoke the private validateAst method via reflection.
     */
    private function invokeValidateAst(array $ast): void {
        $method = new \ReflectionMethod(QueryService::class, 'validateAst');
        $method->setAccessible(true);
        $method->invoke($this->service, $ast, 0);
    }

    /**
     * Create a fully configured query builder mock for execute() tests.
     */
    private function createFullQueryBuilderMock(array $rows): MockObject {
        $qb = $this->createMock(\OCP\DB\QueryBuilder\IQueryBuilder::class);
        $expr = $this->createMock(\OCP\DB\QueryBuilder\IExpressionBuilder::class);

        $expr->method('isNull')->willReturn('expr_isNull');
        $expr->method('isNotNull')->willReturn('expr_isNotNull');
        $expr->method('eq')->willReturn('expr_eq');
        $expr->method('neq')->willReturn('expr_neq');
        $expr->method('lt')->willReturn('expr_lt');
        $expr->method('gt')->willReturn('expr_gt');
        $expr->method('lte')->willReturn('expr_lte');
        $expr->method('gte')->willReturn('expr_gte');
        $expr->method('like')->willReturn('expr_like');
        // Use anonymous class implementing ICompositeExpression + Stringable
        $compositeExpr = new class implements \OCP\DB\QueryBuilder\ICompositeExpression, \Stringable {
            public function addMultiple(array $parts = []): \OCP\DB\QueryBuilder\ICompositeExpression { return $this; }
            public function add($part): \OCP\DB\QueryBuilder\ICompositeExpression { return $this; }
            public function count(): int { return 1; }
            public function getType(): string { return 'AND'; }
            public function __toString(): string { return '(composite_expr)'; }
        };
        $expr->method('andX')->willReturn($compositeExpr);
        $expr->method('orX')->willReturn($compositeExpr);

        $qb->method('expr')->willReturn($expr);
        $qb->method('select')->willReturnSelf();
        $qb->method('from')->willReturnSelf();
        $qb->method('where')->willReturnSelf();
        $qb->method('andWhere')->willReturnSelf();
        $qb->method('leftJoin')->willReturnSelf();
        $qb->method('orderBy')->willReturnSelf();
        $qb->method('addOrderBy')->willReturnSelf();
        $qb->method('setFirstResult')->willReturnSelf();
        $qb->method('setMaxResults')->willReturnSelf();
        $qb->method('groupBy')->willReturnSelf();
        $qb->method('createNamedParameter')->willReturn('?');
        $qb->method('createFunction')->willReturn('COUNT(DISTINCT m.id) as cnt');

        // Count query result
        $countResult = $this->createMock(\OCP\DB\IResult::class);
        $countResult->method('fetch')->willReturn(['cnt' => count($rows)]);
        $countResult->method('closeCursor')->willReturn(true);

        // Data query result
        $dataResult = $this->createMock(\OCP\DB\IResult::class);
        $callCount = 0;
        $dataResult->method('fetch')->willReturnCallback(function () use (&$callCount, $rows) {
            if ($callCount < count($rows)) {
                return $rows[$callCount++];
            }
            return false;
        });
        $dataResult->method('closeCursor')->willReturn(true);

        // First executeQuery is for count, second for data
        $queryCount = 0;
        $qb->method('executeQuery')->willReturnCallback(function () use (&$queryCount, $countResult, $dataResult) {
            return ($queryCount++ === 0) ? $countResult : $dataResult;
        });

        return $qb;
    }

    /**
     * Create a query-builder mock for the allergien path (no separate count query).
     *
     * Rows should include an `allergien_encrypted` key; the service decrypts
     * and filters them in PHP.
     */
    private function createAllergienQueryBuilderMock(array $rows): MockObject {
        $qb = $this->createMock(\OCP\DB\QueryBuilder\IQueryBuilder::class);
        $expr = $this->createMock(\OCP\DB\QueryBuilder\IExpressionBuilder::class);

        $expr->method('isNull')->willReturn('expr_isNull');
        $expr->method('isNotNull')->willReturn('expr_isNotNull');
        $expr->method('eq')->willReturn('expr_eq');
        $expr->method('neq')->willReturn('expr_neq');
        $expr->method('lt')->willReturn('expr_lt');
        $expr->method('gt')->willReturn('expr_gt');
        $expr->method('lte')->willReturn('expr_lte');
        $expr->method('gte')->willReturn('expr_gte');
        $expr->method('like')->willReturn('expr_like');

        $compositeExpr = new class implements \OCP\DB\QueryBuilder\ICompositeExpression, \Stringable {
            public function addMultiple(array $parts = []): \OCP\DB\QueryBuilder\ICompositeExpression { return $this; }
            public function add($part): \OCP\DB\QueryBuilder\ICompositeExpression { return $this; }
            public function count(): int { return 1; }
            public function getType(): string { return 'AND'; }
            public function __toString(): string { return '(composite_expr)'; }
        };
        $expr->method('andX')->willReturn($compositeExpr);
        $expr->method('orX')->willReturn($compositeExpr);

        $qb->method('expr')->willReturn($expr);
        $qb->method('select')->willReturnSelf();
        $qb->method('from')->willReturnSelf();
        $qb->method('where')->willReturnSelf();
        $qb->method('andWhere')->willReturnSelf();
        $qb->method('leftJoin')->willReturnSelf();
        $qb->method('orderBy')->willReturnSelf();
        $qb->method('addOrderBy')->willReturnSelf();
        $qb->method('setFirstResult')->willReturnSelf();
        $qb->method('setMaxResults')->willReturnSelf();
        $qb->method('groupBy')->willReturnSelf();
        $qb->method('createNamedParameter')->willReturn('?');
        $qb->method('createFunction')->willReturn('NOT (expr)');

        $dataResult = $this->createMock(\OCP\DB\IResult::class);
        $callCount = 0;
        $dataResult->method('fetch')->willReturnCallback(function () use (&$callCount, $rows) {
            if ($callCount < count($rows)) {
                return $rows[$callCount++];
            }
            return false;
        });
        $dataResult->method('closeCursor')->willReturn(true);

        $qb->method('executeQuery')->willReturn($dataResult);

        return $qb;
    }
}
