<?php

declare(strict_types=1);

namespace OCA\Mitgliederverwaltung\Service;

use DateTime;
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\Validation\PhoneValidator;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\DB\Exception;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;

/**
 * Service layer for member CRUD operations with validation and business logic.
 *
 * Part of Issues #21, #61.
 */
class MemberService {

    private MemberMapper $memberMapper;
    private AddressMapper $addressMapper;
    private PhoneMapper $phoneMapper;
    private EmailMapper $emailMapper;
    private FamilyMapper $familyMapper;
    private StufeHistoryMapper $stufeHistoryMapper;
    private AuditService $auditService;
    private LoggerInterface $logger;
    private ?EncryptionService $encryptionService;
    private IDBConnection $db;

    public function __construct(
        MemberMapper $memberMapper,
        AddressMapper $addressMapper,
        PhoneMapper $phoneMapper,
        EmailMapper $emailMapper,
        FamilyMapper $familyMapper,
        StufeHistoryMapper $stufeHistoryMapper,
        AuditService $auditService,
        LoggerInterface $logger,
        ?EncryptionService $encryptionService,
        IDBConnection $db
    ) {
        $this->memberMapper = $memberMapper;
        $this->addressMapper = $addressMapper;
        $this->phoneMapper = $phoneMapper;
        $this->emailMapper = $emailMapper;
        $this->familyMapper = $familyMapper;
        $this->stufeHistoryMapper = $stufeHistoryMapper;
        $this->auditService = $auditService;
        $this->logger = $logger;
        $this->encryptionService = $encryptionService;
        $this->db = $db;
    }

    /**
     * Reveal decrypted allergien for every non-deleted member.
     *
     * Caller must be authorized (admin-only enforced by the middleware).
     * The caller is audited — field + count, never the content.
     *
     * @param string $userId ID of the user triggering the reveal (for audit)
     * @return array<int,string|null> Map of memberId → plaintext or null
     * @throws Exception
     * @throws \RuntimeException if EncryptionService is unavailable
     */
    public function revealAllAllergies(string $userId): array {
        if ($this->encryptionService === null) {
            throw new \RuntimeException('EncryptionService ist nicht verfuegbar.');
        }

        $members = $this->memberMapper->findAll();

        $map = [];
        $counted = 0;
        foreach ($members as $m) {
            $ct = $m->getAllergienEncrypted();
            if ($ct === null || $ct === '') {
                $map[$m->getId()] = null;
                continue;
            }
            $plain = $this->encryptionService->decrypt($ct);
            $map[$m->getId()] = $plain;
            if ($plain !== null && $plain !== '') {
                $counted++;
            }
        }

        $this->logger->info('Allergien-Bulk-Reveal durch Admin', [
            'app' => 'mitgliederverwaltung',
            'user' => $userId,
            'members_total' => count($members),
            'members_with_allergien' => $counted,
        ]);

        return $map;
    }

    // ── Create ───────────────────────────────────────────────────────

    /**
     * Create a new member with optional sub-entities.
     *
     * @param array $data Member fields
     * @param array $addresses Array of address data arrays
     * @param array $phones Array of phone data arrays
     * @param array $emails Array of email data arrays
     * @return array Member with loaded sub-entities
     * @throws ValidationException
     * @throws DuplicateMemberException
     * @throws Exception
     */
    public function create(
        array $data,
        array $addresses = [],
        array $phones = [],
        array $emails = []
    ): array {
        $this->validateRequiredFields($data);
        $this->validateDates($data);
        $this->checkForDuplicate($data);

        $now = (new DateTime())->format('Y-m-d H:i:s');

        $member = new Member();
        $member->setVorname(trim($data['vorname']));
        $member->setNachname(trim($data['nachname']));
        $member->setGeburtsdatum($data['geburtsdatum'] ?? null);
        $member->setGeschlecht($data['geschlecht'] ?? null);
        $member->setRolle($data['rolle'] ?? 'mitglied');
        $member->setStufeId($data['stufeId'] ?? null);
        $member->setEintritt($data['eintritt']);
        $member->setAustritt($data['austritt'] ?? null);
        $member->setStatus($data['status'] ?? 'aktiv');
        $member->setAllergienEncrypted($data['allergienEncrypted'] ?? null);
        $member->setNotizen($data['notizen'] ?? null);
        $member->setZusatzNotizen($data['zusatzNotizen'] ?? null);
        $member->setKvTyp($data['kvTyp'] ?? null);
        $member->setKvName($data['kvName'] ?? null);
        $member->setFamilyId($data['familyId'] ?? null);
        $member->setFrozenFeeRate($data['frozenFeeRate'] ?? null);
        $member->setCalendarEventUri($data['calendarEventUri'] ?? null);
        $member->setContactVcardUri($data['contactVcardUri'] ?? null);
        $member->setJuleicaNummer($data['juleicaNummer'] ?? null);
        $member->setJuleicaAblaufdatum($data['juleicaAblaufdatum'] ?? null);
        $member->setCreatedAt($now);
        $member->setUpdatedAt($now);

        $this->db->beginTransaction();
        try {
            /** @var Member $member */
            $member = $this->memberMapper->insert($member);

            $savedAddresses = $this->saveAddresses($member->getId(), $addresses);
            $savedPhones = $this->savePhones($member->getId(), $phones);
            $savedEmails = $this->saveEmails($member->getId(), $emails);

            $this->db->commit();
        } catch (\Exception $e) {
            $this->db->rollback();
            throw $e;
        }

        $this->auditService->logCreate($member->jsonSerialize(), 'member', $member->getId());

        return $this->buildMemberResponse($member, $savedAddresses, $savedPhones, $savedEmails);
    }

