<?php

declare(strict_types=1);

namespace OCA\Mitgliederverwaltung\Db;

use OCA\Mitgliederverwaltung\Db\PlatformHelper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;

/**
 * Mapper for the oc_mv_members table.
 *
 * @extends QBMapper<Member>
 */
class MemberMapper extends QBMapper {

    public function __construct(IDBConnection $db) {
        parent::__construct($db, 'mv_members', Member::class);
    }

    /**
     * Find a single member by ID.
     *
     * @throws DoesNotExistException
     * @throws MultipleObjectsReturnedException
     * @throws Exception
     */
    public function findById(int $id): Member {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')
            ->from($this->getTableName())
            ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));

        return $this->findEntity($qb);
    }

    /**
     * Find all members with optional pagination.
     * Excludes soft-deleted members by default.
     *
     * @return Member[]
     * @throws Exception
     */
    public function findAll(?int $limit = null, ?int $offset = null, bool $includeDeleted = false): array {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')
            ->from($this->getTableName());

        if (!$includeDeleted) {
            $qb->andWhere($qb->expr()->isNull('deleted_at'));
        }

        $qb->orderBy('nachname', 'ASC')
            ->addOrderBy('vorname', 'ASC');

        if ($limit !== null) {
            $qb->setMaxResults($limit);
        }
        if ($offset !== null) {
            $qb->setFirstResult($offset);
        }

        return $this->findEntities($qb);
    }

    /**
     * Find all members belonging to a given family.
     *
     * @return Member[]
     * @throws Exception
     */
    public function findByFamily(int $familyId): array {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')
            ->from($this->getTableName())
            ->where($qb->expr()->eq('family_id', $qb->createNamedParameter($familyId, IQueryBuilder::PARAM_INT)))
            ->andWhere($qb->expr()->isNull('deleted_at'))
            ->orderBy('nachname', 'ASC')
            ->addOrderBy('vorname', 'ASC');

        return $this->findEntities($qb);
    }

    /**
     * Find all members with a given status.
     *
     * @return Member[]
     * @throws Exception
     */
    public function findByStatus(string $status): array {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')
            ->from($this->getTableName())
            ->where($qb->expr()->eq('status', $qb->createNamedParameter($status)))
            ->orderBy('nachname', 'ASC')
            ->addOrderBy('vorname', 'ASC');

        return $this->findEntities($qb);
    }

    /**
     * Find all members in a given Stufe.
     *
     * @return Member[]
     * @throws Exception
     */
    public function findByStufe(int $stufeId): array {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')
            ->from($this->getTableName())
            ->where($qb->expr()->eq('stufe_id', $qb->createNamedParameter($stufeId, IQueryBuilder::PARAM_INT)))
            ->andWhere($qb->expr()->isNull('deleted_at'))
            ->orderBy('nachname', 'ASC')
            ->addOrderBy('vorname', 'ASC');

        return $this->findEntities($qb);
    }

    /**
     * Search members by name (Vorname or Nachname, case-insensitive).
     *
     * @return Member[]
     * @throws Exception
     */
    public function search(string $query): array {
        $qb = $this->db->getQueryBuilder();
        $searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';

        $qb->select('*')
            ->from($this->getTableName())
            ->where($qb->expr()->isNull('deleted_at'))
            ->andWhere(
                $qb->expr()->orX(
                    $qb->expr()->iLike('vorname', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('nachname', $qb->createNamedParameter($searchPattern))
                )
            )
            ->orderBy('nachname', 'ASC')
            ->addOrderBy('vorname', 'ASC');

        return $this->findEntities($qb);
    }

    /**
     * Find members with a birthday in the given month.
     * Uses platform-aware month extraction instead of LIKE on date columns.
     *
     * Part of Issue #34, updated for Issue #192 (database portability).
     *
     * @return Member[]
     * @throws Exception
     */
    public function findByBirthdayMonth(int $month): array {
        $qb = $this->db->getQueryBuilder();
        $monthExpr = PlatformHelper::getMonthExpression($this->db, 'geburtsdatum');

        $qb->select('*')
            ->from($this->getTableName())
            ->where($qb->expr()->isNull('deleted_at'))
            ->andWhere(
                $qb->expr()->eq(
                    $qb->createFunction($monthExpr),
                    $qb->createNamedParameter($month, IQueryBuilder::PARAM_INT)
                )
            )
            ->orderBy('geburtsdatum', 'ASC');

        return $this->findEntities($qb);
    }

    /**
     * Find members who have unpaid fee records for a given year.
     * Uses a subquery join on mv_fee_records.
     *
     * Part of Issue #34.
     *
     * @return Member[]
     * @throws Exception
     */
    public function findWithUnpaidFees(int $year): array {
        $qb = $this->db->getQueryBuilder();

        $qb->select('m.*')
            ->from($this->getTableName(), 'm')
            ->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id'))
            ->where($qb->expr()->isNull('m.deleted_at'))
            ->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
            ->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)))
            ->orderBy('m.nachname', 'ASC')
            ->addOrderBy('m.vorname', 'ASC');

        return $this->findEntities($qb);
    }

    /**
     * Full-text search across members, addresses, and notes.
     * Searches: vorname, nachname, notizen, zusatz_notizen, and joined address fields.
     *
     * Part of Issue #33.
     *
     * @return Member[]
     * @throws Exception
     */
    public function fullTextSearch(string $query, int $limit = 20): array {
        $qb = $this->db->getQueryBuilder();
        $searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';

        $qb->selectDistinct('m.*')
            ->from($this->getTableName(), 'm')
            ->leftJoin('m', 'mv_addresses', 'a', $qb->expr()->eq('m.id', 'a.member_id'))
            ->where($qb->expr()->isNull('m.deleted_at'))
            ->andWhere(
                $qb->expr()->orX(
                    $qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('m.notizen', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('m.zusatz_notizen', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('a.strasse', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('a.plz', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('a.ort', $qb->createNamedParameter($searchPattern))
                )
            )
            ->orderBy('m.nachname', 'ASC')
            ->addOrderBy('m.vorname', 'ASC')
            ->setMaxResults($limit);

        return $this->findEntities($qb);
    }

    /**
     * Full-text search across members, addresses, and notes with all
     * sub-entities in a single query.
     *
     * Searches: vorname, nachname, notizen, zusatz_notizen, and joined
     * address fields. Returns members with nested addresses, phones, and
     * emails.
     *
     * Part of Issue #33.
     *
     * @return array<int, array> Flat member arrays with nested sub-entities
     * @throws Exception
     */
    public function fullTextSearchWithRelations(string $query, int $limit = 20): array {
        $qb = $this->db->getQueryBuilder();
        $searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';

        $qb->select('m.*')
            ->from($this->getTableName(), 'm');

        $this->addJoinClauses($qb);

        $qb->where($qb->expr()->isNull('m.deleted_at'))
            ->andWhere(
                $qb->expr()->orX(
                    $qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('m.notizen', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('m.zusatz_notizen', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('a.strasse', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('a.plz', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('a.ort', $qb->createNamedParameter($searchPattern))
                )
            )
            ->orderBy('m.nachname', 'ASC')
            ->addOrderBy('m.vorname', 'ASC')
            ->setMaxResults($limit);

        return $this->fetchWithRelations($qb);
    }

    /**
     * Find a member by exact Vorname, Nachname, and Geburtsdatum.
     * Used for duplicate detection during import.
     *
     * Part of Issue #58.
     *
     * @return Member|null The matching member, or null if not found
     * @throws Exception
     */
    public function findByNameAndBirthdate(string $vorname, string $nachname, string $geburtsdatum): ?Member {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')
            ->from($this->getTableName())
            ->where($qb->expr()->eq('vorname', $qb->createNamedParameter($vorname)))
            ->andWhere($qb->expr()->eq('nachname', $qb->createNamedParameter($nachname)))
            ->andWhere($qb->expr()->eq('geburtsdatum', $qb->createNamedParameter($geburtsdatum)))
            ->andWhere($qb->expr()->isNull('deleted_at'))
            ->setMaxResults(1);

        try {
            return $this->findEntity($qb);
        } catch (DoesNotExistException $e) {
            return null;
        }
    }

    /**
     * Find members matching a combination of filters.
     * All filter parameters are optional and additive (AND logic).
     *
     * @param string|null $status Filter by status (aktiv/inaktiv)
     * @param string|null $rolle Filter by rolle (mitglied/erziehungsberechtigter)
     * @param bool $birthdayThisMonth Only members with birthday in current month
     * @param bool $unpaidFees Only members with unpaid fee records for current year
     * @return Member[]
     * @throws Exception
     */
    public function findFiltered(
        ?string $status = null,
        ?string $rolle = null,
        bool $birthdayThisMonth = false,
        bool $unpaidFees = false
    ): array {
        $qb = $this->db->getQueryBuilder();

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

        if ($unpaidFees) {
            $year = (int)(new \DateTime())->format('Y');
            $qb->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id'))
                ->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT)))
                ->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)));
        }

        if ($status !== null) {
            $qb->andWhere($qb->expr()->eq('m.status', $qb->createNamedParameter($status)));
        }

        if ($rolle !== null) {
            $qb->andWhere($qb->expr()->eq('m.rolle', $qb->createNamedParameter($rolle)));
        }

        if ($birthdayThisMonth) {
            $currentMonth = (int)(new \DateTime())->format('m');
            $monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum');
            $qb->andWhere(
                $qb->expr()->eq(
                    $qb->createFunction($monthExpr),
                    $qb->createNamedParameter($currentMonth, IQueryBuilder::PARAM_INT)
                )
            );
        }

        $qb->orderBy('m.nachname', 'ASC')
            ->addOrderBy('m.vorname', 'ASC');

        return $this->findEntities($qb);
    }

    /**
     * Count all non-deleted members.
     *
     * @throws Exception
     */
    public function countAll(): int {
        $qb = $this->db->getQueryBuilder();
        $qb->select($qb->createFunction('COUNT(*)'))
            ->from($this->getTableName())
            ->where($qb->expr()->isNull('deleted_at'));

        $result = $qb->executeQuery();
        $count = (int)$result->fetchOne();
        $result->closeCursor();

        return $count;
    }

    /**
     * Count all soft-deleted (archived) members.
     *
     * Uses a single SQL COUNT query instead of loading all members
     * into memory.
     *
     * Part of Issue #201.
     *
     * @throws Exception
     */
    public function countArchived(): int {
        $qb = $this->db->getQueryBuilder();
        $qb->select($qb->createFunction('COUNT(*)'))
            ->from($this->getTableName())
            ->where($qb->expr()->isNotNull('deleted_at'));

        $result = $qb->executeQuery();
        $count = (int)$result->fetchOne();
        $result->closeCursor();

        return $count;
    }

    // ── Joined fetch methods (N+1 avoidance) ────────────────────────

    /**
     * Find all members with their addresses, phones, and emails in a
     * single query using LEFT JOINs.
     *
     * Returns an array of associative arrays, each representing one
     * member with nested 'addresses', 'phones', and 'emails' arrays.
     *
     * This replaces the N+1 pattern (1 query for members + 3 queries
     * per member for sub-entities) with a single query regardless of
     * the number of members.
     *
     * @param int|null $limit Pagination limit
     * @param int|null $offset Pagination offset
     * @return array<int, array> Flat member arrays with nested sub-entities
     * @throws Exception
     */
    public function findAllWithRelations(?int $limit = null, ?int $offset = null): array {
        return $this->fetchWithRelations(
            $this->buildBaseQuery(false, $limit, $offset)
        );
    }

    /**
     * Find members by family ID with all sub-entities in a single query.
     *
     * @param int $familyId Family ID
     * @return array<int, array> Flat member arrays with nested sub-entities
     * @throws Exception
     */
    public function findByFamilyWithRelations(int $familyId): array {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')
            ->from($this->getTableName(), 'm');

        $this->addJoinClauses($qb);

        $qb->where($qb->expr()->eq('m.family_id', $qb->createNamedParameter($familyId, IQueryBuilder::PARAM_INT)))
            ->andWhere($qb->expr()->isNull('m.deleted_at'))
            ->orderBy('m.nachname', 'ASC')
            ->addOrderBy('m.vorname', 'ASC');

        return $this->fetchWithRelations($qb);
    }

    /**
     * Find members by status with all sub-entities in a single query.
     *
     * @param string $status Status to filter by
     * @return array<int, array> Flat member arrays with nested sub-entities
     * @throws Exception
     */
    public function findByStatusWithRelations(string $status): array {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')
            ->from($this->getTableName(), 'm');

        $this->addJoinClauses($qb);

        $qb->where($qb->expr()->eq('m.status', $qb->createNamedParameter($status)))
            ->orderBy('m.nachname', 'ASC')
            ->addOrderBy('m.vorname', 'ASC');

        return $this->fetchWithRelations($qb);
    }

    /**
     * Search members by name with all sub-entities in a single query.
     *
     * @param string $query Search query (searches Vorname and Nachname)
     * @return array<int, array> Flat member arrays with nested sub-entities
     * @throws Exception
     */
    public function searchWithRelations(string $query): array {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')
            ->from($this->getTableName(), 'm');

        $this->addJoinClauses($qb);

        $searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
        $qb->where($qb->expr()->isNull('m.deleted_at'))
            ->andWhere(
                $qb->expr()->orX(
                    $qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
                    $qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern))
                )
            )
            ->orderBy('m.nachname', 'ASC')
            ->addOrderBy('m.vorname', 'ASC');

        return $this->fetchWithRelations($qb);
    }

    /**
     * Find members with a birthday in the given month with all
     * sub-entities in a single query.
     *
     * @param int $month Month (1-12)
     * @return array<int, array> Flat member arrays with nested sub-entities
     * @throws Exception
     */
    public function findByBirthdayMonthWithRelations(int $month): array {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')
            ->from($this->getTableName(), 'm');

        $this->addJoinClauses($qb);

        $monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum');
        $qb->where($qb->expr()->isNull('m.deleted_at'))
            ->andWhere(
                $qb->expr()->eq(
                    $qb->createFunction($monthExpr),
                    $qb->createNamedParameter($month, IQueryBuilder::PARAM_INT)
                )
            )
            ->orderBy('m.geburtsdatum', 'ASC');

        return $this->fetchWithRelations($qb);
    }

    /**
     * Find members who have unpaid fee records for a given year with
     * all sub-entities in a single query.
     *
     * @param int $year Year to filter by
     * @return array<int, array> Flat member arrays with nested sub-entities
     * @throws Exception
     */
    public function findWithUnpaidFeesWithRelations(int $year): array {
        $qb = $this->db->getQueryBuilder();
        $qb->select('m.*')
            ->from($this->getTableName(), 'm')
            ->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id'));

        $this->addJoinClauses($qb);

        $qb->where($qb->expr()->isNull('m.deleted_at'))
            ->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT)))
            ->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
            ->orderBy('m.nachname', 'ASC')
            ->addOrderBy('m.vorname', 'ASC');

        return $this->fetchWithRelations($qb);
    }

    /**
     * Find members matching a combination of filters with all
     * sub-entities in a single query.
     *
     * @param string|null $status Filter by status (aktiv/inaktiv)
     * @param string|null $rolle Filter by rolle (mitglied/erziehungsberechtigter)
     * @param bool $birthdayThisMonth Only members with birthday in current month
     * @param bool $unpaidFees Only members with unpaid fee records for current year
     * @return array<int, array> Flat member arrays with nested sub-entities
     * @throws Exception
     */
    public function findFilteredWithRelations(
        ?string $status = null,
        ?string $rolle = null,
        bool $birthdayThisMonth = false,
        bool $unpaidFees = false
    ): array {
        $qb = $this->db->getQueryBuilder();
        $qb->select('m.*')
            ->from($this->getTableName(), 'm');

        $this->addJoinClauses($qb);

        $qb->where($qb->expr()->isNull('m.deleted_at'));

        if ($unpaidFees) {
            $year = (int)(new \DateTime())->format('Y');
            $qb->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id'))
                ->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT)))
                ->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)));
        }

        if ($status !== null) {
            $qb->andWhere($qb->expr()->eq('m.status', $qb->createNamedParameter($status)));
        }

        if ($rolle !== null) {
            $qb->andWhere($qb->expr()->eq('m.rolle', $qb->createNamedParameter($rolle)));
        }

        if ($birthdayThisMonth) {
            $currentMonth = (int)(new \DateTime())->format('m');
            $monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum');
            $qb->andWhere(
                $qb->expr()->eq(
                    $qb->createFunction($monthExpr),
                    $qb->createNamedParameter($currentMonth, IQueryBuilder::PARAM_INT)
                )
            );
        }

        $qb->orderBy('m.nachname', 'ASC')
            ->addOrderBy('m.vorname', 'ASC');

        return $this->fetchWithRelations($qb);
    }

    /**
     * Build the base query for findAllWithRelations.
     */
    private function buildBaseQuery(
        bool $includeDeleted,
        ?int $limit,
        ?int $offset
    ): \OCP\DB\QueryBuilder\IQueryBuilder {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')
            ->from($this->getTableName(), 'm');

        $this->addJoinClauses($qb);

        if (!$includeDeleted) {
            $qb->where($qb->expr()->isNull('m.deleted_at'));
        }

        $qb->orderBy('m.nachname', 'ASC')
            ->addOrderBy('m.vorname', 'ASC');

        if ($limit !== null) {
            $qb->setMaxResults($limit);
        }
        if ($offset !== null) {
            $qb->setFirstResult($offset);
        }

        return $qb;
    }

    /**
     * Add the LEFT JOIN clauses for addresses, phones, and emails
     * to the given query builder.
     */
    private function addJoinClauses(\OCP\DB\QueryBuilder\IQueryBuilder $qb): void {
        $qb->leftJoin('m', 'mv_addresses', 'a', $qb->expr()->eq('m.id', 'a.member_id'))
            ->leftJoin('m', 'mv_phones', 'p', $qb->expr()->eq('m.id', 'p.member_id'))
            ->leftJoin('m', 'mv_emails', 'e', $qb->expr()->eq('m.id', 'e.member_id'));
    }

    /**
     * Execute a query with joined sub-entities and parse the flat
     * result set into nested member arrays.
     *
     * The query must SELECT all columns from the members table plus
     * columns from mv_addresses, mv_phones, and mv_emails.  Column
     * name collision is handled by prefixing joined columns with
     * aliases (a.*, p.*, e.*) which Doctrine maps as
     * a_strasse, p_number_e164, etc.
     *
     * @throws Exception
     */
    private function fetchWithRelations(\OCP\DB\QueryBuilder\IQueryBuilder $qb): array {
        $result = $qb->executeQuery();

        /** @var array<int, array> $grouped */
        $grouped = [];

        while ($row = $result->fetch()) {
            $id = (int)$row['id'];
            if (!isset($grouped[$id])) {
                // Build the base member record from the member columns.
                $grouped[$id] = [
                    'id' => $id,
                    'vorname' => $row['vorname'] ?? '',
                    'nachname' => $row['nachname'] ?? '',
                    'geburtsdatum' => $row['geburtsdatum'] ?? null,
                    'geschlecht' => $row['geschlecht'] ?? null,
                    'rolle' => $row['rolle'] ?? 'mitglied',
                    'stufeId' => isset($row['stufe_id']) ? (int)$row['stufe_id'] : null,
                    'eintritt' => $row['eintritt'] ?? '',
                    'austritt' => $row['austritt'] ?? null,
                    'status' => $row['status'] ?? 'aktiv',
                    'allergienEncrypted' => $row['allergien_encrypted'] ?? null,
                    'notizen' => $row['notizen'] ?? null,
                    'zusatzNotizen' => $row['zusatz_notizen'] ?? null,
                    'kvTyp' => $row['kv_typ'] ?? null,
                    'kvName' => $row['kv_name'] ?? null,
                    'familyId' => isset($row['family_id']) ? (int)$row['family_id'] : null,
                    'frozenFeeRate' => $row['frozen_fee_rate'] ?? null,
                    'calendarEventUri' => $row['calendar_event_uri'] ?? null,
                    'contactVcardUri' => $row['contact_vcard_uri'] ?? null,
                    'createdAt' => $row['created_at'] ?? '',
                    'updatedAt' => $row['updated_at'] ?? '',
                    'deletedAt' => $row['deleted_at'] ?? null,
                    'einwilligungDatum' => $row['einwilligung_datum'] ?? null,
                    'juleicaNummer' => $row['juleica_nummer'] ?? null,
                    'juleicaAblaufdatum' => $row['juleica_ablaufdatum'] ?? null,
                    'addresses' => [],
                    'phones' => [],
                    'emails' => [],
                ];
            }

            // Parse sub-entities from the joined columns.
            // A row with NULLs for all address columns means no address
            // is joined for this row — we must not create an empty Address.
            $addrId = $row['a_id'] ?? null;
            if ($addrId !== null && $addrId !== '') {
                $grouped[$id]['addresses'][] = [
                    'id' => (int)$addrId,
                    'memberId' => (int)$row['a_member_id'],
                    'label' => $row['a_label'] ?? null,
                    'strasse' => $row['a_strasse'] ?? '',
                    'plz' => $row['a_plz'] ?? '',
                    'ort' => $row['a_ort'] ?? '',
                    'land' => $row['a_land'] ?? 'Deutschland',
                    'isPrimary' => (bool)$row['a_is_primary'],
                ];
            }

            $phoneId = $row['p_id'] ?? null;
            if ($phoneId !== null && $phoneId !== '') {
                $grouped[$id]['phones'][] = [
                    'id' => (int)$phoneId,
                    'memberId' => (int)$row['p_member_id'],
                    'label' => $row['p_label'] ?? null,
                    'numberE164' => $row['p_number_e164'] ?? '',
                ];
            }

            $emailId = $row['e_id'] ?? null;
            if ($emailId !== null && $emailId !== '') {
                $grouped[$id]['emails'][] = [
                    'id' => (int)$emailId,
                    'memberId' => (int)$row['e_member_id'],
                    'label' => $row['e_label'] ?? null,
                    'email' => $row['e_email'] ?? '',
                ];
            }
        }

        $result->closeCursor();

        // Re-index to a sequential 0-based array while preserving order.
        return array_values($grouped);
    }
}
