<?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\Family;
use OCA\Mitgliederverwaltung\Db\FamilyMapper;
use OCA\Mitgliederverwaltung\Db\FeeRecord;
use OCA\Mitgliederverwaltung\Db\FeeRecordMapper;
use OCA\Mitgliederverwaltung\Db\FeeRule;
use OCA\Mitgliederverwaltung\Db\FeeRuleMapper;
use OCA\Mitgliederverwaltung\Db\Injury;
use OCA\Mitgliederverwaltung\Db\InjuryMapper;
use OCA\Mitgliederverwaltung\Db\Lager;
use OCA\Mitgliederverwaltung\Db\LagerMapper;
use OCA\Mitgliederverwaltung\Db\LagerTeilnehmer;
use OCA\Mitgliederverwaltung\Db\LagerTeilnehmerMapper;
use OCA\Mitgliederverwaltung\Db\Member;
use OCA\Mitgliederverwaltung\Db\MemberMapper;
use OCA\Mitgliederverwaltung\Db\Phone;
use OCA\Mitgliederverwaltung\Db\PhoneMapper;
use OCA\Mitgliederverwaltung\Db\SavedQuery;
use OCA\Mitgliederverwaltung\Db\SavedQueryMapper;
use OCA\Mitgliederverwaltung\Db\Stufe;
use OCA\Mitgliederverwaltung\Db\StufeHistory;
use OCA\Mitgliederverwaltung\Db\StufeHistoryMapper;
use OCA\Mitgliederverwaltung\Db\StufeMapper;
use Psr\Log\LoggerInterface;

/**
 * Generic per-entity CSV import service.
 *
 * Supports importing any entity type using the column definitions
 * from EntityExportService for German header auto-mapping.
 * Foreign-key columns (resolved names) are matched to existing records.
 * Duplicates are detected and skipped.
 *
 * Part of Issue #150.
 */
class EntityImportService {

    private MemberMapper $memberMapper;
    private FamilyMapper $familyMapper;
    private AddressMapper $addressMapper;
    private PhoneMapper $phoneMapper;
    private EmailMapper $emailMapper;
    private StufeMapper $stufeMapper;
    private StufeHistoryMapper $stufeHistoryMapper;
    private FeeRuleMapper $feeRuleMapper;
    private FeeRecordMapper $feeRecordMapper;
    private LagerMapper $lagerMapper;
    private LagerTeilnehmerMapper $lagerTeilnehmerMapper;
    private InjuryMapper $injuryMapper;
    private SavedQueryMapper $savedQueryMapper;
    private EntityExportService $exportService;
    private AuditService $auditService;
    private EncryptionService $encryptionService;
    private LoggerInterface $logger;

    /** Lookup caches for FK resolution */
    private ?array $stufeByName = null;
    private ?array $familyByName = null;
    private ?array $memberByName = null;
    private ?array $lagerByName = null;

    public function __construct(
        MemberMapper $memberMapper,
        FamilyMapper $familyMapper,
        AddressMapper $addressMapper,
        PhoneMapper $phoneMapper,
        EmailMapper $emailMapper,
        StufeMapper $stufeMapper,
        StufeHistoryMapper $stufeHistoryMapper,
        FeeRuleMapper $feeRuleMapper,
        FeeRecordMapper $feeRecordMapper,
        LagerMapper $lagerMapper,
        LagerTeilnehmerMapper $lagerTeilnehmerMapper,
        InjuryMapper $injuryMapper,
        SavedQueryMapper $savedQueryMapper,
        EntityExportService $exportService,
        AuditService $auditService,
        EncryptionService $encryptionService,
        LoggerInterface $logger
    ) {
        $this->memberMapper = $memberMapper;
        $this->familyMapper = $familyMapper;
        $this->addressMapper = $addressMapper;
        $this->phoneMapper = $phoneMapper;
        $this->emailMapper = $emailMapper;
        $this->stufeMapper = $stufeMapper;
        $this->stufeHistoryMapper = $stufeHistoryMapper;
        $this->feeRuleMapper = $feeRuleMapper;
        $this->feeRecordMapper = $feeRecordMapper;
        $this->lagerMapper = $lagerMapper;
        $this->lagerTeilnehmerMapper = $lagerTeilnehmerMapper;
        $this->injuryMapper = $injuryMapper;
        $this->savedQueryMapper = $savedQueryMapper;
        $this->exportService = $exportService;
        $this->auditService = $auditService;
        $this->encryptionService = $encryptionService;
        $this->logger = $logger;
    }

    // ── Auto-detection ─────────────────────────────────────────────

    /**
     * Detect entity type from CSV headers by matching against known schemas.
     *
     * @param string[] $headers CSV column headers
     * @return array{type: string|null, confidence: float, alternatives: array}
     */
    public function detectEntityType(array $headers): array {
        $normalizedHeaders = array_map(fn($h) => strtolower(trim($h)), $headers);
        $bestType = null;
        $bestScore = 0;
        $alternatives = [];

        foreach (EntityExportService::ENTITY_TYPES as $type => $meta) {
            $schemaHeaders = array_map('strtolower', $this->exportService->getColumnHeaders($type));
            $matchCount = count(array_intersect($normalizedHeaders, $schemaHeaders));
            $totalSchema = count($schemaHeaders);
            $score = $totalSchema > 0 ? $matchCount / $totalSchema : 0;

            if ($score > 0.3) {
                $alternatives[] = [
                    'type' => $type,
                    'label' => $meta['label'],
                    'score' => round($score, 2),
                ];
            }

            if ($score > $bestScore) {
                $bestScore = $score;
                $bestType = $type;
            }
        }

        usort($alternatives, fn($a, $b) => $b['score'] <=> $a['score']);

        return [
            'type' => $bestScore >= 0.4 ? $bestType : null,
            'confidence' => round($bestScore, 2),
            'alternatives' => $alternatives,
        ];
    }

    /**
     * Get column mapping for a given entity type.
     * Returns German header -> field index mapping derived from the export schema.
     *
     * @return array{headers: string[], mapping: array, targetFields: array}
     */
    public function getImportSchema(string $type): array {
        $headers = $this->exportService->getColumnHeaders($type);
        $fieldDefs = $this->getFieldDefinitions($type);

        $targetFields = [];
        foreach ($fieldDefs as $field) {
            $targetFields[] = [
                'key' => $field['key'],
                'label' => $field['label'],
                'required' => $field['required'] ?? false,
            ];
        }

        return [
            'headers' => $headers,
            'targetFields' => $targetFields,
        ];
    }