    // ── Read ─────────────────────────────────────────────────────────

    /**
     * Find a member by ID with all sub-entities loaded.
     *
     * @throws DoesNotExistException
     * @throws MultipleObjectsReturnedException
     * @throws Exception
     */
    public function find(int $id): array {
        $member = $this->memberMapper->findById($id);
        $addresses = $this->addressMapper->findByMemberId($id);
        $phones = $this->phoneMapper->findByMemberId($id);
        $emails = $this->emailMapper->findByMemberId($id);

        return $this->buildMemberResponse($member, $addresses, $phones, $emails);
    }

    /**
     * Find all members with optional pagination.
     *
     * Uses a single query with LEFT JOINs to avoid the N+1 problem.
     *
     * @return array[]
     * @throws Exception
     */
    public function findAll(?int $limit = null, ?int $offset = null): array {
        return $this->memberMapper->findAllWithRelations($limit, $offset);
    }

    /**
     * Find members by family ID.
     *
     * Uses a single query with LEFT JOINs to avoid the N+1 problem.
     *
     * @return array[]
     * @throws Exception
     */
    public function findByFamily(int $familyId): array {
        return $this->memberMapper->findByFamilyWithRelations($familyId);
    }

    /**
     * Find members by status.
     *
     * Uses a single query with LEFT JOINs to avoid the N+1 problem.
     *
     * @return array[]
     * @throws Exception
     */
    public function findByStatus(string $status): array {
        return $this->memberMapper->findByStatusWithRelations($status);
    }

    /**
     * Search members by name.
     *
     * Uses a single query with LEFT JOINs to avoid the N+1 problem.
     *
     * @return array[]
     * @throws Exception
     */
    public function search(string $query): array {
        return $this->memberMapper->searchWithRelations($query);
    }

    /**
     * Find members with a birthday in the current month.
     *
     * Uses a single query with LEFT JOINs to avoid the N+1 problem.
     *
     * Part of Issue #34.
     *
     * @return array[]
     * @throws Exception
     */
    public function findByBirthdayThisMonth(): array {
        $currentMonth = (int)(new DateTime())->format('m');
        return $this->memberMapper->findByBirthdayMonthWithRelations($currentMonth);
    }

    /**
     * Find members with unpaid fee records for the current year.
     *
     * Uses a single query with LEFT JOINs to avoid the N+1 problem.
     *
     * Part of Issue #34.
     *
     * @return array[]
     * @throws Exception
     */
    public function findWithUnpaidFees(): array {
        $currentYear = (int)(new DateTime())->format('Y');
        return $this->memberMapper->findWithUnpaidFeesWithRelations($currentYear);
    }

    /**
     * Find members matching a combination of filters (additive/AND logic).
     *
     * Uses a single query with LEFT JOINs to avoid the N+1 problem.
     *
     * @return array[]
     * @throws Exception
     */
    public function findFiltered(
        ?string $status = null,
        ?string $rolle = null,
        bool $birthdayThisMonth = false,
        bool $unpaidFees = false
    ): array {
        return $this->memberMapper->findFilteredWithRelations($status, $rolle, $birthdayThisMonth, $unpaidFees);
    }

