<?php

declare(strict_types=1);

namespace OCA\Mitgliederverwaltung\Tests\Unit;

use OCA\Mitgliederverwaltung\Db\Address;
use OCA\Mitgliederverwaltung\Db\AddressMapper;
use OCA\Mitgliederverwaltung\Db\Email;
use OCA\Mitgliederverwaltung\Db\EmailMapper;
use OCA\Mitgliederverwaltung\Db\FamilyMapper;
use OCA\Mitgliederverwaltung\Db\Member;
use OCA\Mitgliederverwaltung\Db\MemberMapper;
use OCA\Mitgliederverwaltung\Db\Phone;
use OCA\Mitgliederverwaltung\Db\PhoneMapper;
use OCA\Mitgliederverwaltung\Db\StufeHistoryMapper;
use OCA\Mitgliederverwaltung\Service\AuditService;
use OCA\Mitgliederverwaltung\Service\DuplicateMemberException;
use OCA\Mitgliederverwaltung\Service\EncryptionService;
use OCA\Mitgliederverwaltung\Service\MemberService;
use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

class MemberServiceTest extends TestCase {

    private MemberService $service;
    private MemberMapper&MockObject $memberMapper;
    private MockObject $addressMapper;
    private MockObject $phoneMapper;
    private MockObject $emailMapper;
    private FamilyMapper&MockObject $familyMapper;
    private StufeHistoryMapper&MockObject $stufeHistoryMapper;
    private AuditService&MockObject $auditService;
    private LoggerInterface&MockObject $logger;
    private IDBConnection&MockObject $db;

    protected function setUp(): void {
        parent::setUp();

        $this->memberMapper = $this->createMock(MemberMapper::class);
        $this->addressMapper = $this->getMockBuilder(AddressMapper::class)
            ->disableOriginalConstructor()
            ->onlyMethods(['findAll', 'findByMemberId', 'deleteByMemberId', 'insert', 'update', 'delete'])
            ->addMethods(['find'])
            ->getMock();
        $this->phoneMapper = $this->getMockBuilder(PhoneMapper::class)
            ->disableOriginalConstructor()
            ->onlyMethods(['findAll', 'findByMemberId', 'deleteByMemberId', 'insert', 'update', 'delete'])
            ->addMethods(['find'])
            ->getMock();
        $this->emailMapper = $this->getMockBuilder(EmailMapper::class)
            ->disableOriginalConstructor()
            ->onlyMethods(['findAll', 'findByMemberId', 'deleteByMemberId', 'insert', 'update', 'delete'])
            ->addMethods(['find'])
            ->getMock();
        $this->familyMapper = $this->createMock(FamilyMapper::class);
        $this->stufeHistoryMapper = $this->createMock(StufeHistoryMapper::class);
        $this->auditService = $this->createMock(AuditService::class);
        $this->logger = $this->createMock(LoggerInterface::class);
        $this->db = $this->createMock(IDBConnection::class);

        $this->service = new MemberService(
            $this->memberMapper,
            $this->addressMapper,
            $this->phoneMapper,
            $this->emailMapper,
            $this->familyMapper,
            $this->stufeHistoryMapper,
            $this->auditService,
            $this->logger,
            null,
            $this->db
        );
    }

    // ── Helper factories ────────────────────────────────────────────

    private function createMember(
        int $id = 1,
        string $vorname = 'Max',
        string $nachname = 'Mustermann',
        string $geburtsdatum = '2010-01-15',
        string $eintritt = '2020-01-01',
        string $status = 'aktiv',
        string $rolle = 'mitglied',
        ?int $familyId = null
    ): Member {
        $member = new Member();
        $member->setId($id);
        $member->setVorname($vorname);
        $member->setNachname($nachname);
        $member->setGeburtsdatum($geburtsdatum);
        $member->setEintritt($eintritt);
        $member->setStatus($status);
        $member->setRolle($rolle);
        $member->setFamilyId($familyId);
        $member->setCreatedAt('2020-01-01 00:00:00');
        $member->setUpdatedAt('2020-01-01 00:00:00');
        return $member;
    }

    private function createAddress(int $id = 1, int $memberId = 1): Address {
        $address = new Address();
        $address->setId($id);
        $address->setMemberId($memberId);
        $address->setStrasse('Musterstrasse 1');
        $address->setPlz('12345');
        $address->setOrt('Musterstadt');
        $address->setLand('Deutschland');
        $address->setIsPrimary(true);
        return $address;
    }

    private function createPhone(int $id = 1, int $memberId = 1): Phone {
        $phone = new Phone();
        $phone->setId($id);
        $phone->setMemberId($memberId);
        $phone->setNumberE164('+4917612345678');
        $phone->setLabel('Mobil');
        return $phone;
    }

    private function createEmail(int $id = 1, int $memberId = 1): Email {
        $email = new Email();
        $email->setId($id);
        $email->setMemberId($memberId);
        $email->setEmail('max@example.com');
        $email->setLabel('Privat');
        return $email;
    }

    private function getValidMemberData(): array {
        return [
            'vorname' => 'Max',
            'nachname' => 'Mustermann',
            'geburtsdatum' => '2010-01-15',
            'eintritt' => '2020-01-01',
        ];
    }

    private function mockSubEntitiesForMember(int $memberId): void {
        $this->addressMapper->method('findByMemberId')
            ->with($memberId)
            ->willReturn([]);
        $this->phoneMapper->method('findByMemberId')
            ->with($memberId)
            ->willReturn([]);
        $this->emailMapper->method('findByMemberId')
            ->with($memberId)
            ->willReturn([]);
    }

    // ── find() ──────────────────────────────────────────────────────