    /**
     * Auto-map CSV headers to target fields for a given entity type.
     *
     * @param string[] $csvHeaders
     * @return array Mapping { csvIndex => targetFieldKey }
     */
    public function autoMapHeaders(string $type, array $csvHeaders): array {
        $schemaHeaders = $this->exportService->getColumnHeaders($type);
        $fieldDefs = $this->getFieldDefinitions($type);
        $mapping = [];

        $normalizedCsv = array_map(fn($h) => strtolower(trim($h)), $csvHeaders);
        $normalizedSchema = array_map('strtolower', $schemaHeaders);

        foreach ($normalizedCsv as $csvIdx => $csvHeader) {
            $schemaIdx = array_search($csvHeader, $normalizedSchema);
            if ($schemaIdx !== false && isset($fieldDefs[$schemaIdx])) {
                $mapping[$csvIdx] = $fieldDefs[$schemaIdx]['key'];
            } else {
                $mapping[$csvIdx] = '';
            }
        }

        return $mapping;
    }

    // ── Preview / Dry-run ──────────────────────────────────────────

    /**
     * Run import in dry-run mode for any entity type.
     *
     * @param bool $migrationMode When true, use content-based matching instead of ID-based
     * @return array{valid: array[], errors: array[], duplicates: array[], summary: array, fkWarnings: array[], ambiguous: array[]}
     */
    public function preview(
        string $type,
        string $content,
        array $mapping,
        string $delimiter = ';',
        string $encoding = 'UTF-8',
        bool $migrationMode = false,
        array $corrections = []
    ): array {
        $this->resetCaches();
        $allRows = $this->parseAllRows($content, $delimiter, $encoding);

        $valid = [];
        $errors = [];
        $duplicates = [];
        $fkWarnings = [];
        $ambiguous = [];

        foreach ($allRows as $rowIndex => $row) {
            $mapped = $this->applyMapping($row, $mapping);
            $rowNum = $rowIndex + 2; // +2 for header + 0-index

            // Apply user corrections if present for this row
            if (isset($corrections[$rowNum])) {
                foreach ($corrections[$rowNum] as $fieldKey => $value) {
                    $mapped[$fieldKey] = $value;
                }
            }

            // Validate
            $rowErrors = $this->validateRow($type, $mapped, $rowNum);

            // FK resolution: use content-based in migration mode
            if ($migrationMode) {
                $fkResult = $this->resolveFKByContent($type, $mapped);
            } else {
                $fkResult = $this->resolveFK($type, $mapped);
            }
            if (!empty($fkResult['warnings'])) {
                foreach ($fkResult['warnings'] as $w) {
                    $fkWarnings[] = ['row' => $rowNum, 'message' => $w];
                }
            }
            if (!empty($fkResult['ambiguous'])) {
                foreach ($fkResult['ambiguous'] as $a) {
                    $ambiguous[] = ['row' => $rowNum, 'message' => $a];
                }
            }

            // In migration mode, always use content-based duplicate detection
            $dupResult = $this->detectDuplicateWithExisting($type, $mapped);

            if ($dupResult !== false) {
                $mapped['_rowIndex'] = $rowNum;
                $mapped['_reason'] = $dupResult['reason'];
                $mapped['_existingId'] = $dupResult['existingId'];
                $mapped['_existingData'] = $dupResult['existingData'];
                $duplicates[] = $mapped;
            } elseif (!empty($rowErrors)) {
                $mapped['_rowIndex'] = $rowNum;
                $mapped['_errors'] = $rowErrors;
                $errors[] = $mapped;
            } else {
                $mapped['_rowIndex'] = $rowNum;
                $valid[] = $mapped;
            }
        }

        return [
            'valid' => $valid,
            'errors' => $errors,
            'duplicates' => $duplicates,
            'fkWarnings' => $fkWarnings,
            'ambiguous' => $ambiguous,
            'summary' => [
                'total' => count($allRows),
                'toCreate' => count($valid),
                'duplicates' => count($duplicates),
                'errors' => count($errors),
            ],
        ];
    }

    // ── Execute import ─────────────────────────────────────────────

    /**
     * Execute import for any entity type.
     *
     * @param bool $migrationMode When true, use content-based FK resolution
     * @return array{created: int, skipped: int, errors: int, details: array[]}
     */
    public function execute(
        string $type,
        string $content,
        array $mapping,
        string $delimiter = ';',
        string $encoding = 'UTF-8',
        bool $migrationMode = false,
        array $corrections = []
    ): array {
        $this->resetCaches();
        $allRows = $this->parseAllRows($content, $delimiter, $encoding);

        $created = 0;
        $skipped = 0;
        $errorCount = 0;
        $details = [];
        $now = (new DateTime())->format('Y-m-d H:i:s');

        foreach ($allRows as $rowIndex => $row) {
            $mapped = $this->applyMapping($row, $mapping);
            $rowNum = $rowIndex + 2;

            // Apply user corrections if present for this row
            if (isset($corrections[$rowNum])) {
                foreach ($corrections[$rowNum] as $fieldKey => $value) {
                    $mapped[$fieldKey] = $value;
                }
            }

            $rowErrors = $this->validateRow($type, $mapped, $rowNum);
            if (!empty($rowErrors)) {
                $errorCount++;
                $details[] = ['row' => $rowNum, 'status' => 'error', 'errors' => $rowErrors];
                continue;
            }

            $isDuplicate = $this->detectDuplicate($type, $mapped);
            if ($isDuplicate) {
                $skipped++;
                $details[] = ['row' => $rowNum, 'status' => 'skipped', 'reason' => $isDuplicate];
                continue;
            }

            try {
                $fkResult = $migrationMode
                    ? $this->resolveFKByContent($type, $mapped)
                    : $this->resolveFK($type, $mapped);
                $resolved = $fkResult['resolved'];

                $entity = $this->createEntity($type, $resolved, $now);
                $created++;
                $details[] = ['row' => $rowNum, 'status' => 'created', 'id' => $entity->getId()];
            } catch (\Exception $e) {
                $errorCount++;
                $details[] = ['row' => $rowNum, 'status' => 'error', 'errors' => [$e->getMessage()]];
                $this->logger->error('Entity import row failed', [
                    'type' => $type,
                    'row' => $rowNum,
                    'exception' => $e,
                    'app' => 'mitgliederverwaltung',
                ]);
            }
        }

        $this->logger->info('Entity import completed', [
            'type' => $type,
            'created' => $created,
            'skipped' => $skipped,
            'errors' => $errorCount,
            'app' => 'mitgliederverwaltung',
        ]);

        return [
            'created' => $created,
            'skipped' => $skipped,
            'errors' => $errorCount,
            'details' => $details,
        ];
    }

    // ── Conflict resolution (Issue #152) ──────────────────────────