    /**
     * Full-text search across member names, addresses, and notes.
     * Returns results with match context for display in search dropdown.
     *
     * Uses a single query with LEFT JOINs to avoid the N+1 problem.
     *
     * Part of Issue #33.
     *
     * @return array[] Members with sub-entities and matchContext field
     * @throws Exception
     */
    public function fullTextSearch(string $query, int $limit = 20): array {
        $rawResults = $this->memberMapper->fullTextSearchWithRelations($query, $limit);
        $lowQuery = mb_strtolower($query);

        return array_map(function (array $row) use ($lowQuery): array {
            // buildMatchContext expects an Address[] — the raw result
            // stores addresses as arrays.  Build minimal Address objects
            // so the existing logic works without rewriting the method.
            $addresses = array_map(
                fn(array $a): Address => $this->arrayToAddress($a),
                $row['addresses'] ?? []
            );

            // Determine match context — which field matched
            $row['matchContext'] = $this->buildMatchContext(
                $this->arrayToMember($row),
                $addresses,
                $lowQuery
            );

            return $row;
        }, $rawResults);
    }

    /**
     * Convert an associative array (from fetchWithRelations) to a Member entity.
     */
    private function arrayToMember(array $row): Member {
        $m = new Member();
        $m->setId($row['id']);
        $m->setVorname($row['vorname']);
        $m->setNachname($row['nachname']);
        $m->setGeburtsdatum($row['geburtsdatum']);
        $m->setGeschlecht($row['geschlecht']);
        $m->setRolle($row['rolle']);
        $m->setStufeId($row['stufeId']);
        $m->setEintritt($row['eintritt']);
        $m->setAustritt($row['austritt']);
        $m->setStatus($row['status']);
        $m->setAllergienEncrypted($row['allergienEncrypted']);
        $m->setNotizen($row['notizen']);
        $m->setZusatzNotizen($row['zusatzNotizen']);
        $m->setKvTyp($row['kvTyp']);
        $m->setKvName($row['kvName']);
        $m->setFamilyId($row['familyId']);
        $m->setFrozenFeeRate($row['frozenFeeRate']);
        $m->setCalendarEventUri($row['calendarEventUri']);
        $m->setContactVcardUri($row['contactVcardUri']);
        $m->setEinwilligungDatum($row['einwilligungDatum']);
        $m->setJuleicaNummer($row['juleicaNummer']);
        $m->setJuleicaAblaufdatum($row['juleicaAblaufdatum']);
        return $m;
    }

    /**
     * Convert an associative array (from fetchWithRelations) to an Address entity.
     */
    private function arrayToAddress(array $row): Address {
        $a = new Address();
        $a->setId($row['id']);
        $a->setMemberId($row['memberId']);
        $a->setLabel($row['label']);
        $a->setStrasse($row['strasse']);
        $a->setPlz($row['plz']);
        $a->setOrt($row['ort']);
        $a->setLand($row['land']);
        $a->setIsPrimary($row['isPrimary']);
        return $a;
    }

    /**
     * Build a human-readable match context string.
     *
     * @param Member $member
     * @param Address[] $addresses
     * @param string $lowQuery Lowercased search query
     * @return string
     */
    private function buildMatchContext(Member $member, array $addresses, string $lowQuery): string {
        // Check name match
        if (
            str_contains(mb_strtolower($member->getVorname()), $lowQuery) ||
            str_contains(mb_strtolower($member->getNachname()), $lowQuery)
        ) {
            return 'Name: ' . $member->getVorname() . ' ' . $member->getNachname();
        }

        // Check address match
        foreach ($addresses as $address) {
            if (
                str_contains(mb_strtolower($address->getStrasse()), $lowQuery) ||
                str_contains(mb_strtolower($address->getPlz()), $lowQuery) ||
                str_contains(mb_strtolower($address->getOrt()), $lowQuery)
            ) {
                return 'Adresse: ' . $address->getStrasse() . ', ' . $address->getPlz() . ' ' . $address->getOrt();
            }
        }

        // Check notes match
        if ($member->getNotizen() !== null && str_contains(mb_strtolower($member->getNotizen()), $lowQuery)) {
            $pos = mb_stripos($member->getNotizen(), $lowQuery);
            $start = max(0, $pos - 20);
            $excerpt = mb_substr($member->getNotizen(), $start, 60);
            return 'Notizen: ...' . $excerpt . '...';
        }

        if ($member->getZusatzNotizen() !== null && str_contains(mb_strtolower($member->getZusatzNotizen()), $lowQuery)) {
            return 'Zusatz-Notizen';
        }

        return '';
    }