    public function testFindReturnsMemberWithSubEntities(): void {
        $member = $this->createMember(1);
        $address = $this->createAddress(1, 1);
        $phone = $this->createPhone(1, 1);
        $email = $this->createEmail(1, 1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->addressMapper->method('findByMemberId')->with(1)->willReturn([$address]);
        $this->phoneMapper->method('findByMemberId')->with(1)->willReturn([$phone]);
        $this->emailMapper->method('findByMemberId')->with(1)->willReturn([$email]);

        $result = $this->service->find(1);

        $this->assertSame('Max', $result['vorname']);
        $this->assertSame('Mustermann', $result['nachname']);
        $this->assertCount(1, $result['addresses']);
        $this->assertCount(1, $result['phones']);
        $this->assertCount(1, $result['emails']);
    }

    public function testFindThrowsDoesNotExistException(): void {
        $this->memberMapper->method('findById')
            ->willThrowException(new DoesNotExistException('Not found'));

        $this->expectException(DoesNotExistException::class);

        $this->service->find(999);
    }

    // ── findAll() ───────────────────────────────────────────────────

    public function testFindAllReturnsList(): void {
        $member1 = $this->createMember(1, 'Max');
        $member2 = $this->createMember(2, 'Anna', 'Schmidt');

        $this->memberMapper->method('findAllWithRelations')->willReturn([
            $member1->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
            $member2->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
        ]);

        $result = $this->service->findAll();

        $this->assertCount(2, $result);
        $this->assertSame('Max', $result[0]['vorname']);
        $this->assertSame('Anna', $result[1]['vorname']);
    }

    public function testFindAllWithPagination(): void {
        $this->memberMapper->expects($this->once())
            ->method('findAllWithRelations')
            ->with(10, 5)
            ->willReturn([]);

        $this->service->findAll(10, 5);
    }

    public function testFindAllReturnsEmptyArray(): void {
        $this->memberMapper->method('findAllWithRelations')->willReturn([]);

        $result = $this->service->findAll();
        $this->assertSame([], $result);
    }

    // ── create() ────────────────────────────────────────────────────

    public function testCreateValidMember(): void {
        $data = $this->getValidMemberData();

        $this->memberMapper->method('search')->willReturn([]);
        $this->memberMapper->method('insert')
            ->willReturnCallback(function (Member $m) {
                $m->setId(1);
                return $m;
            });

        $this->auditService->expects($this->once())->method('logCreate');

        $result = $this->service->create($data);

        $this->assertSame('Max', $result['vorname']);
        $this->assertSame('Mustermann', $result['nachname']);
    }

    public function testCreateTrimsWhitespace(): void {
        $data = $this->getValidMemberData();
        $data['vorname'] = '  Max  ';
        $data['nachname'] = '  Mustermann  ';

        $this->memberMapper->method('search')->willReturn([]);
        $this->memberMapper->method('insert')
            ->willReturnCallback(function (Member $m) {
                $this->assertSame('Max', $m->getVorname());
                $this->assertSame('Mustermann', $m->getNachname());
                $m->setId(1);
                return $m;
            });
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $this->service->create($data);
    }

    public function testCreateSetsDefaults(): void {
        $data = $this->getValidMemberData();

        $this->memberMapper->method('search')->willReturn([]);
        $this->memberMapper->method('insert')
            ->willReturnCallback(function (Member $m) {
                $this->assertSame('mitglied', $m->getRolle());
                $this->assertSame('aktiv', $m->getStatus());
                $m->setId(1);
                return $m;
            });
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $this->service->create($data);
    }

    public function testCreateWithSubEntities(): void {
        $data = $this->getValidMemberData();
        $addresses = [['strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin']];
        $emails = [['email' => 'test@example.com']];

        $this->memberMapper->method('search')->willReturn([]);
        $this->memberMapper->method('insert')
            ->willReturnCallback(function (Member $m) {
                $m->setId(1);
                return $m;
            });

        $this->addressMapper->method('insert')
            ->willReturnCallback(function (Address $a) {
                $a->setId(1);
                return $a;
            });
        $this->emailMapper->method('insert')
            ->willReturnCallback(function (Email $e) {
                $e->setId(1);
                return $e;
            });

        $this->addressMapper->expects($this->once())->method('insert');
        $this->emailMapper->expects($this->once())->method('insert');

        $this->service->create($data, $addresses, [], $emails);
    }

    public function testCreateMissingVornameThrowsValidationException(): void {
        $data = $this->getValidMemberData();
        unset($data['vorname']);

        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('vorname');

        $this->service->create($data);
    }

    public function testCreateMissingNachnameThrowsValidationException(): void {
        $data = $this->getValidMemberData();
        unset($data['nachname']);

        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('nachname');

        $this->service->create($data);
    }

    public function testCreateWithoutGeburtsdatumSucceeds(): void {
        $data = $this->getValidMemberData();
        unset($data['geburtsdatum']);

        $member = $this->createMember(1, geburtsdatum: '');
        $this->memberMapper->method('search')->willReturn([]);
        $this->memberMapper->method('insert')->willReturnCallback(function (Member $m) use ($member) {
            $m->setId(1);
            return $m;
        });
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $result = $this->service->create($data);
        $this->assertSame(1, $result['id']);
    }

    public function testCreateMissingEintrittThrowsValidationException(): void {
        $data = $this->getValidMemberData();
        unset($data['eintritt']);

        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('eintritt');

        $this->service->create($data);
    }

    public function testCreateEmptyVornameThrowsValidationException(): void {
        $data = $this->getValidMemberData();
        $data['vorname'] = '   ';

        $this->expectException(ValidationException::class);

        $this->service->create($data);
    }

    public function testCreateFutureBirthdayThrowsValidationException(): void {
        $data = $this->getValidMemberData();
        $data['geburtsdatum'] = '2099-01-01';

        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('Zukunft');

        $this->service->create($data);
    }

    public function testCreateVeryOldBirthdayThrowsValidationException(): void {
        $data = $this->getValidMemberData();
        $data['geburtsdatum'] = '1800-01-01';

        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('120');

        $this->service->create($data);
    }

    public function testCreateEintrittBeforeBirthdayThrowsValidationException(): void {
        $data = $this->getValidMemberData();
        $data['geburtsdatum'] = '2010-06-15';
        $data['eintritt'] = '2005-01-01';

        $this->expectException(ValidationException::class);
        $this->expectExceptionMessage('Eintrittsdatum');

        $this->service->create($data);
    }

    public function testCreateDuplicateThrowsDuplicateMemberException(): void {
        $data = $this->getValidMemberData();

        $existingMember = $this->createMember(99, 'Max', 'Mustermann', '2010-01-15');

        $this->memberMapper->method('search')
            ->with('Mustermann')
            ->willReturn([$existingMember]);

        $this->expectException(DuplicateMemberException::class);

        $this->service->create($data);
    }

    public function testCreateDuplicateDetectionIsCaseInsensitive(): void {
        $data = $this->getValidMemberData();
        $data['vorname'] = 'max';
        $data['nachname'] = 'mustermann';

        $existingMember = $this->createMember(99, 'Max', 'Mustermann', '2010-01-15');

        $this->memberMapper->method('search')
            ->willReturn([$existingMember]);

        $this->expectException(DuplicateMemberException::class);

        $this->service->create($data);
    }

    public function testCreateNoDuplicateWhenDifferentBirthday(): void {
        $data = $this->getValidMemberData();

        $existingMember = $this->createMember(99, 'Max', 'Mustermann', '2011-01-15');

        $this->memberMapper->method('search')->willReturn([$existingMember]);
        $this->memberMapper->method('insert')
            ->willReturnCallback(function (Member $m) {
                $m->setId(1);
                return $m;
            });
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $result = $this->service->create($data);
        $this->assertSame('Max', $result['vorname']);
    }

    // ── update() ────────────────────────────────────────────────────

    public function testUpdatePartialFields(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
            return $m;
        });
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $result = $this->service->update(1, ['notizen' => 'Test']);