    /**
     * Apply per-field merge resolutions for duplicate records.
     *
     * @param string $type Entity type
     * @param array $resolutions Array of resolved conflicts, each:
     *   {existingId: int, mergedFields: {fieldKey: value}}
     * @return array{updated: int, errors: int, details: array[]}
     */
    public function mergeConflicts(string $type, array $resolutions): array {
        $updated = 0;
        $errorCount = 0;
        $details = [];
        $now = (new \DateTime())->format('Y-m-d H:i:s');

        foreach ($resolutions as $resolution) {
            $existingId = (int)($resolution['existingId'] ?? 0);
            $mergedFields = $resolution['mergedFields'] ?? [];

            if ($existingId === 0 || empty($mergedFields)) {
                $errorCount++;
                $details[] = ['id' => $existingId, 'status' => 'error', 'errors' => ['Ungueltige Konfliktloesung']];
                continue;
            }

            try {
                $this->updateEntity($type, $existingId, $mergedFields, $now);
                $updated++;
                $details[] = ['id' => $existingId, 'status' => 'updated'];
            } catch (\Exception $e) {
                $errorCount++;
                $details[] = ['id' => $existingId, 'status' => 'error', 'errors' => [$e->getMessage()]];
                $this->logger->error('Conflict merge failed', [
                    'type' => $type,
                    'id' => $existingId,
                    'exception' => $e,
                    'app' => 'mitgliederverwaltung',
                ]);
            }
        }

        return ['updated' => $updated, 'errors' => $errorCount, 'details' => $details];
    }

    /**
     * Update an existing entity with merged field values.
     */
    private function updateEntity(string $type, int $id, array $fields, string $now): void {
        switch ($type) {
            case 'mitglieder':
                $entity = $this->memberMapper->findById($id);
                if (isset($fields['vorname'])) $entity->setVorname($fields['vorname']);
                if (isset($fields['nachname'])) $entity->setNachname($fields['nachname']);
                if (isset($fields['geburtsdatum'])) $entity->setGeburtsdatum($this->normalizeDate($fields['geburtsdatum']));
                if (isset($fields['geschlecht'])) $entity->setGeschlecht($fields['geschlecht']);
                if (isset($fields['rolle'])) $entity->setRolle($fields['rolle']);
                if (isset($fields['eintritt'])) $entity->setEintritt($this->normalizeDate($fields['eintritt']));
                if (isset($fields['austritt'])) $entity->setAustritt($fields['austritt'] ? $this->normalizeDate($fields['austritt']) : null);
                if (isset($fields['status'])) $entity->setStatus($fields['status']);
                if (isset($fields['notizen'])) $entity->setNotizen($fields['notizen']);
                if (isset($fields['zusatz_notizen'])) $entity->setZusatzNotizen($fields['zusatz_notizen']);
                if (isset($fields['kv_typ'])) $entity->setKvTyp($fields['kv_typ']);
                if (isset($fields['kv_name'])) $entity->setKvName($fields['kv_name']);
                if (isset($fields['allergien']) && $fields['allergien'] !== '[verschluesselt]') {
                    $entity->setAllergienEncrypted($fields['allergien'] ? $this->encryptionService->encrypt($fields['allergien']) : null);
                }
                if (isset($fields['juleica_nummer'])) $entity->setJuleicaNummer($fields['juleica_nummer'] ?: null);
                if (isset($fields['juleica_ablaufdatum'])) $entity->setJuleicaAblaufdatum($fields['juleica_ablaufdatum'] ? $this->normalizeDate($fields['juleica_ablaufdatum']) : null);
                $entity->setUpdatedAt($now);
                $this->memberMapper->update($entity);
                $this->auditService->logUpdate($entity->jsonSerialize(), 'member', $id);
                break;

            case 'familien':
                $entity = $this->familyMapper->findById($id);
                if (isset($fields['name'])) $entity->setName($fields['name']);
                if (isset($fields['bic'])) $entity->setBic($fields['bic']);
                if (isset($fields['kreditinstitut'])) $entity->setKreditinstitut($fields['kreditinstitut']);
                if (isset($fields['kontoinhaber']) && $fields['kontoinhaber'] !== '[verschluesselt]') {
                    $entity->setKontoinhaberEncrypted($fields['kontoinhaber'] ? $this->encryptionService->encrypt($fields['kontoinhaber']) : null);
                }
                if (isset($fields['iban']) && $fields['iban'] !== '[verschluesselt]') {
                    $entity->setIbanEncrypted($fields['iban'] ? $this->encryptionService->encrypt($fields['iban']) : null);
                }
                $entity->setUpdatedAt($now);
                $this->familyMapper->update($entity);
                $this->auditService->logUpdate($entity->jsonSerialize(), 'family', $id);
                break;

            case 'stufen':
                $entity = $this->stufeMapper->findById($id);
                if (isset($fields['name'])) $entity->setName($fields['name']);
                if (isset($fields['sort_order'])) $entity->setSortOrder((int)$fields['sort_order']);
                if (isset($fields['age_range_min'])) $entity->setAgeRangeMin($fields['age_range_min'] !== '' ? (int)$fields['age_range_min'] : null);
                if (isset($fields['age_range_max'])) $entity->setAgeRangeMax($fields['age_range_max'] !== '' ? (int)$fields['age_range_max'] : null);
                if (isset($fields['color'])) $entity->setColor($fields['color']);
                $this->stufeMapper->update($entity);
                $this->auditService->logUpdate($entity->jsonSerialize(), 'stufe', $id);
                break;

            case 'lager':
                $entity = $this->lagerMapper->findById($id);
                if (isset($fields['name'])) $entity->setName($fields['name']);
                if (isset($fields['startdatum'])) $entity->setStartdatum($this->normalizeDate($fields['startdatum']));
                if (isset($fields['enddatum'])) $entity->setEnddatum($this->normalizeDate($fields['enddatum']));
                if (isset($fields['ort'])) $entity->setOrt($fields['ort']);
                if (isset($fields['beschreibung'])) $entity->setBeschreibung($fields['beschreibung']);
                $entity->setUpdatedAt($now);
                $this->lagerMapper->update($entity);
                $this->auditService->logUpdate($entity->jsonSerialize(), 'lager', $id);
                break;

            default:
                throw new \InvalidArgumentException("Merge nicht unterstuetzt fuer Typ: $type");
        }
    }

    // ── Field definitions per entity type ──────────────────────────