    /**
     * Count all non-deleted members.
     *
     * @throws Exception
     */
    public function countAll(): int {
        return $this->memberMapper->countAll();
    }

    // ── Update ───────────────────────────────────────────────────────

    /**
     * Update an existing member.
     *
     * @param int $id Member ID
     * @param array $data Fields to update
     * @return array Updated member with sub-entities
     * @throws DoesNotExistException
     * @throws MultipleObjectsReturnedException
     * @throws ValidationException
     * @throws Exception
     */
    public function update(int $id, array $data): array {
        $member = $this->memberMapper->findById($id);
        $oldData = $member->jsonSerialize();

        // Preserve the raw payload so we can still see addresses/phones/emails
        // after filtering the member-column data below.
        $rawData = $data;

        // Filter data to only known editable fields to prevent pollution
        // from read-only/system fields (createdAt, updatedAt, deletedAt, etc.)
        $editableFields = [
            'vorname', 'nachname', 'geburtsdatum', 'geschlecht', 'rolle',
            'stufeId', 'eintritt', 'austritt', 'status', 'allergienEncrypted',
            'notizen', 'zusatzNotizen', 'kvTyp', 'kvName', 'familyId',
            'frozenFeeRate', 'calendarEventUri', 'contactVcardUri', 'einwilligungDatum',
            'juleicaNummer', 'juleicaAblaufdatum',
        ];
        $data = array_intersect_key($data, array_flip($editableFields));

        if (isset($data['vorname']) || isset($data['nachname']) || isset($data['geburtsdatum']) || isset($data['eintritt'])) {
            $merged = array_merge($member->jsonSerialize(), $data);
            $this->validateRequiredFields($merged);
            $this->validateDates($merged);
        }

        if (isset($data['vorname'])) {
            $member->setVorname(trim($data['vorname']));
        }
        if (isset($data['nachname'])) {
            $member->setNachname(trim($data['nachname']));
        }
        if (array_key_exists('geburtsdatum', $data)) {
            $member->setGeburtsdatum($data['geburtsdatum']);
        }
        if (array_key_exists('geschlecht', $data)) {
            $member->setGeschlecht($data['geschlecht']);
        }
        if (isset($data['rolle'])) {
            $member->setRolle($data['rolle']);
        }
        if (array_key_exists('stufeId', $data)) {
            $member->setStufeId($data['stufeId']);
        }
        if (array_key_exists('eintritt', $data)) {
            $member->setEintritt($data['eintritt']);
        }
        if (array_key_exists('austritt', $data)) {
            $member->setAustritt($data['austritt']);
        }
        if (isset($data['status'])) {
            $member->setStatus($data['status']);
        }
        if (array_key_exists('allergienEncrypted', $data)) {
            $member->setAllergienEncrypted($data['allergienEncrypted']);
        }
        if (array_key_exists('notizen', $data)) {
            $member->setNotizen($data['notizen']);
        }
        if (array_key_exists('zusatzNotizen', $data)) {
            $member->setZusatzNotizen($data['zusatzNotizen']);
        }
        if (array_key_exists('kvTyp', $data)) {
            $member->setKvTyp($data['kvTyp']);
        }
        if (array_key_exists('kvName', $data)) {
            $member->setKvName($data['kvName']);
        }
        if (array_key_exists('familyId', $data)) {
            $member->setFamilyId($data['familyId']);
        }
        if (array_key_exists('frozenFeeRate', $data)) {
            $member->setFrozenFeeRate($data['frozenFeeRate']);
        }
        if (array_key_exists('calendarEventUri', $data)) {
            $member->setCalendarEventUri($data['calendarEventUri']);
        }
        if (array_key_exists('contactVcardUri', $data)) {
            $member->setContactVcardUri($data['contactVcardUri']);
        }
        if (array_key_exists('einwilligungDatum', $data)) {
            $member->setEinwilligungDatum($data['einwilligungDatum']);
        }
        if (array_key_exists('juleicaNummer', $data)) {
            $member->setJuleicaNummer($data['juleicaNummer']);
        }
        if (array_key_exists('juleicaAblaufdatum', $data)) {
            $member->setJuleicaAblaufdatum($data['juleicaAblaufdatum']);
        }

        $member->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s'));

        $this->db->beginTransaction();
        try {
            /** @var Member $member */
            $member = $this->memberMapper->update($member);

            // Sync sub-entities if the caller included them in the payload.
            // Absent keys (undefined) leave existing sub-entities untouched.
            if (array_key_exists('addresses', $rawData) && is_array($rawData['addresses'])) {
                $this->syncAddresses($id, $rawData['addresses']);
            }
            if (array_key_exists('phones', $rawData) && is_array($rawData['phones'])) {
                $this->syncPhones($id, $rawData['phones']);
            }
            if (array_key_exists('emails', $rawData) && is_array($rawData['emails'])) {
                $this->syncEmails($id, $rawData['emails']);
            }

            $this->db->commit();
        } catch (\Exception $e) {
            $this->db->rollback();
            throw $e;
        }

        $this->auditService->logUpdate($oldData, $member->jsonSerialize(), 'member', $id);

        return $this->find($id);
    }