        $this->assertSame('Max', $result['vorname']);
    }

    public function testUpdateTrimsVorname(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
            $this->assertSame('Moritz', $m->getVorname());
            return $m;
        });
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $this->service->update(1, ['vorname' => '  Moritz  ']);
    }

    public function testUpdateValidationTriggeredOnKeyFields(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);

        $this->expectException(ValidationException::class);

        $this->service->update(1, ['vorname' => '']);
    }

    public function testUpdateLogsAudit(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $this->auditService->expects($this->once())->method('logUpdate');

        $this->service->update(1, ['notizen' => 'New notes']);
    }

    public function testUpdateThrowsForMissingMember(): void {
        $this->memberMapper->method('findById')
            ->willThrowException(new DoesNotExistException('Not found'));

        $this->expectException(DoesNotExistException::class);

        $this->service->update(999, ['vorname' => 'Test']);
    }

    public function testUpdateSetsMultipleFields(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
            $this->assertSame('Moritz', $m->getVorname());
            $this->assertSame('Schmidt', $m->getNachname());
            $this->assertSame('inaktiv', $m->getStatus());
            return $m;
        });
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $this->service->update(1, [
            'vorname' => 'Moritz',
            'nachname' => 'Schmidt',
            'status' => 'inaktiv',
        ]);
    }

    public function testUpdateFiltersOutSystemFields(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
            $this->assertSame('Moritz', $m->getVorname());
            return $m;
        });
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        // Send editable fields plus system fields that should be ignored
        $result = $this->service->update(1, [
            'vorname' => 'Moritz',
            'createdAt' => '1999-01-01 00:00:00',
            'updatedAt' => '1999-01-01 00:00:00',
            'deletedAt' => '1999-01-01 00:00:00',
            'id' => 999,
        ]);

        // createdAt should NOT have been changed to the request value
        $this->assertSame('Moritz', $result['vorname']);
    }

    public function testUpdateAddsNewAddressWhenSyncingEmptyList(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnArgument(0);
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $inserted = new Address();
        $inserted->setStrasse('Musterstr. 1');
        $inserted->setPlz('12345');
        $inserted->setOrt('Berlin');
        $inserted->setLand('Deutschland');
        $inserted->setMemberId(1);
        $inserted->setIsPrimary(false);

        $this->addressMapper->expects($this->once())
            ->method('insert')
            ->willReturn($inserted);
        $this->addressMapper->expects($this->never())->method('update');
        $this->addressMapper->expects($this->never())->method('delete');

        $result = $this->service->update(1, [
            'vorname' => 'Moritz',
            'addresses' => [
                ['strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland'],
            ],
        ]);

        $this->assertSame('Moritz', $result['vorname']);
    }

    public function testUpdateUpdatesExistingAddressById(): void {
        $member = $this->createMember(1);

        $existing = new Address();
        $existing->setId(42);
        $existing->setMemberId(1);
        $existing->setStrasse('Alt');
        $existing->setPlz('00000');
        $existing->setOrt('Alt');
        $existing->setLand('Deutschland');
        $existing->setIsPrimary(false);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnArgument(0);
        $this->addressMapper->method('findByMemberId')->willReturn([$existing]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $this->addressMapper->method('find')->with(42)->willReturn($existing);
        $this->addressMapper->expects($this->once())
            ->method('update')
            ->willReturnArgument(0);
        $this->addressMapper->expects($this->never())->method('insert');
        $this->addressMapper->expects($this->never())->method('delete');

        $this->service->update(1, [
            'addresses' => [
                ['id' => 42, 'strasse' => 'Neu 1', 'plz' => '12345', 'ort' => 'Neu'],
            ],
        ]);
    }

    public function testUpdateDeletesAddressesNotInPayload(): void {
        $member = $this->createMember(1);

        $keep = new Address();
        $keep->setId(42);
        $keep->setMemberId(1);
        $keep->setStrasse('Keep');
        $keep->setPlz('11111');
        $keep->setOrt('Keep');
        $keep->setLand('Deutschland');
        $keep->setIsPrimary(false);

        $remove = new Address();
        $remove->setId(43);
        $remove->setMemberId(1);
        $remove->setStrasse('Remove');
        $remove->setPlz('22222');
        $remove->setOrt('Remove');
        $remove->setLand('Deutschland');
        $remove->setIsPrimary(false);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnArgument(0);
        $this->addressMapper->method('findByMemberId')->willReturn([$keep, $remove]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $this->addressMapper->method('find')->willReturnMap([
            [42, $keep],
            [43, $remove],
        ]);
        $this->addressMapper->expects($this->once())->method('update')->willReturnArgument(0);
        $this->addressMapper->expects($this->once())->method('delete')->with($remove);
        $this->addressMapper->expects($this->never())->method('insert');

        $this->service->update(1, [
            'addresses' => [
                ['id' => 42, 'strasse' => 'Keep', 'plz' => '11111', 'ort' => 'Keep'],
            ],
        ]);
    }

    public function testUpdateWithoutSubEntitiesKeysDoesNotTouchAddresses(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnArgument(0);
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        // Only mapper calls allowed: no insert/update/delete on sub-entity mappers
        $this->addressMapper->expects($this->never())->method('insert');
        $this->addressMapper->expects($this->never())->method('update');
        $this->addressMapper->expects($this->never())->method('delete');
        $this->phoneMapper->expects($this->never())->method('insert');
        $this->phoneMapper->expects($this->never())->method('update');
        $this->phoneMapper->expects($this->never())->method('delete');
        $this->emailMapper->expects($this->never())->method('insert');
        $this->emailMapper->expects($this->never())->method('update');
        $this->emailMapper->expects($this->never())->method('delete');

        $this->service->update(1, ['vorname' => 'Moritz']);
    }

    // ── revealAllAllergies ─────────────────────────────────────────────

    public function testRevealAllAllergiesDecryptsEveryMember(): void {
        $m1 = $this->createMember(1, 'A', 'Aa');
        $m1->setAllergienEncrypted('CT1');
        $m2 = $this->createMember(2, 'B', 'Bb');
        $m2->setAllergienEncrypted(null);
        $m3 = $this->createMember(3, 'C', 'Cc');
        $m3->setAllergienEncrypted('CT3');

        $this->memberMapper->method('findAll')->willReturn([$m1, $m2, $m3]);

        $encryption = $this->createMock(EncryptionService::class);
        $encryption->method('decrypt')->willReturnMap([
            ['CT1', 'Nussallergie'],
            ['CT3', 'Laktose'],
        ]);

        $service = new MemberService(
            $this->memberMapper,
            $this->addressMapper,
            $this->phoneMapper,
            $this->emailMapper,
            $this->familyMapper,
            $this->stufeHistoryMapper,
            $this->auditService,
            $this->logger,
            $encryption,
            $this->db
        );

        $map = $service->revealAllAllergies('alice');

        $this->assertSame('Nussallergie', $map[1]);
        $this->assertNull($map[2]);
        $this->assertSame('Laktose', $map[3]);
    }

    public function testRevealAllAllergiesLogsCountsNotValues(): void {
        $m1 = $this->createMember(1);
        $m1->setAllergienEncrypted('CT');

        $this->memberMapper->method('findAll')->willReturn([$m1]);

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

        $this->logger->expects($this->once())
            ->method('info')
            ->with(
                $this->stringContains('Bulk-Reveal'),
                $this->callback(function ($ctx) {
                    $flat = json_encode($ctx);
                    return !str_contains($flat, 'Nuss');
                })
            );

        $service = new MemberService(
            $this->memberMapper,
            $this->addressMapper,
            $this->phoneMapper,
            $this->emailMapper,
            $this->familyMapper,
            $this->stufeHistoryMapper,
            $this->auditService,
            $this->logger,
            $encryption,
            $this->db
        );

        $service->revealAllAllergies('alice');
    }

    public function testRevealAllAllergiesThrowsWithoutEncryptionService(): void {
        $this->expectException(\RuntimeException::class);
        $this->service->revealAllAllergies('alice');
    }

    public function testUpdateImportedMemberSucceeds(): void {
        // Simulate a member created by import (has all fields set via import path)
        $member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01');

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
            $this->assertSame('Max Updated', $m->getVorname());
            return $m;
        });
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        // Update with same payload structure the frontend sends
        $result = $this->service->update(1, [
            'vorname' => 'Max Updated',
            'nachname' => 'Mustermann',
            'geburtsdatum' => '2010-01-15',
            'eintritt' => '2020-01-01',
            'geschlecht' => null,
            'rolle' => 'mitglied',
            'stufeId' => null,
            'austritt' => null,
            'status' => 'aktiv',
            'allergienEncrypted' => null,
            'notizen' => null,
            'zusatzNotizen' => null,
            'kvTyp' => null,
            'kvName' => null,
            'familyId' => null,
            'frozenFeeRate' => null,
            'einwilligungDatum' => null,
        ]);

        $this->assertSame('Max Updated', $result['vorname']);
    }

    // ── softDelete() ────────────────────────────────────────────────

    public function testSoftDeleteSetsStatusGeloescht(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
            $this->assertSame('geloescht', $m->getStatus());
            $this->assertNotNull($m->getDeletedAt());
            return $m;
        });

        $this->addressMapper->expects($this->once())->method('deleteByMemberId')->with(1);
        $this->phoneMapper->expects($this->once())->method('deleteByMemberId')->with(1);
        $this->emailMapper->expects($this->once())->method('deleteByMemberId')->with(1);

        $this->service->softDelete(1);
    }

    public function testSoftDeletePurgesSensitiveFields(): void {
        $member = $this->createMember(1);
        $member->setAllergienEncrypted('ENC:sensitive');
        $member->setNotizen('Some notes');
        $member->setZusatzNotizen('Extra notes');
        $member->setKvTyp('gesetzlich');
        $member->setKvName('AOK');

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
            $this->assertNull($m->getAllergienEncrypted());
            $this->assertNull($m->getNotizen());
            $this->assertNull($m->getZusatzNotizen());
            $this->assertNull($m->getKvTyp());
            $this->assertNull($m->getKvName());
            $this->assertNull($m->getFamilyId());
            $this->assertNull($m->getFrozenFeeRate());
            return $m;
        });

        $this->service->softDelete(1);
    }

    public function testSoftDeleteSetsAustrittIfNotSet(): void {
        $member = $this->createMember(1);
        // austritt is null by default

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
            $this->assertNotNull($m->getAustritt());
            return $m;
        });

        $this->service->softDelete(1);
    }

    public function testSoftDeleteKeepsExistingAustritt(): void {
        $member = $this->createMember(1);
        $member->setAustritt('2025-06-15');

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(function (Member $m) {
            $this->assertSame('2025-06-15', $m->getAustritt());
            return $m;
        });

        $this->service->softDelete(1);
    }

    public function testSoftDeleteLogsAudit(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);

        $this->auditService->expects($this->once())->method('logSoftDelete')->with('member', 1);
        // Also logs the purge data via logCreate
        $this->auditService->expects($this->once())->method('logCreate');

        $this->service->softDelete(1);
    }

    public function testSoftDeleteLogsToLogger(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);

        $this->logger->expects($this->once())->method('info');

        $this->service->softDelete(1);
    }

    public function testDeleteDelegatesToSoftDelete(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->expects($this->once())->method('update');

        $this->service->delete(1);
    }

    // ── search() ────────────────────────────────────────────────────

    public function testSearchReturnsMatchingMembers(): void {
        $member = $this->createMember(1);
        $this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([
            $member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
        ]);

        $result = $this->service->search('Max');

        $this->assertCount(1, $result);
        $this->assertSame('Max', $result[0]['vorname']);
    }

    public function testSearchReturnsEmptyForNoMatch(): void {
        $this->memberMapper->method('searchWithRelations')->willReturn([]);

        $result = $this->service->search('Nonexistent');
        $this->assertSame([], $result);
    }

    // ── fullTextSearch() ────────────────────────────────────────────

    public function testFullTextSearchReturnsResultsWithMatchContext(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->method('fullTextSearchWithRelations')
            ->with('Max', 20)
            ->willReturn([$memberData]);

        $result = $this->service->fullTextSearch('Max');

        $this->assertCount(1, $result);
        $this->assertArrayHasKey('matchContext', $result[0]);
        $this->assertStringContainsString('Name:', $result[0]['matchContext']);
    }

    public function testFullTextSearchMatchesAddress(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Musterstadt', 'land' => 'Deutschland', 'isPrimary' => true]],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->method('fullTextSearchWithRelations')
            ->willReturn([$memberData]);

        $result = $this->service->fullTextSearch('Musterstrasse');

        $this->assertCount(1, $result);
        $this->assertStringContainsString('Adresse:', $result[0]['matchContext']);
    }

    public function testFullTextSearchMatchesNotizen(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann');
        $member->setNotizen('Hat einen besonderen Wunsch fuer Training');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]);

        $result = $this->service->fullTextSearch('Training');

        $this->assertCount(1, $result);
        $this->assertStringContainsString('Notizen:', $result[0]['matchContext']);
    }

    public function testFullTextSearchMatchesZusatzNotizen(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann');
        $member->setZusatzNotizen('Zusaetzliche Information');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]);

        // Search for something not in name or notizen, but in zusatzNotizen
        $result = $this->service->fullTextSearch('zusaetzliche');

        $this->assertCount(1, $result);
        $this->assertStringContainsString('Zusatz-Notizen', $result[0]['matchContext']);
    }

    // ── findByBirthdayThisMonth() ───────────────────────────────────

    public function testFindByBirthdayThisMonthCallsMapperWithCurrentMonth(): void {
        $currentMonth = (int)(new \DateTime())->format('m');

        $this->memberMapper->expects($this->once())
            ->method('findByBirthdayMonthWithRelations')
            ->with($currentMonth)
            ->willReturn([]);

        $this->service->findByBirthdayThisMonth();
    }

    public function testFindByBirthdayThisMonthReturnsMembers(): void {
        $member = $this->createMember(1);
        $this->memberMapper->method('findByBirthdayMonthWithRelations')->willReturn([
            $member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
        ]);

        $result = $this->service->findByBirthdayThisMonth();

        $this->assertCount(1, $result);
    }

    // ── findWithUnpaidFees() ────────────────────────────────────────

    public function testFindWithUnpaidFeesCallsMapperWithCurrentYear(): void {
        $currentYear = (int)(new \DateTime())->format('Y');

        $this->memberMapper->expects($this->once())
            ->method('findWithUnpaidFeesWithRelations')
            ->with($currentYear)
            ->willReturn([]);

        $this->service->findWithUnpaidFees();
    }

    public function testFindWithUnpaidFeesReturnsMembers(): void {
        $member = $this->createMember(1);
        $this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([
            $member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
        ]);

        $result = $this->service->findWithUnpaidFees();

        $this->assertCount(1, $result);
    }

    // ── findFiltered() ────────────────────────────────────────────────

    public function testFindFilteredDelegatesToMapper(): void {
        $this->memberMapper->expects($this->once())
            ->method('findFilteredWithRelations')
            ->with('aktiv', 'mitglied', true, false)
            ->willReturn([]);

        $this->service->findFiltered('aktiv', 'mitglied', true, false);
    }

    public function testFindFilteredNoFilters(): void {
        $this->memberMapper->expects($this->once())
            ->method('findFilteredWithRelations')
            ->with(null, null, false, false)
            ->willReturn([]);

        $result = $this->service->findFiltered();
        $this->assertCount(0, $result);
    }

    public function testFindFilteredReturnsWithSubEntities(): void {
        $member = $this->createMember(1);
        $memberData = $member->jsonSerialize() + [
            'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->method('findFilteredWithRelations')->willReturn([$memberData]);

        $result = $this->service->findFiltered('aktiv');

        $this->assertCount(1, $result);
        $this->assertSame(1, $result[0]['id']);
        $this->assertArrayHasKey('addresses', $result[0]);
        $this->assertCount(1, $result[0]['addresses']);
    }

    public function testFindFilteredByRolleOnly(): void {
        $member = $this->createMember(1, rolle: 'erziehungsberechtigter');
        $this->memberMapper->method('findFilteredWithRelations')
            ->with(null, 'erziehungsberechtigter', false, false)
            ->willReturn([$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []]]);

        $result = $this->service->findFiltered(null, 'erziehungsberechtigter');

        $this->assertCount(1, $result);
        $this->assertSame('erziehungsberechtigter', $result[0]['rolle']);
    }

    public function testFindFilteredCombinedAllParams(): void {
        $this->memberMapper->expects($this->once())
            ->method('findFilteredWithRelations')
            ->with('inaktiv', 'mitglied', true, true)
            ->willReturn([]);

        $result = $this->service->findFiltered('inaktiv', 'mitglied', true, true);
        $this->assertCount(0, $result);
    }

    // ── Sub-entity management: Addresses ────────────────────────────

    public function testAddAddressCreatesAddress(): void {
        $this->addressMapper->expects($this->once())
            ->method('insert')
            ->willReturnCallback(function (Address $a) {
                $this->assertSame(1, $a->getMemberId());
                $this->assertSame('Hauptstr. 1', $a->getStrasse());
                $this->assertSame('12345', $a->getPlz());
                $this->assertSame('Berlin', $a->getOrt());
                $this->assertSame('Deutschland', $a->getLand());
                $a->setId(1);
                return $a;
            });

        $result = $this->service->addAddress(1, [
            'strasse' => 'Hauptstr. 1',
            'plz' => '12345',
            'ort' => 'Berlin',
        ]);

        $this->assertInstanceOf(Address::class, $result);
    }

    public function testUpdateAddressUpdatesFields(): void {
        $address = $this->createAddress(1, 1);

        $this->addressMapper->method('find')->with(1)->willReturn($address);
        $this->addressMapper->expects($this->once())
            ->method('update')
            ->willReturnCallback(function (Address $a) {
                $this->assertSame('Neue Strasse 2', $a->getStrasse());
                return $a;
            });

        $this->service->updateAddress(1, ['strasse' => 'Neue Strasse 2']);
    }

    public function testDeleteAddressDeletesAddress(): void {
        $address = $this->createAddress(1, 1);

        $this->addressMapper->method('find')->with(1)->willReturn($address);
        $this->addressMapper->expects($this->once())->method('delete')->with($address);

        $this->service->deleteAddress(1);
    }

    // ── Sub-entity management: Phones ───────────────────────────────

    public function testAddPhoneValidatesAndNormalizesNumber(): void {
        $this->phoneMapper->expects($this->once())
            ->method('insert')
            ->willReturnCallback(function (Phone $p) {
                $this->assertSame('+4917612345678', $p->getNumberE164());
                $p->setId(1);
                return $p;
            });

        $result = $this->service->addPhone(1, [
            'numberE164' => '017612345678',
            'label' => 'Mobil',
        ]);

        $this->assertInstanceOf(Phone::class, $result);
    }

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

        $this->service->addPhone(1, ['numberE164' => 'invalid']);
    }

    public function testUpdatePhoneValidatesNumber(): void {
        $phone = $this->createPhone(1, 1);

        $this->phoneMapper->method('find')->with(1)->willReturn($phone);
        $this->phoneMapper->method('update')->willReturnCallback(fn(Phone $p) => $p);

        $result = $this->service->updatePhone(1, ['numberE164' => '+4917699999999']);
        $this->assertInstanceOf(Phone::class, $result);
    }

    public function testUpdatePhoneInvalidNumberThrowsValidationException(): void {
        $phone = $this->createPhone(1, 1);

        $this->phoneMapper->method('find')->with(1)->willReturn($phone);

        $this->expectException(ValidationException::class);

        $this->service->updatePhone(1, ['numberE164' => 'abc']);
    }

    public function testDeletePhoneDeletesPhone(): void {
        $phone = $this->createPhone(1, 1);

        $this->phoneMapper->method('find')->with(1)->willReturn($phone);
        $this->phoneMapper->expects($this->once())->method('delete')->with($phone);

        $this->service->deletePhone(1);
    }

    // ── Sub-entity management: Emails ───────────────────────────────

    public function testAddEmailCreatesEmail(): void {
        $this->emailMapper->expects($this->once())
            ->method('insert')
            ->willReturnCallback(function (Email $e) {
                $this->assertSame(1, $e->getMemberId());
                $this->assertSame('test@example.com', $e->getEmail());
                $e->setId(1);
                return $e;
            });

        $result = $this->service->addEmail(1, ['email' => ' test@example.com ']);

        $this->assertInstanceOf(Email::class, $result);
    }

    public function testUpdateEmailUpdatesFields(): void {
        $email = $this->createEmail(1, 1);

        $this->emailMapper->method('find')->with(1)->willReturn($email);
        $this->emailMapper->expects($this->once())
            ->method('update')
            ->willReturnCallback(function (Email $e) {
                $this->assertSame('new@example.com', $e->getEmail());
                return $e;
            });

        $this->service->updateEmail(1, ['email' => ' new@example.com ']);
    }

    public function testDeleteEmailDeletesEmail(): void {
        $email = $this->createEmail(1, 1);

        $this->emailMapper->method('find')->with(1)->willReturn($email);
        $this->emailMapper->expects($this->once())->method('delete')->with($email);

        $this->service->deleteEmail(1);
    }

    // ── countAll() ──────────────────────────────────────────────────

    public function testCountAllDelegatesToMapper(): void {
        $this->memberMapper->method('countAll')->willReturn(42);

        $this->assertSame(42, $this->service->countAll());
    }

    // ── findByFamily() ──────────────────────────────────────────────

    public function testFindByFamilyReturnsMembers(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5);

        $this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([
            $member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
        ]);

        $result = $this->service->findByFamily(5);

        $this->assertCount(1, $result);
        $this->assertSame('Max', $result[0]['vorname']);
    }

    // ── findByStatus() ──────────────────────────────────────────────

    public function testFindByStatusReturnsMembers(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv');

        $this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([
            $member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
        ]);

        $result = $this->service->findByStatus('aktiv');

        $this->assertCount(1, $result);
    }

    // ── findArchived() ──────────────────────────────────────────────

    public function testFindArchivedReturnsOnlyDeletedMembers(): void {
        $activeMember = $this->createMember(1);
        $deletedMember = $this->createMember(2, 'Anna', 'Schmidt');
        $deletedMember->setDeletedAt('2026-01-01 00:00:00');
        $deletedMember->setStatus('geloescht');
        $deletedMember->setEintritt('2018-01-01');
        $deletedMember->setAustritt('2025-12-31');

        $this->memberMapper->method('findAll')
            ->with(null, null, true)
            ->willReturn([$activeMember, $deletedMember]);

        $result = $this->service->findArchived();

        $this->assertCount(1, $result);
        $this->assertSame('Anna', $result[0]['vorname']);
        $this->assertArrayHasKey('mitgliedsdauer', $result[0]);
        $this->assertArrayHasKey('deletedAt', $result[0]);
    }

    // ── countArchived() ──────────────────────────────────────────────

    public function testCountArchivedReturnsCorrectCount(): void {
        // Issue #201: countArchived() now delegates to MemberMapper::countArchived()
        // which uses a SQL COUNT query instead of loading all members into memory.
        $this->memberMapper->method('countArchived')->willReturn(1);

        $this->assertSame(1, $this->service->countArchived());
    }

    public function testCountArchivedReturnsZeroWhenNoArchived(): void {
        $this->memberMapper->method('countArchived')->willReturn(0);

        $this->assertSame(0, $this->service->countArchived());
    }

    // ── Joined methods: findAllWithRelations ──────────────────────

    public function testFindAllWithRelationsReturnsMemberWithNestedSubEntities(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [
                ['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true],
                ['id' => 2, 'memberId' => 1, 'label' => 'Arbeit', 'strasse' => 'Büroweg 5', 'plz' => '12346', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => false],
            ],
            'phones' => [
                ['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678'],
            ],
            'emails' => [
                ['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com'],
            ],
        ];

        $this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);

        $result = $this->service->findAll();

        $this->assertCount(1, $result);
        $this->assertSame('Max', $result[0]['vorname']);
        $this->assertCount(2, $result[0]['addresses']);
        $this->assertCount(1, $result[0]['phones']);
        $this->assertCount(1, $result[0]['emails']);
        $this->assertSame('Hauptstr. 1', $result[0]['addresses'][0]['strasse']);
        $this->assertSame('+4917612345678', $result[0]['phones'][0]['numberE164']);
        $this->assertSame('max@example.com', $result[0]['emails'][0]['email']);
    }

    public function testFindAllWithRelationsHandlesMemberWithoutSubEntities(): void {
        $member = $this->createMember(1);
        $memberData = $member->jsonSerialize() + [
            'addresses' => [],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);

        $result = $this->service->findAll();

        $this->assertCount(1, $result);
        $this->assertCount(0, $result[0]['addresses']);
        $this->assertCount(0, $result[0]['phones']);
        $this->assertCount(0, $result[0]['emails']);
    }

    // ── Joined methods: searchWithRelations ───────────────────────

    public function testSearchWithRelationsReturnsMemberWithSubEntities(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([$memberData]);

        $result = $this->service->search('Max');

        $this->assertCount(1, $result);
        $this->assertSame('Max', $result[0]['vorname']);
        $this->assertCount(1, $result[0]['addresses']);
    }

    // ── Joined methods: findByFamilyWithRelations ─────────────────

    public function testFindByFamilyWithRelationsReturnsMemberWithSubEntities(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5);
        $memberData = $member->jsonSerialize() + [
            'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
            'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']],
            'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']],
        ];

        $this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([$memberData]);

        $result = $this->service->findByFamily(5);

        $this->assertCount(1, $result);
        $this->assertSame('Max', $result[0]['vorname']);
        $this->assertCount(1, $result[0]['addresses']);
        $this->assertCount(1, $result[0]['phones']);
        $this->assertCount(1, $result[0]['emails']);
    }

    // ── Joined methods: findByStatusWithRelations ─────────────────

    public function testFindByStatusWithRelationsReturnsMemberWithSubEntities(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([$memberData]);

        $result = $this->service->findByStatus('aktiv');

        $this->assertCount(1, $result);
        $this->assertSame('Max', $result[0]['vorname']);
    }

    // ── Joined methods: findByBirthdayMonthWithRelations ──────────

    public function testFindByBirthdayMonthWithRelationsReturnsMemberWithSubEntities(): void {
        $currentMonth = (int)(new \DateTime())->format('m');
        $member = $this->createMember(1, 'Max', 'Mustermann', '2010-06-15', '2020-01-01');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->expects($this->once())
            ->method('findByBirthdayMonthWithRelations')
            ->with($currentMonth)
            ->willReturn([$memberData]);

        $result = $this->service->findByBirthdayThisMonth();

        $this->assertCount(1, $result);
        $this->assertSame('Max', $result[0]['vorname']);
    }

    // ── Joined methods: findWithUnpaidFeesWithRelations ───────────

    public function testFindWithUnpaidFeesWithRelationsReturnsMemberWithSubEntities(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([$memberData]);

        $result = $this->service->findWithUnpaidFees();

        $this->assertCount(1, $result);
        $this->assertSame('Max', $result[0]['vorname']);
    }

    // ── Joined methods: findFilteredWithRelations ─────────────────

    public function testFindFilteredWithRelationsReturnsMemberWithSubEntities(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->method('findFilteredWithRelations')
            ->with('aktiv', 'mitglied', false, false)
            ->willReturn([$memberData]);

        $result = $this->service->findFiltered('aktiv', 'mitglied');

        $this->assertCount(1, $result);
        $this->assertSame('Max', $result[0]['vorname']);
        $this->assertCount(1, $result[0]['addresses']);
    }

    // ── Joined methods: fullTextSearchWithRelations ───────────────

    public function testFullTextSearchWithRelationsReturnsResultsWithMatchContext(): void {
        $member = $this->createMember(1, 'Max', 'Mustermann');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
            'phones' => [],
            'emails' => [],
        ];

        $this->memberMapper->method('fullTextSearchWithRelations')
            ->with('Musterstrasse', 20)
            ->willReturn([$memberData]);

        $result = $this->service->fullTextSearch('Musterstrasse');

        $this->assertCount(1, $result);
        $this->assertArrayHasKey('matchContext', $result[0]);
        $this->assertStringContainsString('Adresse:', $result[0]['matchContext']);
        $this->assertStringContainsString('Musterstrasse 1', $result[0]['matchContext']);
    }

    // ── Backward compatibility: findAll shape ─────────────────────

    public function testFindAllReturnsSameShapeAsBefore(): void {
        // The new method returns the same shape as the old one:
        // an array of member arrays with addresses/phones/emails sub-arrays
        $member = $this->createMember(1, 'Max');
        $memberData = $member->jsonSerialize() + [
            'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
            'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']],
            'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']],
        ];

        $this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);

        $result = $this->service->findAll();

        // Verify the shape matches what the frontend expects
        $this->assertIsArray($result[0]);
        $this->assertArrayHasKey('id', $result[0]);
        $this->assertArrayHasKey('vorname', $result[0]);
        $this->assertArrayHasKey('nachname', $result[0]);
        $this->assertArrayHasKey('addresses', $result[0]);
        $this->assertArrayHasKey('phones', $result[0]);
        $this->assertArrayHasKey('emails', $result[0]);
    }

    // ── Database Transactions ───────────────────────────────────────

    public function testCreateCommitsTransactionOnSuccess(): void {
        $data = $this->getValidMemberData();

        $this->memberMapper->method('search')->willReturn([]);
        $this->memberMapper->method('insert')
            ->willReturnCallback(function (Member $m) {
                $m->setId(1);
                return $m;
            });

        $this->db->expects($this->once())->method('beginTransaction');
        $this->db->expects($this->once())->method('commit');
        $this->db->expects($this->never())->method('rollback');

        $this->service->create($data);
    }

    public function testCreateRollbacksTransactionWhenSubEntityInsertFails(): void {
        $data = $this->getValidMemberData();
        $phones = [['numberE164' => '+4917612345678']];

        $this->memberMapper->method('search')->willReturn([]);
        $this->memberMapper->method('insert')
            ->willReturnCallback(function (Member $m) {
                $m->setId(1);
                return $m;
            });
        $this->phoneMapper->method('insert')->willThrowException(new \RuntimeException('DB error'));

        $this->db->expects($this->once())->method('beginTransaction');
        $this->db->expects($this->once())->method('rollback');
        $this->db->expects($this->never())->method('commit');

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('DB error');

        $this->service->create($data, [], $phones, []);
    }

    public function testCreateRollbacksTransactionWhenAddressInsertFails(): void {
        $data = $this->getValidMemberData();
        $addresses = [['strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin']];

        $this->memberMapper->method('search')->willReturn([]);
        $this->memberMapper->method('insert')
            ->willReturnCallback(function (Member $m) {
                $m->setId(1);
                return $m;
            });
        $this->addressMapper->method('insert')->willThrowException(new \RuntimeException('Constraint violation'));

        $this->db->expects($this->once())->method('beginTransaction');
        $this->db->expects($this->once())->method('rollback');
        $this->db->expects($this->never())->method('commit');

        $this->expectException(\RuntimeException::class);

        $this->service->create($data, $addresses, [], []);
    }

    public function testSoftDeleteCommitsTransactionOnSuccess(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);

        $this->db->expects($this->once())->method('beginTransaction');
        $this->db->expects($this->once())->method('commit');
        $this->db->expects($this->never())->method('rollback');

        $this->service->softDelete(1);
    }

    public function testSoftDeleteRollbacksTransactionWhenDeleteFails(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
        $this->phoneMapper->method('deleteByMemberId')->willThrowException(new \RuntimeException('DB error'));

        $this->db->expects($this->once())->method('beginTransaction');
        $this->db->expects($this->once())->method('rollback');
        $this->db->expects($this->never())->method('commit');

        $this->expectException(\RuntimeException::class);

        $this->service->softDelete(1);
    }

    public function testUpdateCommitsTransactionOnSuccess(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        $this->db->expects($this->once())->method('beginTransaction');
        $this->db->expects($this->once())->method('commit');
        $this->db->expects($this->never())->method('rollback');

        $this->service->update(1, ['notizen' => 'Test']);
    }

    public function testUpdateRollbacksTransactionWhenSubEntitySyncFails(): void {
        $member = $this->createMember(1);

        $existing = new Address();
        $existing->setId(42);
        $existing->setMemberId(1);
        $existing->setStrasse('Alt');
        $existing->setPlz('00000');
        $existing->setOrt('Alt');
        $existing->setLand('Deutschland');
        $existing->setIsPrimary(false);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
        $this->addressMapper->method('findByMemberId')->willReturn([$existing]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        // find() is called by syncAddresses -> updateAddress
        $this->addressMapper->method('find')->with(42)->willReturn($existing);
        $this->addressMapper->method('update')->willThrowException(new \RuntimeException('DB error'));

        $this->db->expects($this->once())->method('beginTransaction');
        $this->db->expects($this->once())->method('rollback');
        $this->db->expects($this->never())->method('commit');

        $this->expectException(\RuntimeException::class);

        $this->service->update(1, [
            'addresses' => [
                ['id' => 42, 'strasse' => 'Neu', 'plz' => '12345', 'ort' => 'Neu'],
            ],
        ]);
    }

    public function testUpdateRollbacksTransactionWhenPhoneSyncFails(): void {
        $member = $this->createMember(1);

        $this->memberMapper->method('findById')->with(1)->willReturn($member);
        $this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
        $this->addressMapper->method('findByMemberId')->willReturn([]);
        $this->phoneMapper->method('findByMemberId')->willReturn([]);
        $this->emailMapper->method('findByMemberId')->willReturn([]);

        // syncPhones will try to insert a new phone
        $this->phoneMapper->method('insert')->willThrowException(new \RuntimeException('DB error'));

        $this->db->expects($this->once())->method('beginTransaction');
        $this->db->expects($this->once())->method('rollback');
        $this->db->expects($this->never())->method('commit');

        $this->expectException(\RuntimeException::class);

        $this->service->update(1, [
            'phones' => [['numberE164' => '+4917612345678']],
        ]);
    }
}