    /**
     * Get field definitions for an entity type.
     * Maps export column order to importable field keys.
     *
     * @return array[] Each element: {key, label, required, fk?, type?}
     */
    public function getFieldDefinitions(string $type): array {
        return match ($type) {
            'mitglieder' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'vorname', 'label' => 'Vorname', 'required' => true],
                ['key' => 'nachname', 'label' => 'Nachname', 'required' => true],
                ['key' => 'geburtsdatum', 'label' => 'Geburtsdatum', 'required' => false, 'type' => 'date'],
                ['key' => 'geschlecht', 'label' => 'Geschlecht', 'required' => false],
                ['key' => 'rolle', 'label' => 'Rolle', 'required' => false],
                ['key' => 'stufe_id', 'label' => 'Stufe-ID', 'required' => false, 'fk' => 'stufe'],
                ['key' => 'stufenname', 'label' => 'Stufenname', 'required' => false, 'fk_resolve' => 'stufe'],
                ['key' => 'eintritt', 'label' => 'Eintritt', 'required' => true, 'type' => 'date'],
                ['key' => 'austritt', 'label' => 'Austritt', 'required' => false, 'type' => 'date'],
                ['key' => 'status', 'label' => 'Status', 'required' => false],
                ['key' => 'allergien', 'label' => 'Allergien', 'required' => false],
                ['key' => 'notizen', 'label' => 'Notizen', 'required' => false],
                ['key' => 'zusatz_notizen', 'label' => 'Zusaetzliche Notizen', 'required' => false],
                ['key' => 'kv_typ', 'label' => 'KV-Typ', 'required' => false],
                ['key' => 'kv_name', 'label' => 'KV-Name', 'required' => false],
                ['key' => 'family_id', 'label' => 'Familien-ID', 'required' => false, 'fk' => 'family'],
                ['key' => 'familienname', 'label' => 'Familienname', 'required' => false, 'fk_resolve' => 'family'],
                ['key' => 'frozen_fee_rate', 'label' => 'Eingefrorener Beitragssatz', 'required' => false],
                ['key' => 'einwilligung_datum', 'label' => 'Einwilligungsdatum', 'required' => false, 'type' => 'date'],
                ['key' => 'juleica_nummer', 'label' => 'Juleica-Nummer', 'required' => false],
                ['key' => 'juleica_ablaufdatum', 'label' => 'Juleica-Ablaufdatum', 'required' => false, 'type' => 'date'],
            ],
            'familien' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'name', 'label' => 'Name', 'required' => true],
                ['key' => 'kontoinhaber', 'label' => 'Kontoinhaber', 'required' => false],
                ['key' => 'iban', 'label' => 'IBAN', 'required' => false],
                ['key' => 'bic', 'label' => 'BIC', 'required' => false],
                ['key' => 'kreditinstitut', 'label' => 'Kreditinstitut', 'required' => false],
            ],
            'adressen' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'member_id', 'label' => 'Mitglied-ID', 'required' => false, 'fk' => 'member'],
                ['key' => 'mitgliedername', 'label' => 'Mitgliedername', 'required' => false, 'fk_resolve' => 'member'],
                ['key' => 'label', 'label' => 'Label', 'required' => false],
                ['key' => 'strasse', 'label' => 'Strasse', 'required' => true],
                ['key' => 'plz', 'label' => 'PLZ', 'required' => true],
                ['key' => 'ort', 'label' => 'Ort', 'required' => true],
                ['key' => 'land', 'label' => 'Land', 'required' => false],
                ['key' => 'primaer', 'label' => 'Primaer', 'required' => false],
            ],
            'telefonnummern' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'member_id', 'label' => 'Mitglied-ID', 'required' => false, 'fk' => 'member'],
                ['key' => 'mitgliedername', 'label' => 'Mitgliedername', 'required' => false, 'fk_resolve' => 'member'],
                ['key' => 'label', 'label' => 'Label', 'required' => false],
                ['key' => 'nummer', 'label' => 'Nummer', 'required' => true],
            ],
            'emails' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'member_id', 'label' => 'Mitglied-ID', 'required' => false, 'fk' => 'member'],
                ['key' => 'mitgliedername', 'label' => 'Mitgliedername', 'required' => false, 'fk_resolve' => 'member'],
                ['key' => 'label', 'label' => 'Label', 'required' => false],
                ['key' => 'email', 'label' => 'E-Mail-Adresse', 'required' => true],
            ],
            'stufen' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'name', 'label' => 'Name', 'required' => true],
                ['key' => 'sort_order', 'label' => 'Sortierung', 'required' => false],
                ['key' => 'age_range_min', 'label' => 'Mindestalter', 'required' => false],
                ['key' => 'age_range_max', 'label' => 'Hoechstalter', 'required' => false],
                ['key' => 'color', 'label' => 'Farbe', 'required' => false],
            ],
            'stufenverlauf' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'member_id', 'label' => 'Mitglied-ID', 'required' => false, 'fk' => 'member'],
                ['key' => 'mitgliedername', 'label' => 'Mitgliedername', 'required' => false, 'fk_resolve' => 'member'],
                ['key' => 'stufe_id', 'label' => 'Stufe-ID', 'required' => false, 'fk' => 'stufe'],
                ['key' => 'stufenname', 'label' => 'Stufenname', 'required' => false, 'fk_resolve' => 'stufe'],
                ['key' => 'datum', 'label' => 'Datum', 'required' => true, 'type' => 'date'],
                ['key' => 'changed_by', 'label' => 'Geaendert von', 'required' => false],
            ],
            'beitragsregeln' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'year_from', 'label' => 'Gueltig ab Jahr', 'required' => true],
                ['key' => 'base_rate', 'label' => 'Grundbeitrag', 'required' => true],
                ['key' => 'family_rules_json', 'label' => 'Familienregeln', 'required' => false],
                ['key' => 'inactive_rule', 'label' => 'Inaktiven-Regel', 'required' => false],
                ['key' => 'created_at', 'label' => 'Erstellt am', 'required' => false],
                ['key' => 'created_by', 'label' => 'Erstellt von', 'required' => false],
            ],
            'beitraege' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'member_id', 'label' => 'Mitglied-ID', 'required' => false, 'fk' => 'member'],
                ['key' => 'mitgliedername', 'label' => 'Mitgliedername', 'required' => false, 'fk_resolve' => 'member'],
                ['key' => 'year', 'label' => 'Jahr', 'required' => true],
                ['key' => 'amount', 'label' => 'Betrag', 'required' => true],
                ['key' => 'paid', 'label' => 'Bezahlt', 'required' => false],
                ['key' => 'payment_date', 'label' => 'Zahlungsdatum', 'required' => false, 'type' => 'date'],
                ['key' => 'manuell_angepasst', 'label' => 'Manuell angepasst', 'required' => false],
                ['key' => 'notes', 'label' => 'Notizen', 'required' => false],
            ],
            'lager' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'name', 'label' => 'Name', 'required' => true],
                ['key' => 'startdatum', 'label' => 'Startdatum', 'required' => true, 'type' => 'date'],
                ['key' => 'enddatum', 'label' => 'Enddatum', 'required' => true, 'type' => 'date'],
                ['key' => 'ort', 'label' => 'Ort', 'required' => false],
                ['key' => 'beschreibung', 'label' => 'Beschreibung', 'required' => false],
            ],
            'lagerteilnehmer' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'lager_id', 'label' => 'Lager-ID', 'required' => false, 'fk' => 'lager'],
                ['key' => 'lagername', 'label' => 'Lagername', 'required' => false, 'fk_resolve' => 'lager'],
                ['key' => 'member_id', 'label' => 'Mitglied-ID', 'required' => false, 'fk' => 'member'],
                ['key' => 'mitgliedername', 'label' => 'Mitgliedername', 'required' => false, 'fk_resolve' => 'member'],
                ['key' => 'rolle', 'label' => 'Rolle', 'required' => false],
            ],
            'verletzungen' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'datum', 'label' => 'Datum', 'required' => true, 'type' => 'date'],
                ['key' => 'member_id', 'label' => 'Mitglied-ID', 'required' => false, 'fk' => 'member'],
                ['key' => 'mitgliedername', 'label' => 'Mitgliedername', 'required' => false, 'fk_resolve' => 'member'],
                ['key' => 'lager_id', 'label' => 'Lager-ID', 'required' => false, 'fk' => 'lager'],
                ['key' => 'lagername', 'label' => 'Lagername', 'required' => false, 'fk_resolve' => 'lager'],
                ['key' => 'aktivitaet', 'label' => 'Aktivitaet', 'required' => false],
                ['key' => 'beschreibung', 'label' => 'Beschreibung', 'required' => true],
                ['key' => 'created_by', 'label' => 'Erstellt von', 'required' => false],
            ],
            'gespeicherte_abfragen' => [
                ['key' => 'id', 'label' => 'ID', 'required' => false],
                ['key' => 'name', 'label' => 'Name', 'required' => true],
                ['key' => 'query_json', 'label' => 'Abfrage', 'required' => true],
                ['key' => 'sort_json', 'label' => 'Sortierung', 'required' => false],
                ['key' => 'created_by', 'label' => 'Erstellt von', 'required' => false],
                ['key' => 'created_at', 'label' => 'Erstellt am', 'required' => false],
            ],
            default => throw new \InvalidArgumentException("Unbekannter Entity-Typ: $type"),
        };
    }

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

    private function validateRow(string $type, array $mapped, int $rowNum): array {
        $errors = [];
        $fieldDefs = $this->getFieldDefinitions($type);

        foreach ($fieldDefs as $field) {
            if (!empty($field['required']) && (empty($mapped[$field['key']]) || trim((string)$mapped[$field['key']]) === '')) {
                $errors[] = "Pflichtfeld '{$field['label']}' fehlt (Zeile $rowNum)";
            }
        }

        // Date validation for date-type fields
        foreach ($fieldDefs as $field) {
            if (($field['type'] ?? null) === 'date' && !empty($mapped[$field['key']])) {
                if (!$this->isValidDate($mapped[$field['key']])) {
                    $errors[] = "Ungueltiges Datum fuer '{$field['label']}': {$mapped[$field['key']]} (Zeile $rowNum)";
                }
            }
        }

        // FK resolution: need either the ID or the resolvable name
        if (in_array($type, ['adressen', 'telefonnummern', 'emails', 'stufenverlauf', 'beitraege', 'lagerteilnehmer', 'verletzungen'])) {
            if (empty($mapped['member_id']) && empty($mapped['mitgliedername'])) {
                $errors[] = "Mitglied-ID oder Mitgliedername erforderlich (Zeile $rowNum)";
            }
        }

        return $errors;
    }

    // ── Duplicate detection ────────────────────────────────────────

    /**
     * @return string|false Reason string if duplicate, false otherwise
     */
    private function detectDuplicate(string $type, array $mapped): string|false {
        $result = $this->detectDuplicateWithExisting($type, $mapped);
        return $result !== false ? $result['reason'] : false;
    }

    /**
     * Detect duplicate and return existing record data for conflict resolution.
     *
     * @return array{reason: string, existingId: int, existingData: array}|false
     */
    private function detectDuplicateWithExisting(string $type, array $mapped): array|false {
        return match ($type) {
            'stufen' => $this->detectStufeDuplicateWithExisting($mapped),
            'mitglieder' => $this->detectMemberDuplicateWithExisting($mapped),
            'familien' => $this->detectFamilyDuplicateWithExisting($mapped),
            'lager' => $this->detectLagerDuplicateWithExisting($mapped),
            'gespeicherte_abfragen' => $this->detectSavedQueryDuplicateWithExisting($mapped),
            default => false,
        };
    }

    private function detectStufeDuplicateWithExisting(array $mapped): array|false {
        if (empty($mapped['name'])) return false;
        foreach ($this->stufeMapper->findAll() as $s) {
            if (strtolower(trim($s->getName())) === strtolower(trim($mapped['name']))) {
                return [
                    'reason' => 'Stufe mit diesem Namen existiert bereits',
                    'existingId' => $s->getId(),
                    'existingData' => $s->jsonSerialize(),
                ];
            }
        }
        return false;
    }

    private function detectMemberDuplicateWithExisting(array $mapped): array|false {
        if (empty($mapped['vorname']) || empty($mapped['nachname']) || empty($mapped['geburtsdatum'])) {
            return false;
        }
        try {
            $existing = $this->memberMapper->findByNameAndBirthdate(
                trim($mapped['vorname']),
                trim($mapped['nachname']),
                $this->normalizeDate($mapped['geburtsdatum'])
            );
            if ($existing !== null) {
                return [
                    'reason' => 'Duplikat (Vorname + Nachname + Geburtsdatum)',
                    'existingId' => $existing->getId(),
                    'existingData' => $existing->jsonSerialize(),
                ];
            }
            return false;
        } catch (\Exception) {
            return false;
        }
    }

    private function detectFamilyDuplicateWithExisting(array $mapped): array|false {
        if (empty($mapped['name'])) return false;
        foreach ($this->familyMapper->findAll() as $f) {
            if (strtolower(trim($f->getName())) === strtolower(trim($mapped['name']))) {
                return [
                    'reason' => 'Familie mit diesem Namen existiert bereits',
                    'existingId' => $f->getId(),
                    'existingData' => $f->jsonSerialize(),
                ];
            }
        }
        return false;
    }

    private function detectLagerDuplicateWithExisting(array $mapped): array|false {
        if (empty($mapped['name'])) return false;
        foreach ($this->lagerMapper->findAll() as $l) {
            if (strtolower(trim($l->getName())) === strtolower(trim($mapped['name']))) {
                return [
                    'reason' => 'Lager mit diesem Namen existiert bereits',
                    'existingId' => $l->getId(),
                    'existingData' => $l->jsonSerialize(),
                ];
            }
        }
        return false;
    }

    private function detectSavedQueryDuplicateWithExisting(array $mapped): array|false {
        if (empty($mapped['name'])) return false;
        $queries = $this->savedQueryMapper->findAll();
        foreach ($queries as $q) {
            if (strtolower(trim($q->getName())) === strtolower(trim($mapped['name']))) {
                return [
                    'reason' => 'Gespeicherte Abfrage mit diesem Namen existiert bereits',
                    'existingId' => $q->getId(),
                    'existingData' => $q->jsonSerialize(),
                ];
            }
        }
        return false;
    }

    // ── FK resolution ──────────────────────────────────────────────

    /**
     * Resolve foreign keys from name columns to IDs.
     *
     * @return array{resolved: array, warnings: string[]}
     */
    private function resolveFK(string $type, array $mapped): array {
        $warnings = [];
        $resolved = $mapped;

        // Resolve Stufe
        if (isset($mapped['stufenname']) && !empty($mapped['stufenname']) && empty($mapped['stufe_id'])) {
            $stufen = $this->getStufeByNameMap();
            $key = strtolower(trim($mapped['stufenname']));
            if (isset($stufen[$key])) {
                $resolved['stufe_id'] = (string)$stufen[$key];
            } else {
                $warnings[] = "Stufe '{$mapped['stufenname']}' nicht gefunden";
            }
        }

        // Resolve Family
        if (isset($mapped['familienname']) && !empty($mapped['familienname']) && empty($mapped['family_id'])) {
            $families = $this->getFamilyByNameMap();
            $key = strtolower(trim($mapped['familienname']));
            if (isset($families[$key])) {
                $resolved['family_id'] = (string)$families[$key];
            } else {
                $warnings[] = "Familie '{$mapped['familienname']}' nicht gefunden";
            }
        }

        // Resolve Member
        if (isset($mapped['mitgliedername']) && !empty($mapped['mitgliedername']) && empty($mapped['member_id'])) {
            $members = $this->getMemberByNameMap();
            $key = strtolower(trim($mapped['mitgliedername']));
            if (isset($members[$key])) {
                $resolved['member_id'] = (string)$members[$key];
            } else {
                $warnings[] = "Mitglied '{$mapped['mitgliedername']}' nicht gefunden";
            }
        }

        // Resolve Lager
        if (isset($mapped['lagername']) && !empty($mapped['lagername']) && empty($mapped['lager_id'])) {
            $lager = $this->getLagerByNameMap();
            $key = strtolower(trim($mapped['lagername']));
            if (isset($lager[$key])) {
                $resolved['lager_id'] = (string)$lager[$key];
            } else {
                $warnings[] = "Lager '{$mapped['lagername']}' nicht gefunden";
            }
        }

        return ['resolved' => $resolved, 'warnings' => $warnings];
    }

    /**
     * Resolve foreign keys by content/natural keys (migration mode).
     *
     * In migration mode, IDs from the source instance are meaningless.
     * Instead, use the resolved-name columns to find matching records
     * in the target database. Report ambiguous matches.
     *
     * Part of Issue #153.
     *
     * @return array{resolved: array, warnings: string[], ambiguous: string[]}
     */
    private function resolveFKByContent(string $type, array $mapped): array {
        $warnings = [];
        $ambiguous = [];
        $resolved = $mapped;

        // In migration mode, always prefer name-based resolution over IDs
        // (IDs from source instance are meaningless)

        // Resolve Stufe by name
        if (!empty($mapped['stufenname'])) {
            $stufen = $this->getStufeByNameMap();
            $key = strtolower(trim($mapped['stufenname']));
            if (isset($stufen[$key])) {
                $resolved['stufe_id'] = (string)$stufen[$key];
            } else {
                $warnings[] = "Stufe '{$mapped['stufenname']}' nicht in Zieldatenbank gefunden";
            }
        }

        // Resolve Family by name
        if (!empty($mapped['familienname'])) {
            $families = $this->getFamilyByNameMap();
            $key = strtolower(trim($mapped['familienname']));
            if (isset($families[$key])) {
                $resolved['family_id'] = (string)$families[$key];
            } else {
                $warnings[] = "Familie '{$mapped['familienname']}' nicht in Zieldatenbank gefunden";
            }
        }

        // Resolve Member by name (check for ambiguous matches)
        if (!empty($mapped['mitgliedername'])) {
            $name = strtolower(trim($mapped['mitgliedername']));
            $matches = [];
            foreach ($this->memberMapper->findAll() as $m) {
                $mName = strtolower($m->getVorname() . ' ' . $m->getNachname());
                if ($mName === $name) {
                    $matches[] = $m;
                }
            }

            if (count($matches) === 1) {
                $resolved['member_id'] = (string)$matches[0]->getId();
            } elseif (count($matches) > 1) {
                // Ambiguous: multiple members with same name
                $ambiguous[] = "Mitglied '{$mapped['mitgliedername']}' mehrdeutig ({$this->count($matches)} Treffer)";
                $resolved['member_id'] = (string)$matches[0]->getId(); // Use first match as default
            } else {
                $warnings[] = "Mitglied '{$mapped['mitgliedername']}' nicht in Zieldatenbank gefunden";
            }
        }

        // Resolve Lager by name
        if (!empty($mapped['lagername'])) {
            $lager = $this->getLagerByNameMap();
            $key = strtolower(trim($mapped['lagername']));
            if (isset($lager[$key])) {
                $resolved['lager_id'] = (string)$lager[$key];
            } else {
                $warnings[] = "Lager '{$mapped['lagername']}' nicht in Zieldatenbank gefunden";
            }
        }

        return ['resolved' => $resolved, 'warnings' => $warnings, 'ambiguous' => $ambiguous];
    }

    /**
     * Count items in an array (helper to avoid name collision with PHP count).
     */
    private function count(array $items): int {
        return \count($items);
    }

    // ── Entity creation ────────────────────────────────────────────

    private function createEntity(string $type, array $data, string $now): \OCP\AppFramework\Db\Entity {
        $entity = match ($type) {
            'stufen' => $this->createStufe($data),
            'familien' => $this->createFamily($data, $now),
            'mitglieder' => $this->createMember($data, $now),
            'adressen' => $this->createAddress($data),
            'telefonnummern' => $this->createPhone($data),
            'emails' => $this->createEmail($data),
            'stufenverlauf' => $this->createStufeHistory($data),
            'beitragsregeln' => $this->createFeeRule($data, $now),
            'beitraege' => $this->createFeeRecord($data, $now),
            'lager' => $this->createLager($data, $now),
            'lagerteilnehmer' => $this->createLagerTeilnehmer($data),
            'verletzungen' => $this->createInjury($data, $now),
            'gespeicherte_abfragen' => $this->createSavedQuery($data, $now),
            default => throw new \InvalidArgumentException("Unbekannter Entity-Typ: $type"),
        };

        $this->auditService->logCreate($entity->jsonSerialize(), $type, $entity->getId() ?? 0);
        return $entity;
    }

    private function createStufe(array $d): Stufe {
        $s = new Stufe();
        $s->setName(trim($d['name']));
        $s->setSortOrder((int)($d['sort_order'] ?? 0));
        if (!empty($d['age_range_min'])) $s->setAgeRangeMin((int)$d['age_range_min']);
        if (!empty($d['age_range_max'])) $s->setAgeRangeMax((int)$d['age_range_max']);
        if (!empty($d['color'])) $s->setColor($d['color']);
        $s = $this->stufeMapper->insert($s);
        $this->stufeByName = null; // invalidate cache
        return $s;
    }

    private function createFamily(array $d, string $now): Family {
        $f = new Family();
        $f->setName(trim($d['name']));
        if (!empty($d['kontoinhaber']) && $d['kontoinhaber'] !== '[verschluesselt]') {
            $f->setKontoinhaberEncrypted($this->encryptionService->encrypt($d['kontoinhaber']));
        }
        if (!empty($d['iban']) && $d['iban'] !== '[verschluesselt]') {
            $f->setIbanEncrypted($this->encryptionService->encrypt($d['iban']));
        }
        if (!empty($d['bic'])) $f->setBic($d['bic']);
        if (!empty($d['kreditinstitut'])) $f->setKreditinstitut($d['kreditinstitut']);
        $f->setCreatedAt($now);
        $f->setUpdatedAt($now);
        $f = $this->familyMapper->insert($f);
        $this->familyByName = null; // invalidate cache
        return $f;
    }

    private function createMember(array $d, string $now): Member {
        $m = new Member();
        $m->setVorname(trim($d['vorname']));
        $m->setNachname(trim($d['nachname']));
        $m->setGeburtsdatum(!empty($d['geburtsdatum']) ? $this->normalizeDate($d['geburtsdatum']) : null);
        $m->setEintritt($this->normalizeDate($d['eintritt']));
        $m->setRolle($d['rolle'] ?? 'mitglied');
        $m->setStatus($d['status'] ?? 'aktiv');
        if (!empty($d['geschlecht'])) $m->setGeschlecht($d['geschlecht']);
        if (!empty($d['stufe_id'])) $m->setStufeId((int)$d['stufe_id']);
        if (!empty($d['austritt'])) $m->setAustritt($this->normalizeDate($d['austritt']));
        if (!empty($d['allergien']) && $d['allergien'] !== '[verschluesselt]') {
            $m->setAllergienEncrypted($this->encryptionService->encrypt($d['allergien']));
        }
        if (!empty($d['notizen'])) $m->setNotizen($d['notizen']);
        if (!empty($d['zusatz_notizen'])) $m->setZusatzNotizen($d['zusatz_notizen']);
        if (!empty($d['kv_typ'])) $m->setKvTyp($d['kv_typ']);
        if (!empty($d['kv_name'])) $m->setKvName($d['kv_name']);
        if (!empty($d['family_id'])) $m->setFamilyId((int)$d['family_id']);
        if (!empty($d['frozen_fee_rate'])) $m->setFrozenFeeRate($d['frozen_fee_rate']);
        if (!empty($d['einwilligung_datum'])) $m->setEinwilligungDatum($this->normalizeDate($d['einwilligung_datum']));
        if (!empty($d['juleica_nummer'])) $m->setJuleicaNummer($d['juleica_nummer']);
        if (!empty($d['juleica_ablaufdatum'])) $m->setJuleicaAblaufdatum($this->normalizeDate($d['juleica_ablaufdatum']));
        $m->setCreatedAt($now);
        $m->setUpdatedAt($now);
        $m = $this->memberMapper->insert($m);
        $this->memberByName = null; // invalidate cache
        return $m;
    }

    private function createAddress(array $d): Address {
        $a = new Address();
        $a->setMemberId((int)$d['member_id']);
        if (!empty($d['label'])) $a->setLabel($d['label']);
        $a->setStrasse($d['strasse'] ?? '');
        $a->setPlz($d['plz'] ?? '');
        $a->setOrt($d['ort'] ?? '');
        $a->setLand($d['land'] ?? 'Deutschland');
        $a->setIsPrimary(($d['primaer'] ?? '') === 'Ja');
        return $this->addressMapper->insert($a);
    }

    private function createPhone(array $d): Phone {
        $p = new Phone();
        $p->setMemberId((int)$d['member_id']);
        if (!empty($d['label'])) $p->setLabel($d['label']);
        $p->setNumberE164(trim($d['nummer']));
        return $this->phoneMapper->insert($p);
    }

    private function createEmail(array $d): Email {
        $e = new Email();
        $e->setMemberId((int)$d['member_id']);
        if (!empty($d['label'])) $e->setLabel($d['label']);
        $e->setEmail(trim($d['email']));
        return $this->emailMapper->insert($e);
    }

    private function createStufeHistory(array $d): StufeHistory {
        $h = new StufeHistory();
        $h->setMemberId((int)$d['member_id']);
        $h->setStufeId((int)$d['stufe_id']);
        $h->setChangedAt($this->normalizeDate($d['datum']));
        $h->setChangedBy($d['changed_by'] ?? 'import');
        return $this->stufeHistoryMapper->insert($h);
    }

    private function createFeeRule(array $d, string $now): FeeRule {
        $r = new FeeRule();
        $r->setYearFrom((int)$d['year_from']);
        $r->setBaseRate($d['base_rate']);
        if (!empty($d['family_rules_json'])) $r->setFamilyRulesJson($d['family_rules_json']);
        if (!empty($d['inactive_rule'])) $r->setInactiveRule($d['inactive_rule']);
        $r->setCreatedAt($d['created_at'] ?? $now);
        $r->setCreatedBy($d['created_by'] ?? 'import');
        return $this->feeRuleMapper->insert($r);
    }

    private function createFeeRecord(array $d, string $now): FeeRecord {
        $r = new FeeRecord();
        $r->setMemberId((int)$d['member_id']);
        $r->setYear((int)$d['year']);
        $r->setAmount($d['amount']);
        $r->setPaid(($d['paid'] ?? 'Nein') === 'Ja');
        if (!empty($d['payment_date'])) $r->setPaymentDate($this->normalizeDate($d['payment_date']));
        $r->setManuellAngepasst(($d['manuell_angepasst'] ?? 'Nein') === 'Ja');
        if (!empty($d['notes'])) $r->setNotes($d['notes']);
        $r->setCreatedAt($now);
        $r->setUpdatedAt($now);
        return $this->feeRecordMapper->insert($r);
    }

    private function createLager(array $d, string $now): Lager {
        $l = new Lager();
        $l->setName(trim($d['name']));
        $l->setStartdatum($this->normalizeDate($d['startdatum']));
        $l->setEnddatum($this->normalizeDate($d['enddatum']));
        if (!empty($d['ort'])) $l->setOrt($d['ort']);
        if (!empty($d['beschreibung'])) $l->setBeschreibung($d['beschreibung']);
        $l->setCreatedAt($now);
        $l->setUpdatedAt($now);
        $l = $this->lagerMapper->insert($l);
        $this->lagerByName = null; // invalidate cache
        return $l;
    }

    private function createLagerTeilnehmer(array $d): LagerTeilnehmer {
        $t = new LagerTeilnehmer();
        $t->setLagerId((int)$d['lager_id']);
        $t->setMemberId((int)$d['member_id']);
        $t->setRolle($d['rolle'] ?? 'Teilnehmer');
        return $this->lagerTeilnehmerMapper->insert($t);
    }

    private function createInjury(array $d, string $now): Injury {
        $i = new Injury();
        $i->setDatum($this->normalizeDate($d['datum']));
        $i->setMemberId((int)$d['member_id']);
        if (!empty($d['lager_id'])) $i->setLagerId((int)$d['lager_id']);
        if (!empty($d['aktivitaet'])) $i->setActivityName($d['aktivitaet']);
        $i->setBeschreibung($d['beschreibung']);
        $i->setCreatedBy($d['created_by'] ?? 'import');
        $i->setCreatedAt($now);
        return $this->injuryMapper->insert($i);
    }

    private function createSavedQuery(array $d, string $now): SavedQuery {
        $q = new SavedQuery();
        $q->setName(trim($d['name']));
        $q->setQueryJson($d['query_json']);
        if (!empty($d['sort_json'])) $q->setSortJson($d['sort_json']);
        $q->setCreatedBy($d['created_by'] ?? 'import');
        $q->setCreatedAt($d['created_at'] ?? $now);
        return $this->savedQueryMapper->insert($q);
    }

    // ── Lookup caches ──────────────────────────────────────────────

    private function getStufeByNameMap(): array {
        if ($this->stufeByName === null) {
            $this->stufeByName = [];
            foreach ($this->stufeMapper->findAll() as $s) {
                $this->stufeByName[strtolower($s->getName())] = $s->getId();
            }
        }
        return $this->stufeByName;
    }

    private function getFamilyByNameMap(): array {
        if ($this->familyByName === null) {
            $this->familyByName = [];
            foreach ($this->familyMapper->findAll() as $f) {
                $this->familyByName[strtolower($f->getName())] = $f->getId();
            }
        }
        return $this->familyByName;
    }

    private function getMemberByNameMap(): array {
        if ($this->memberByName === null) {
            $this->memberByName = [];
            foreach ($this->memberMapper->findAll() as $m) {
                $key = strtolower($m->getVorname() . ' ' . $m->getNachname());
                $this->memberByName[$key] = $m->getId();
            }
        }
        return $this->memberByName;
    }

    private function getLagerByNameMap(): array {
        if ($this->lagerByName === null) {
            $this->lagerByName = [];
            foreach ($this->lagerMapper->findAll() as $l) {
                $this->lagerByName[strtolower($l->getName())] = $l->getId();
            }
        }
        return $this->lagerByName;
    }

    private function resetCaches(): void {
        $this->stufeByName = null;
        $this->familyByName = null;
        $this->memberByName = null;
        $this->lagerByName = null;
    }

    // ── CSV parsing helpers ────────────────────────────────────────

    /**
     * Parse CSV content and return all data rows (excluding header).
     */
    public function parseFile(string $content, string $delimiter = ';', string $encoding = 'UTF-8'): array {
        if (strtoupper($encoding) !== 'UTF-8') {
            $content = mb_convert_encoding($content, 'UTF-8', $encoding);
        }
        $content = $this->removeBom($content);

        $lines = str_getcsv($content, "\n");
        if (empty($lines)) {
            throw new ValidationException('Datei ist leer');
        }

        $rows = [];
        foreach ($lines as $line) {
            $row = str_getcsv($line, $delimiter);
            if ($row !== false && !$this->isEmptyRow($row)) {
                $rows[] = $row;
            }
        }

        if (count($rows) < 2) {
            throw new ValidationException('Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten');
        }

        $columns = array_shift($rows);
        $columns = array_map('trim', $columns);

        return [
            'columns' => $columns,
            'rows' => array_slice($rows, 0, 50),
            'totalRows' => count($rows),
        ];
    }

    private function parseAllRows(string $content, string $delimiter, string $encoding): array {
        if (strtoupper($encoding) !== 'UTF-8') {
            $content = mb_convert_encoding($content, 'UTF-8', $encoding);
        }
        $content = $this->removeBom($content);

        $lines = str_getcsv($content, "\n");
        $rows = [];
        foreach ($lines as $line) {
            $row = str_getcsv($line, $delimiter);
            if ($row !== false && !$this->isEmptyRow($row)) {
                $rows[] = $row;
            }
        }
        array_shift($rows); // Remove header
        return $rows;
    }

    private function applyMapping(array $row, array $mapping): array {
        $mapped = [];
        foreach ($mapping as $sourceIndex => $targetField) {
            if ($targetField !== '' && $targetField !== null && isset($row[(int)$sourceIndex])) {
                $mapped[$targetField] = trim($row[(int)$sourceIndex]);
            }
        }
        return $mapped;
    }

    private function isValidDate(string $date): bool {
        $formats = ['Y-m-d', 'd.m.Y', 'd/m/Y'];
        foreach ($formats as $format) {
            $parsed = DateTime::createFromFormat($format, trim($date));
            if ($parsed !== false && $parsed->format($format) === trim($date)) {
                return true;
            }
        }
        return false;
    }

    private function normalizeDate(string $date): string {
        $date = trim($date);
        if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) return $date;
        $parsed = DateTime::createFromFormat('d.m.Y', $date);
        if ($parsed !== false) return $parsed->format('Y-m-d');
        $parsed = DateTime::createFromFormat('d/m/Y', $date);
        if ($parsed !== false) return $parsed->format('Y-m-d');
        return $date;
    }

    private function removeBom(string $content): string {
        if (substr($content, 0, 3) === "\xEF\xBB\xBF") return substr($content, 3);
        return $content;
    }

    private function isEmptyRow(array $row): bool {
        foreach ($row as $cell) {
            if ($cell !== null && trim($cell) !== '') return false;
        }
        return true;
    }
}