    /**
     * Sync a member's addresses against an incoming array.
     * Rules:
     *  - Items with an existing id ∈ current DB set → updated
     *  - Items without id (or with unknown id) → created
     *  - Existing DB items whose id is not referenced → deleted
     *
     * @param array[] $incoming
     * @throws Exception
     */
    private function syncAddresses(int $memberId, array $incoming): void {
        $existing = $this->addressMapper->findByMemberId($memberId);
        $existingById = [];
        foreach ($existing as $addr) {
            $existingById[$addr->getId()] = $addr;
        }

        $keptIds = [];
        foreach ($incoming as $item) {
            $id = isset($item['id']) ? (int)$item['id'] : 0;
            if ($id > 0 && isset($existingById[$id])) {
                $this->updateAddress($id, $item);
                $keptIds[$id] = true;
            } else {
                $this->addAddress($memberId, $item);
            }
        }

        foreach ($existingById as $id => $addr) {
            if (!isset($keptIds[$id])) {
                $this->deleteAddress($id);
            }
        }
    }

    /**
     * Sync a member's phones (same rules as {@see syncAddresses}).
     *
     * @param array[] $incoming
     * @throws Exception
     */
    private function syncPhones(int $memberId, array $incoming): void {
        $existing = $this->phoneMapper->findByMemberId($memberId);
        $existingById = [];
        foreach ($existing as $p) {
            $existingById[$p->getId()] = $p;
        }

        $keptIds = [];
        foreach ($incoming as $item) {
            $id = isset($item['id']) ? (int)$item['id'] : 0;
            if ($id > 0 && isset($existingById[$id])) {
                $this->updatePhone($id, $item);
                $keptIds[$id] = true;
            } else {
                $this->addPhone($memberId, $item);
            }
        }

        foreach ($existingById as $id => $p) {
            if (!isset($keptIds[$id])) {
                $this->deletePhone($id);
            }
        }
    }

    /**
     * Sync a member's emails (same rules as {@see syncAddresses}).
     *
     * @param array[] $incoming
     * @throws Exception
     */
    private function syncEmails(int $memberId, array $incoming): void {
        $existing = $this->emailMapper->findByMemberId($memberId);
        $existingById = [];
        foreach ($existing as $e) {
            $existingById[$e->getId()] = $e;
        }

        $keptIds = [];
        foreach ($incoming as $item) {
            $id = isset($item['id']) ? (int)$item['id'] : 0;
            if ($id > 0 && isset($existingById[$id])) {
                $this->updateEmail($id, $item);
                $keptIds[$id] = true;
            } else {
                $this->addEmail($memberId, $item);
            }
        }

        foreach ($existingById as $id => $e) {
            if (!isset($keptIds[$id])) {
                $this->deleteEmail($id);
            }
        }
    }

    // ── Delete (soft) ────────────────────────────────────────────────

    /**
     * Soft-delete a member: set status to geloescht, hard-delete sensitive data.
     *
     * Retained fields:
     * - Vorname, Nachname, Geburtsdatum, Eintritt, Austritt
     * - Mitgliedsdauer (computed), Stufe history, fee history
     *
     * Hard-deleted immediately:
     * - Family banking reference (cleared, family banking data untouched)
     * - Allergien (encrypted field set to NULL)
     * - Krankenversicherung (kv_typ, kv_name set to NULL)
     * - Notizen, Zusaetzliche Notizen (set to NULL)
     * - All addresses (deleted from oc_mv_addresses)
     * - All phone numbers (deleted from oc_mv_phones)
     * - All email addresses (deleted from oc_mv_emails)
     *
     * Part of Issue #61.
     *
     * @throws DoesNotExistException
     * @throws MultipleObjectsReturnedException
     * @throws Exception
     */
    public function softDelete(int $id): void {
        $member = $this->memberMapper->findById($id);
        $now = (new DateTime())->format('Y-m-d H:i:s');

        // Set austritt to today if not already set
        if ($member->getAustritt() === null) {
            $member->setAustritt((new DateTime())->format('Y-m-d'));
        }

        // Set soft-delete status
        $member->setStatus('geloescht');
        $member->setDeletedAt($now);
        $member->setUpdatedAt($now);

        // Hard-delete sensitive fields on the member record
        $member->setAllergienEncrypted(null);
        $member->setNotizen(null);
        $member->setZusatzNotizen(null);
        $member->setKvTyp(null);
        $member->setKvName(null);
        $member->setFrozenFeeRate(null);

        // Clear family reference (family banking data remains on the family)
        $member->setFamilyId(null);

        // Clear integration references
        $member->setCalendarEventUri(null);
        $member->setContactVcardUri(null);

        $this->db->beginTransaction();
        try {
            $this->memberMapper->update($member);

            // Hard-delete sub-entities (addresses, phones, emails)
            $this->addressMapper->deleteByMemberId($id);
            $this->phoneMapper->deleteByMemberId($id);
            $this->emailMapper->deleteByMemberId($id);

            $this->db->commit();
        } catch (\Exception $e) {
            $this->db->rollback();
            throw $e;
        }

        // Audit log the soft-delete
        $this->auditService->logSoftDelete('member', $id);

        // Log hard-deleted sensitive data as [geloescht] in audit
        $sensitiveFields = [
            'allergienEncrypted' => '[geloescht]',
            'kvTyp' => '[geloescht]',
            'kvName' => '[geloescht]',
            'notizen' => '[geloescht]',
            'zusatzNotizen' => '[geloescht]',
            'adressen' => '[geloescht]',
            'telefonnummern' => '[geloescht]',
            'emails' => '[geloescht]',
        ];
        $this->auditService->logCreate($sensitiveFields, 'soft-delete-purge', $id);

        $this->logger->info('Member soft-deleted with sensitive data purged', [
            'memberId' => $id,
            'app' => 'mitgliederverwaltung',
        ]);
    }

    /**
     * Legacy delete method — delegates to softDelete.
     *
     * @throws DoesNotExistException
     * @throws MultipleObjectsReturnedException
     * @throws Exception
     */
    public function delete(int $id): void {
        $this->softDelete($id);
    }

    // ── Archive (soft-deleted members) ───────────────────────────────

    /**
     * Find all soft-deleted members for the archive view.
     *
     * Returns only retained fields: Name, Geburtsdatum, Eintritt, Austritt.
     *
     * Part of Issue #61.
     *
     * @return array[]
     * @throws Exception
     */
    public function findArchived(?int $limit = null, ?int $offset = null): array {
        $members = $this->memberMapper->findAll($limit, $offset, true);

        // Filter to only soft-deleted members
        $archived = array_filter($members, fn(Member $m) => $m->getDeletedAt() !== null);

        return array_values(array_map(function (Member $member) {
            // Only return retained fields
            return [
                'id' => $member->getId(),
                'vorname' => $member->getVorname(),
                'nachname' => $member->getNachname(),
                'geburtsdatum' => $member->getGeburtsdatum(),
                'eintritt' => $member->getEintritt(),
                'austritt' => $member->getAustritt(),
                'deletedAt' => $member->getDeletedAt(),
                'mitgliedsdauer' => $this->calculateMitgliedsdauer($member),
            ];
        }, $archived));
    }

    /**
     * Count all soft-deleted (archived) members.
     *
     * Part of Issue #201: uses a single SQL COUNT query via the mapper
     * instead of loading all members into memory.
     *
     * @throws Exception
     */
    public function countArchived(): int {
        return $this->memberMapper->countArchived();
    }

    /**
     * Calculate membership duration in years and months.
     */
    private function calculateMitgliedsdauer(Member $member): string {
        try {
            $eintritt = new DateTime($member->getEintritt());
            $end = $member->getAustritt()
                ? new DateTime($member->getAustritt())
                : new DateTime();

            $diff = $eintritt->diff($end);
            $years = $diff->y;
            $months = $diff->m;

            if ($years > 0 && $months > 0) {
                return "{$years} Jahre, {$months} Monate";
            } elseif ($years > 0) {
                return "{$years} Jahre";
            } else {
                return "{$months} Monate";
            }
        } catch (\Exception $e) {
            return '–';
        }
    }

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

    /**
     * Add an address to a member.
     *
     * @throws Exception
     */
    public function addAddress(int $memberId, array $data): Address {
        $address = new Address();
        $address->setMemberId($memberId);
        $address->setLabel($data['label'] ?? null);
        $address->setStrasse(trim($data['strasse']));
        $address->setPlz(trim($data['plz']));
        $address->setOrt(trim($data['ort']));
        $address->setLand($data['land'] ?? 'Deutschland');
        $address->setIsPrimary($data['isPrimary'] ?? false);

        /** @var Address $address */
        return $this->addressMapper->insert($address);
    }

    /**
     * Update an existing address.
     *
     * @throws DoesNotExistException
     * @throws MultipleObjectsReturnedException
     * @throws Exception
     */
    public function updateAddress(int $addressId, array $data): Address {
        /** @var Address $address */
        $address = $this->addressMapper->find($addressId);

        if (isset($data['label'])) {
            $address->setLabel($data['label']);
        }
        if (isset($data['strasse'])) {
            $address->setStrasse(trim($data['strasse']));
        }
        if (isset($data['plz'])) {
            $address->setPlz(trim($data['plz']));
        }
        if (isset($data['ort'])) {
            $address->setOrt(trim($data['ort']));
        }
        if (isset($data['land'])) {
            $address->setLand($data['land']);
        }
        if (array_key_exists('isPrimary', $data)) {
            $address->setIsPrimary($data['isPrimary']);
        }

        /** @var Address $address */
        return $this->addressMapper->update($address);
    }

    /**
     * Delete an address.
     *
     * @throws DoesNotExistException
     * @throws MultipleObjectsReturnedException
     * @throws Exception
     */
    public function deleteAddress(int $addressId): void {
        $address = $this->addressMapper->find($addressId);
        $this->addressMapper->delete($address);
    }

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

    /**
     * Add a phone number to a member.
     * Validates and normalizes to E.164 format.
     *
     * @throws ValidationException
     * @throws Exception
     */
    public function addPhone(int $memberId, array $data): Phone {
        $raw = trim($data['numberE164'] ?? '');
        $normalized = PhoneValidator::validateAndNormalize($raw);
        if ($normalized === null) {
            throw new ValidationException(PhoneValidator::getErrorMessage($raw));
        }

        $phone = new Phone();
        $phone->setMemberId($memberId);
        $phone->setLabel($data['label'] ?? null);
        $phone->setNumberE164($normalized);

        /** @var Phone $phone */
        return $this->phoneMapper->insert($phone);
    }

    /**
     * Update an existing phone number.
     * Validates and normalizes to E.164 format if number is changed.
     *
     * @throws DoesNotExistException
     * @throws MultipleObjectsReturnedException
     * @throws ValidationException
     * @throws Exception
     */
    public function updatePhone(int $phoneId, array $data): Phone {
        /** @var Phone $phone */
        $phone = $this->phoneMapper->find($phoneId);

        if (isset($data['label'])) {
            $phone->setLabel($data['label']);
        }
        if (isset($data['numberE164'])) {
            $raw = trim($data['numberE164']);
            $normalized = PhoneValidator::validateAndNormalize($raw);
            if ($normalized === null) {
                throw new ValidationException(PhoneValidator::getErrorMessage($raw));
            }
            $phone->setNumberE164($normalized);
        }

        /** @var Phone $phone */
        return $this->phoneMapper->update($phone);
    }

    /**
     * Delete a phone number.
     *
     * @throws DoesNotExistException
     * @throws MultipleObjectsReturnedException
     * @throws Exception
     */
    public function deletePhone(int $phoneId): void {
        $phone = $this->phoneMapper->find($phoneId);
        $this->phoneMapper->delete($phone);
    }

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

    /**
     * Add an email address to a member.
     *
     * @throws Exception
     */
    public function addEmail(int $memberId, array $data): Email {
        $email = new Email();
        $email->setMemberId($memberId);
        $email->setLabel($data['label'] ?? null);
        $email->setEmail(trim($data['email']));

        /** @var Email $email */
        return $this->emailMapper->insert($email);
    }

    /**
     * Update an existing email address.
     *
     * @throws DoesNotExistException
     * @throws MultipleObjectsReturnedException
     * @throws Exception
     */
    public function updateEmail(int $emailId, array $data): Email {
        /** @var Email $email */
        $email = $this->emailMapper->find($emailId);

        if (isset($data['label'])) {
            $email->setLabel($data['label']);
        }
        if (isset($data['email'])) {
            $email->setEmail(trim($data['email']));
        }

        /** @var Email $email */
        return $this->emailMapper->update($email);
    }

    /**
     * Delete an email address.
     *
     * @throws DoesNotExistException
     * @throws MultipleObjectsReturnedException
     * @throws Exception
     */
    public function deleteEmail(int $emailId): void {
        $email = $this->emailMapper->find($emailId);
        $this->emailMapper->delete($email);
    }

    // ── Validation helpers ───────────────────────────────────────────

    /**
     * Validate that all required fields are present and non-empty.
     * Geburtsdatum is optional (Issue #162).
     *
     * @throws ValidationException
     */
    private function validateRequiredFields(array $data): void {
        $required = ['vorname', 'nachname', 'eintritt'];
        $missing = [];

        foreach ($required as $field) {
            if (!isset($data[$field]) || (is_string($data[$field]) && trim($data[$field]) === '')) {
                $missing[] = $field;
            }
        }

        if (!empty($missing)) {
            throw new ValidationException(
                'Missing required fields: ' . implode(', ', $missing)
            );
        }
    }

    /**
     * Validate date plausibility.
     *
     * - Birthday must not be in the future
     * - Birthday must not be more than 120 years ago
     * - Eintritt must not be before birthday
     *
     * @throws ValidationException
     */
    private function validateDates(array $data): void {
        $now = new DateTime();

        if (!empty($data['geburtsdatum'])) {
            $birthday = new DateTime($data['geburtsdatum']);

            if ($birthday > $now) {
                throw new ValidationException('Geburtsdatum darf nicht in der Zukunft liegen.');
            }

            $maxAge = (clone $now)->modify('-120 years');
            if ($birthday < $maxAge) {
                throw new ValidationException('Geburtsdatum ist unplausibel (mehr als 120 Jahre her).');
            }

            if (isset($data['eintritt'])) {
                $eintritt = new DateTime($data['eintritt']);
                if ($eintritt < $birthday) {
                    throw new ValidationException('Eintrittsdatum darf nicht vor dem Geburtsdatum liegen.');
                }
            }
        }
    }

    /**
     * Check for a duplicate member (matching vorname + nachname + geburtsdatum).
     * When geburtsdatum is missing, skip duplicate check (Issue #162).
     *
     * @throws DuplicateMemberException
     * @throws Exception
     */
    private function checkForDuplicate(array $data): void {
        // Skip duplicate check if birthday is missing — cannot reliably
        // detect duplicates with name alone
        if (empty($data['geburtsdatum'])) {
            return;
        }

        $existing = $this->memberMapper->search($data['nachname']);

        foreach ($existing as $member) {
            if (
                mb_strtolower($member->getVorname()) === mb_strtolower(trim($data['vorname'])) &&
                mb_strtolower($member->getNachname()) === mb_strtolower(trim($data['nachname'])) &&
                $member->getGeburtsdatum() === $data['geburtsdatum']
            ) {
                throw new DuplicateMemberException(
                    'Ein Mitglied mit gleichem Vor-/Nachname und Geburtsdatum existiert bereits (ID: ' . $member->getId() . ').'
                );
            }
        }
    }

    // ── Response builder ─────────────────────────────────────────────

    /**
     * Build a response array containing member data with sub-entities.
     */
    private function buildMemberResponse(Member $member, array $addresses, array $phones, array $emails): array {
        $result = $member->jsonSerialize();
        $result['addresses'] = array_map(fn(Address $a) => $a->jsonSerialize(), $addresses);
        $result['phones'] = array_map(fn(Phone $p) => $p->jsonSerialize(), $phones);
        $result['emails'] = array_map(fn(Email $e) => $e->jsonSerialize(), $emails);
        return $result;
    }

    /**
     * Save a batch of addresses for a member.
     *
     * @return Address[]
     * @throws Exception
     */
    private function saveAddresses(int $memberId, array $addressesData): array {
        $saved = [];
        foreach ($addressesData as $data) {
            $saved[] = $this->addAddress($memberId, $data);
        }
        return $saved;
    }

    /**
     * Save a batch of phone numbers for a member.
     *
     * @return Phone[]
     * @throws Exception
     */
    private function savePhones(int $memberId, array $phonesData): array {
        $saved = [];
        foreach ($phonesData as $data) {
            $saved[] = $this->addPhone($memberId, $data);
        }
        return $saved;
    }

    /**
     * Save a batch of email addresses for a member.
     *
     * @return Email[]
     * @throws Exception
     */
    private function saveEmails(int $memberId, array $emailsData): array {
        $saved = [];
        foreach ($emailsData as $data) {
            $saved[] = $this->addEmail($memberId, $data);
        }
        return $saved;
    }
}
