package de.votian.services.service;

import de.votian.services.dto.ImportExecutionResultDto;
import de.votian.services.dto.ImportFileDto;
import de.votian.services.dto.ImportProcessDto;
import de.votian.services.entity.Customer;
import de.votian.services.entity.ServiceRadius;
import de.votian.services.entity.ServiceZipCode;
import de.votian.services.entity.ServiceZipCodeNeighbour;
import de.votian.services.entity.ServiceZone;
import de.votian.services.entity.ServiceZoneMapping;
import de.votian.services.repository.CustomerRepository;
import de.votian.services.repository.ServiceRadiusRepository;
import de.votian.services.repository.ServiceZipCodeRepository;
import de.votian.services.repository.ServiceZipCodeNeighbourRepository;
import de.votian.services.repository.ServiceZoneMappingRepository;
import de.votian.services.repository.ServiceZoneRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

@Service
@Transactional
public class ImportWorkspaceService {

    private static final int MAX_UPLOAD_BYTES = 25 * 1024 * 1024;

    private final CustomerRepository customerRepository;
    private final ServiceZoneRepository serviceZoneRepository;
    private final ServiceZoneMappingRepository serviceZoneMappingRepository;
    private final ServiceZipCodeRepository serviceZipCodeRepository;
    private final ServiceRadiusRepository serviceRadiusRepository;
    private final ServiceZipCodeNeighbourRepository serviceZipCodeNeighbourRepository;
    private final Path importRoot;
    private final Map<String, ProcessDefinition> processDefinitions;

    public ImportWorkspaceService(CustomerRepository customerRepository,
                                  ServiceZoneRepository serviceZoneRepository,
                                  ServiceZoneMappingRepository serviceZoneMappingRepository,
                                  ServiceZipCodeRepository serviceZipCodeRepository,
                                  ServiceRadiusRepository serviceRadiusRepository,
                                  ServiceZipCodeNeighbourRepository serviceZipCodeNeighbourRepository,
                                  @Value("${votian.storage.import-processes-dir:../html/import/upload/_ng-imports}") String importRootDir) {
        this.customerRepository = customerRepository;
        this.serviceZoneRepository = serviceZoneRepository;
        this.serviceZoneMappingRepository = serviceZoneMappingRepository;
        this.serviceZipCodeRepository = serviceZipCodeRepository;
        this.serviceRadiusRepository = serviceRadiusRepository;
        this.serviceZipCodeNeighbourRepository = serviceZipCodeNeighbourRepository;
        this.importRoot = Path.of(importRootDir).normalize();
        this.processDefinitions = buildProcessDefinitions();
    }

    @Transactional(readOnly = true)
    public List<ImportProcessDto> getProcesses() {
        return processDefinitions.values().stream()
                .map(ProcessDefinition::toDto)
                .toList();
    }

    @Transactional(readOnly = true)
    public ImportProcessDto getProcess(String processKey) {
        return definition(processKey).toDto();
    }

    @Transactional(readOnly = true)
    public List<ImportFileDto> listFiles(String processKey) {
        ProcessDefinition definition = definition(processKey);
        Path processDir = processDirectory(definition);
        if (!Files.isDirectory(processDir)) {
            return List.of();
        }
        try {
            List<ImportFileDto> files = new ArrayList<>();
            try (var stream = Files.list(processDir)) {
                stream.filter(Files::isRegularFile)
                        .forEach(path -> files.add(toFileDto(path)));
            }
            files.sort(Comparator.comparing(ImportFileDto::getModifiedAt, Comparator.nullsLast(Comparator.reverseOrder()))
                    .thenComparing(ImportFileDto::getFileName, Comparator.nullsLast(String::compareToIgnoreCase)));
            return files;
        } catch (IOException ex) {
            throw new RuntimeException("Importdateien konnten nicht gelesen werden", ex);
        }
    }

    public ImportFileDto storeFile(String processKey, MultipartFile file) {
        if (file == null || file.isEmpty()) {
            throw new IllegalArgumentException("Keine Datei uebergeben");
        }
        if (file.getSize() > MAX_UPLOAD_BYTES) {
            throw new IllegalArgumentException("Datei ist groesser als 25 MB");
        }
        ProcessDefinition definition = definition(processKey);
        String sanitizedName = sanitizeFileName(file.getOriginalFilename());
        Path target = processDirectory(definition).resolve(sanitizedName).normalize();
        if (!target.startsWith(processDirectory(definition))) {
            throw new IllegalArgumentException("Dateiname ist ungueltig");
        }
        try {
            Files.createDirectories(processDirectory(definition));
            Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
            return toFileDto(target);
        } catch (IOException ex) {
            throw new RuntimeException("Datei konnte nicht gespeichert werden", ex);
        }
    }

    @Transactional(readOnly = true)
    public byte[] loadFile(String processKey, String fileName) {
        try {
            return Files.readAllBytes(resolveFile(processKey, fileName));
        } catch (IOException ex) {
            throw new RuntimeException("Datei konnte nicht gelesen werden", ex);
        }
    }

    public void deleteFile(String processKey, String fileName) {
        try {
            Files.deleteIfExists(resolveFile(processKey, fileName));
        } catch (IOException ex) {
            throw new RuntimeException("Datei konnte nicht geloescht werden", ex);
        }
    }

    public ImportExecutionResultDto execute(String processKey, String fileName, Long customerId, Long headquartersId) {
        ProcessDefinition definition = definition(processKey);
        if (!definition.nativeExecution) {
            throw new IllegalArgumentException("Dieser Prozess ist im NG-Workspace nur als Datenuebernahme modelliert");
        }
        if (definition.customerRequired && (customerId == null || customerId <= 0)) {
            throw new IllegalArgumentException("Kunde fehlt");
        }
        Path file = resolveFile(processKey, fileName);
        Customer customer = definition.customerRequired ? requireCustomer(customerId, headquartersId) : null;
        if ("ZONE".equals(definition.key)) {
            return executeZoneImport(definition, file, customer);
        }
        if ("RADIUSZONE".equals(definition.key)) {
            return executeRadiusImport(definition, file, customer);
        }
        if ("ZONEAGIO".equals(definition.key)) {
            return executeZoneAgioImport(definition, file, customer);
        }
        if ("ZIPCODE_NEIGHBOURS".equals(definition.key)) {
            return executeZipcodeNeighboursImport(definition, file, headquartersId);
        }
        throw new IllegalArgumentException("Kein nativer NG-Handler hinterlegt");
    }

    private ImportExecutionResultDto executeZoneImport(ProcessDefinition definition, Path file, Customer customer) {
        List<String[]> rows = readDelimitedFile(file, ';');
        Map<Integer, ZoneAccumulator> importedZones = new LinkedHashMap<>();
        List<String> warnings = new ArrayList<>();
        Set<String> requestedZipcodes = new LinkedHashSet<>();

        int ignoredRows = 0;
        for (String[] row : rows) {
            if (isBlankRow(row)) {
                continue;
            }
            String zoneText = column(row, 0);
            String country = normalizeCountry(column(row, 1));
            String zipcode = normalizeZipcode(column(row, 2), country);
            BigDecimal price = parseDecimal(column(row, 3));
            BigDecimal price2 = parseDecimal(column(row, 4));
            BigDecimal price3 = parseDecimal(column(row, 5));

            Integer zoneNo = parseInteger(zoneText);
            if (zoneNo == null || zipcode == null) {
                ignoredRows++;
                continue;
            }

            ZoneAccumulator zone = importedZones.computeIfAbsent(zoneNo, key -> new ZoneAccumulator());
            zone.zoneNo = zoneNo;
            zone.price = price;
            zone.price2 = price2;
            zone.price3 = price3;
            zone.zipcodes.add(zipcode);
            requestedZipcodes.add(zipcode);
        }

        Map<String, ServiceZipCode> zipcodesByValue = mapByZipcode(serviceZipCodeRepository.findByZipcodeIn(requestedZipcodes));
        Map<Integer, ServiceZone> existingZones = new LinkedHashMap<>();
        for (ServiceZone zone : serviceZoneRepository.findByHeadquartersIdAndCustomerIdOrderByZoneNo(customer.getHeadquartersId(), customer.getId())) {
            existingZones.put(zone.getZoneNo(), zone);
        }

        int created = 0;
        int updated = 0;
        int deleted = 0;
        int mappingCount = 0;
        List<ServiceZoneMapping> newMappings = new ArrayList<>();

        for (ZoneAccumulator accumulator : importedZones.values()) {
            ServiceZone zone = existingZones.get(accumulator.zoneNo);
            if (zone == null) {
                zone = new ServiceZone();
                zone.setHeadquartersId(customer.getHeadquartersId());
                zone.setCustomerId(customer.getId());
                zone.setZoneNo(accumulator.zoneNo);
                zone.setName("Zone " + accumulator.zoneNo);
                created++;
            } else {
                updated++;
                serviceZoneMappingRepository.deleteByZoneId(zone.getId());
                deleted++;
            }
            zone.setPrice(zeroIfNull(accumulator.price));
            zone.setPrice1(zeroIfNull(accumulator.price2));
            zone.setPrice2(zeroIfNull(accumulator.price3));
            zone.setPrice3(BigDecimal.ZERO);
            ServiceZone savedZone = serviceZoneRepository.save(zone);

            for (String zipcode : accumulator.zipcodes) {
                ServiceZipCode serviceZipCode = zipcodesByValue.get(zipcode);
                if (serviceZipCode == null) {
                    warnings.add("PLZ " + zipcode + " ist im Basisdatenbestand nicht vorhanden");
                    ignoredRows++;
                    continue;
                }
                ServiceZoneMapping mapping = new ServiceZoneMapping();
                mapping.setZoneId(savedZone.getId());
                mapping.setZipCodeId(serviceZipCode.getId());
                mapping.setMappedValue1(zeroIfNull(accumulator.price));
                mapping.setMappedValue2(zeroIfNull(accumulator.price2));
                mapping.setMappedValue3(zeroIfNull(accumulator.price3));
                newMappings.add(mapping);
                mappingCount++;
            }
        }

        serviceZoneMappingRepository.saveAll(newMappings);

        ImportExecutionResultDto result = new ImportExecutionResultDto();
        result.setSuccess(true);
        result.setProcessKey(definition.key);
        result.setCreatedCount(created + mappingCount);
        result.setUpdatedCount(updated);
        result.setDeletedCount(deleted);
        result.setIgnoredCount(ignoredRows);
        result.setMessages(warnings);
        result.setSummary("Zonenimport abgeschlossen: " + importedZones.size() + " Zonen, " + mappingCount + " Zuordnungen");
        return result;
    }

    private ImportExecutionResultDto executeRadiusImport(ProcessDefinition definition, Path file, Customer customer) {
        List<String[]> rows = readDelimitedFile(file, ';');
        Map<Integer, List<RadiusRow>> rowsByArea = new LinkedHashMap<>();
        int ignoredRows = 0;

        for (String[] row : rows) {
            if (isBlankRow(row)) {
                continue;
            }
            Integer areaNo = parseInteger(column(row, 0));
            String zipcode = normalizeZipcode(column(row, 1), "DE");
            String district = blankToNull(column(row, 2));
            if (areaNo == null || zipcode == null) {
                ignoredRows++;
                continue;
            }
            rowsByArea.computeIfAbsent(areaNo, key -> new ArrayList<>())
                    .add(new RadiusRow(areaNo, zipcode, district));
        }

        List<ServiceRadius> existing = serviceRadiusRepository.findByHeadquartersIdAndCustomerIdOrderByZipcodeAscCityDistrictAsc(
                customer.getHeadquartersId(),
                customer.getId()
        );
        Set<Integer> importedAreas = rowsByArea.keySet();
        List<ServiceRadius> toDelete = existing.stream()
                .filter(row -> importedAreas.contains(row.getRadiusAreaNo()))
                .toList();
        if (!toDelete.isEmpty()) {
            serviceRadiusRepository.deleteAll(toDelete);
        }

        List<ServiceRadius> toSave = new ArrayList<>();
        for (List<RadiusRow> areaRows : rowsByArea.values()) {
            for (RadiusRow row : deduplicateRadiusRows(areaRows)) {
                ServiceRadius radius = new ServiceRadius();
                radius.setHeadquartersId(customer.getHeadquartersId());
                radius.setCustomerId(customer.getId());
                radius.setRadiusAreaNo(row.areaNo());
                radius.setZipcode(row.zipcode());
                radius.setCityDistrict(row.district());
                toSave.add(radius);
            }
        }
        serviceRadiusRepository.saveAll(toSave);

        ImportExecutionResultDto result = new ImportExecutionResultDto();
        result.setSuccess(true);
        result.setProcessKey(definition.key);
        result.setCreatedCount(toSave.size());
        result.setDeletedCount(toDelete.size());
        result.setIgnoredCount(ignoredRows);
        result.setSummary("Radiuszonenimport abgeschlossen: " + importedAreas.size() + " Bereiche, " + toSave.size() + " Zeilen");
        return result;
    }

    private ImportExecutionResultDto executeZoneAgioImport(ProcessDefinition definition, Path file, Customer customer) {
        List<String[]> rows = readDelimitedFile(file, ';');
        Map<String, BigDecimal> agioByZipcode = new LinkedHashMap<>();
        int ignoredRows = 0;

        for (String[] row : rows) {
            if (isBlankRow(row)) {
                continue;
            }
            String zipcode = normalizeZipcode(column(row, 0), "DE");
            if (zipcode == null) {
                ignoredRows++;
                continue;
            }
            agioByZipcode.put(zipcode, parseDecimal(column(row, 1)));
        }

        List<ServiceZone> zones = serviceZoneRepository.findByHeadquartersIdAndCustomerIdOrderByZoneNo(
                customer.getHeadquartersId(),
                customer.getId()
        );
        List<Long> zoneIds = zones.stream()
                .map(ServiceZone::getId)
                .filter(Objects::nonNull)
                .toList();
        if (zoneIds.isEmpty()) {
            throw new IllegalArgumentException("Fuer den ausgewaehlten Kunden existieren keine Zonen");
        }

        List<ServiceZoneMapping> mappings = new ArrayList<>(serviceZoneMappingRepository.findByZoneIdIn(zoneIds));
        if (mappings.isEmpty()) {
            throw new IllegalArgumentException("Fuer den ausgewaehlten Kunden existieren keine Zonen-PLZ-Zuordnungen");
        }

        for (ServiceZoneMapping mapping : mappings) {
            mapping.setMappedValue2(BigDecimal.ZERO);
        }

        Map<Long, List<ServiceZoneMapping>> mappingsByZipCodeId = new LinkedHashMap<>();
        for (ServiceZoneMapping mapping : mappings) {
            mappingsByZipCodeId.computeIfAbsent(mapping.getZipCodeId(), key -> new ArrayList<>()).add(mapping);
        }

        Map<String, ServiceZipCode> zipcodesByValue = mapByZipcode(serviceZipCodeRepository.findByZipcodeIn(agioByZipcode.keySet()));
        List<String> warnings = new ArrayList<>();
        int updatedMappings = 0;

        for (Map.Entry<String, BigDecimal> entry : agioByZipcode.entrySet()) {
            ServiceZipCode zipcode = zipcodesByValue.get(entry.getKey());
            if (zipcode == null) {
                warnings.add("PLZ " + entry.getKey() + " ist im Basisdatenbestand nicht vorhanden");
                ignoredRows++;
                continue;
            }
            List<ServiceZoneMapping> relatedMappings = mappingsByZipCodeId.get(zipcode.getId());
            if (relatedMappings == null || relatedMappings.isEmpty()) {
                warnings.add("PLZ " + entry.getKey() + " ist fuer den ausgewaehlten Kunden keiner Zone zugeordnet");
                ignoredRows++;
                continue;
            }
            for (ServiceZoneMapping mapping : relatedMappings) {
                mapping.setMappedValue2(entry.getValue());
                updatedMappings++;
            }
        }

        serviceZoneMappingRepository.saveAll(mappings);

        ImportExecutionResultDto result = new ImportExecutionResultDto();
        result.setSuccess(true);
        result.setProcessKey(definition.key);
        result.setUpdatedCount(updatedMappings);
        result.setIgnoredCount(ignoredRows);
        result.setMessages(warnings);
        result.setSummary("Zonen-Agioimport abgeschlossen: " + agioByZipcode.size() + " PLZ-Vorgaben, " + updatedMappings + " Zuordnungen aktualisiert");
        return result;
    }

    private ImportExecutionResultDto executeZipcodeNeighboursImport(ProcessDefinition definition, Path file, Long headquartersId) {
        if (headquartersId == null || headquartersId <= 0) {
            throw new IllegalArgumentException("Aktive Niederlassung fehlt");
        }

        List<String[]> rows = readDelimitedFile(file, ';');
        Map<String, List<String>> neighboursByZipcode = new LinkedHashMap<>();
        Set<String> requestedZipcodes = new LinkedHashSet<>();
        List<String> warnings = new ArrayList<>();
        int ignoredRows = 0;

        for (String[] row : rows) {
            if (isBlankRow(row)) {
                continue;
            }
            String baseZipcode = normalizeZipcode(column(row, 0), "DE");
            if (baseZipcode == null) {
                ignoredRows++;
                continue;
            }

            LinkedHashSet<String> neighbourZipcodes = new LinkedHashSet<>();
            for (int columnIndex = 1; columnIndex < row.length; columnIndex++) {
                String neighbourZipcode = normalizeZipcode(column(row, columnIndex), "DE");
                if (neighbourZipcode != null && !neighbourZipcode.equals(baseZipcode)) {
                    neighbourZipcodes.add(neighbourZipcode);
                }
            }

            if (neighbourZipcodes.isEmpty()) {
                warnings.add("PLZ " + baseZipcode + " hat keine gueltigen Nachbarn in der Importdatei");
                ignoredRows++;
                continue;
            }

            neighboursByZipcode.put(baseZipcode, new ArrayList<>(neighbourZipcodes));
            requestedZipcodes.add(baseZipcode);
            requestedZipcodes.addAll(neighbourZipcodes);
        }

        Map<String, ServiceZipCode> zipcodesByValue = mapByZipcode(serviceZipCodeRepository.findByZipcodeIn(requestedZipcodes));
        Set<Long> baseZipCodeIds = new LinkedHashSet<>();
        for (String zipcode : neighboursByZipcode.keySet()) {
            ServiceZipCode serviceZipCode = zipcodesByValue.get(zipcode);
            if (serviceZipCode != null) {
                baseZipCodeIds.add(serviceZipCode.getId());
            }
        }

        Set<String> existingRelations = new LinkedHashSet<>();
        if (!baseZipCodeIds.isEmpty()) {
            for (ServiceZipCodeNeighbour relation : serviceZipCodeNeighbourRepository.findByHeadquartersIdAndZipCodeIdIn(headquartersId, baseZipCodeIds)) {
                existingRelations.add(relationKey(relation.getZipCodeId(), relation.getNeighbourZipCodeId()));
            }
        }

        List<ServiceZipCodeNeighbour> toCreate = new ArrayList<>();
        int existingCount = 0;

        for (Map.Entry<String, List<String>> entry : neighboursByZipcode.entrySet()) {
            ServiceZipCode baseZipCode = zipcodesByValue.get(entry.getKey());
            if (baseZipCode == null) {
                warnings.add("Basis-PLZ " + entry.getKey() + " ist im Basisdatenbestand nicht vorhanden");
                ignoredRows++;
                continue;
            }

            List<String> neighbourZipcodes = entry.getValue();
            for (int index = 0; index < neighbourZipcodes.size(); index++) {
                String neighbourZipcodeValue = neighbourZipcodes.get(index);
                ServiceZipCode neighbourZipCode = zipcodesByValue.get(neighbourZipcodeValue);
                if (neighbourZipCode == null) {
                    warnings.add("Nachbar-PLZ " + neighbourZipcodeValue + " ist im Basisdatenbestand nicht vorhanden");
                    ignoredRows++;
                    continue;
                }

                String relationKey = relationKey(baseZipCode.getId(), neighbourZipCode.getId());
                if (existingRelations.contains(relationKey)) {
                    existingCount++;
                    continue;
                }

                ServiceZipCodeNeighbour neighbour = new ServiceZipCodeNeighbour();
                neighbour.setHeadquartersId(headquartersId);
                neighbour.setZipCodeId(baseZipCode.getId());
                neighbour.setNeighbourZipCodeId(neighbourZipCode.getId());
                neighbour.setSortOrder(index + 1);
                toCreate.add(neighbour);
                existingRelations.add(relationKey);
            }
        }

        if (!toCreate.isEmpty()) {
            serviceZipCodeNeighbourRepository.saveAll(toCreate);
        }
        if (existingCount > 0) {
            warnings.add(existingCount + " Nachbarschaften waren bereits vorhanden");
        }

        ImportExecutionResultDto result = new ImportExecutionResultDto();
        result.setSuccess(true);
        result.setProcessKey(definition.key);
        result.setCreatedCount(toCreate.size());
        result.setIgnoredCount(ignoredRows);
        result.setMessages(warnings);
        result.setSummary("PLZ-Nachbarschaftsimport abgeschlossen: " + toCreate.size() + " neue Relationen");
        return result;
    }

    private List<RadiusRow> deduplicateRadiusRows(List<RadiusRow> rows) {
        Map<String, RadiusRow> unique = new LinkedHashMap<>();
        for (RadiusRow row : rows) {
            unique.put(row.areaNo() + "|" + row.zipcode() + "|" + blankToNull(row.district()), row);
        }
        return new ArrayList<>(unique.values());
    }

    private Customer requireCustomer(Long customerId, Long headquartersId) {
        Customer customer = customerRepository.findById(Objects.requireNonNull(customerId))
                .orElseThrow(() -> new IllegalArgumentException("Kunde nicht gefunden: " + customerId));
        if (headquartersId != null && !Objects.equals(customer.getHeadquartersId(), headquartersId)) {
            throw new IllegalArgumentException("Kunde gehoert nicht zur aktiven Niederlassung");
        }
        return customer;
    }

    private Map<String, ServiceZipCode> mapByZipcode(Collection<ServiceZipCode> zipcodes) {
        Map<String, ServiceZipCode> result = new LinkedHashMap<>();
        for (ServiceZipCode zipcode : zipcodes) {
            if (zipcode != null && zipcode.getZipcode() != null) {
                result.put(zipcode.getZipcode(), zipcode);
            }
        }
        return result;
    }

    private List<String[]> readDelimitedFile(Path file, char delimiter) {
        try {
            List<String[]> rows = new ArrayList<>();
            for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) {
                rows.add(splitDelimitedLine(stripBom(line), delimiter));
            }
            return rows;
        } catch (IOException ex) {
            throw new RuntimeException("Importdatei konnte nicht gelesen werden", ex);
        }
    }

    private String[] splitDelimitedLine(String line, char delimiter) {
        List<String> columns = new ArrayList<>();
        StringBuilder current = new StringBuilder();
        boolean quoted = false;
        for (int i = 0; i < line.length(); i++) {
            char currentChar = line.charAt(i);
            if (currentChar == '"') {
                if (quoted && i + 1 < line.length() && line.charAt(i + 1) == '"') {
                    current.append('"');
                    i++;
                } else {
                    quoted = !quoted;
                }
            } else if (currentChar == delimiter && !quoted) {
                columns.add(current.toString().trim());
                current.setLength(0);
            } else {
                current.append(currentChar);
            }
        }
        columns.add(current.toString().trim());
        return columns.toArray(String[]::new);
    }

    private boolean isBlankRow(String[] row) {
        if (row == null || row.length == 0) {
            return true;
        }
        for (String value : row) {
            if (value != null && !value.isBlank()) {
                return false;
            }
        }
        return true;
    }

    private String column(String[] row, int index) {
        if (row == null || index < 0 || index >= row.length) {
            return null;
        }
        return blankToNull(row[index]);
    }

    private String normalizeCountry(String country) {
        if (country == null || country.isBlank()) {
            return "DE";
        }
        return country.trim().toUpperCase(Locale.ROOT);
    }

    private String normalizeZipcode(String zipcode, String country) {
        if (zipcode == null || zipcode.isBlank()) {
            return null;
        }
        String normalized = zipcode.trim();
        if ("DE".equalsIgnoreCase(country) && normalized.chars().allMatch(Character::isDigit) && normalized.length() < 5) {
            normalized = "0".repeat(5 - normalized.length()) + normalized;
        }
        return normalized;
    }

    private Integer parseInteger(String value) {
        if (value == null || value.isBlank()) {
            return null;
        }
        try {
            return Integer.parseInt(value.trim());
        } catch (NumberFormatException ex) {
            return null;
        }
    }

    private BigDecimal parseDecimal(String value) {
        if (value == null || value.isBlank()) {
            return BigDecimal.ZERO;
        }
        try {
            return new BigDecimal(value.trim().replace(",", "."));
        } catch (NumberFormatException ex) {
            return BigDecimal.ZERO;
        }
    }

    private BigDecimal zeroIfNull(BigDecimal value) {
        return value != null ? value : BigDecimal.ZERO;
    }

    private String blankToNull(String value) {
        if (value == null) {
            return null;
        }
        String trimmed = value.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }

    private String relationKey(Long left, Long right) {
        return left + "|" + right;
    }

    private ImportFileDto toFileDto(Path path) {
        try {
            ImportFileDto dto = new ImportFileDto();
            dto.setFileName(path.getFileName().toString());
            dto.setSize(Files.size(path));
            dto.setModifiedAt(LocalDateTime.ofInstant(
                    Files.getLastModifiedTime(path).toInstant(),
                    ZoneId.systemDefault()
            ));
            return dto;
        } catch (IOException ex) {
            throw new RuntimeException("Dateiinformation konnte nicht gelesen werden", ex);
        }
    }

    private Path resolveFile(String processKey, String fileName) {
        ProcessDefinition definition = definition(processKey);
        String sanitized = sanitizeFileName(fileName);
        Path file = processDirectory(definition).resolve(sanitized).normalize();
        if (!file.startsWith(processDirectory(definition))) {
            throw new IllegalArgumentException("Dateiname ist ungueltig");
        }
        if (!Files.isRegularFile(file)) {
            throw new IllegalArgumentException("Datei nicht gefunden: " + sanitized);
        }
        return file;
    }

    private Path processDirectory(ProcessDefinition definition) {
        Path dir = importRoot.resolve(definition.directoryName).normalize();
        try {
            Files.createDirectories(dir);
            return dir;
        } catch (IOException ex) {
            throw new RuntimeException("Importverzeichnis konnte nicht vorbereitet werden", ex);
        }
    }

    private String sanitizeFileName(String fileName) {
        String candidate = Optional.ofNullable(fileName)
                .map(name -> Path.of(name).getFileName().toString())
                .orElse("import.dat")
                .replaceAll("[^A-Za-z0-9._-]", "_");
        if (candidate.isBlank()) {
            return "import.dat";
        }
        return candidate;
    }

    private ProcessDefinition definition(String processKey) {
        ProcessDefinition definition = processDefinitions.get(processKey);
        if (definition == null) {
            throw new IllegalArgumentException("Unbekannter Importprozess: " + processKey);
        }
        return definition;
    }

    private String stripBom(String line) {
        if (line == null || line.isEmpty()) {
            return line;
        }
        if (line.charAt(0) == '\uFEFF') {
            return line.substring(1);
        }
        return line;
    }

    private Map<String, ProcessDefinition> buildProcessDefinitions() {
        Map<String, ProcessDefinition> definitions = new LinkedHashMap<>();
        definitions.put("ZONE", nativeProcess(
                "ZONE",
                "Zonenimport",
                "Native NG-Uebernahme fuer `servicezone` und `servicezonemapping`. Die Datei ersetzt die gemappten PLZ je importierter Zone fuer den ausgewaehlten Kunden.",
                "Schema: `zone_no;country;zipcode;price;price2;price3`. Beispiel: `1;DE;04109;12,50;0;0`.",
                true,
                "zone",
                "Hoch",
                "Kundenservice / Import-Workspace",
                "Standardisierte Kernmatrix fuer Kundenzonen; Legacy-Skript bleibt als direkte NG-Paritaet erhalten.",
                "import_ZONE.php"
        ));
        definitions.put("RADIUSZONE", nativeProcess(
                "RADIUSZONE",
                "Radiuszonenimport",
                "Native NG-Uebernahme fuer `serviceradius`. Die Datei ersetzt die importierten Radiusbereiche fuer den ausgewaehlten Kunden.",
                "Schema: `radiusarea_no;zipcode;district`. Beispiel: `2;04109;Mitte`.",
                true,
                "radiuszone",
                "Hoch",
                "Kundenservice / Import-Workspace",
                "Standardisierte Radiusmatrix mit direkter NG-Datenstruktur.",
                "import_RADIUSZONE.php"
        ));
        definitions.put("ZONEAGIO", nativeProcess(
                "ZONEAGIO",
                "Zonen-Agio",
                "Native NG-Uebernahme fuer kundenspezifische PLZ-Aufschlaege auf vorhandenen Zonenmappings.",
                "Schema: `zipcode;price`. Beispiel: `04109;2,50`.",
                true,
                "zoneagio",
                "Hoch",
                "Kundenservice / Import-Workspace",
                "Kleiner, klar abgegrenzter Legacy-Standardimport auf bestehende `servicezonemapping`-Felder.",
                "import_ZONEAGIO.php"
        ));
        definitions.put("ZIPCODE_NEIGHBOURS", nativeProcess(
                "ZIPCODE_NEIGHBOURS",
                "PLZ-Nachbarschaften",
                "Native NG-Uebernahme fuer `serviceplzneighbour` zur Pflege von Routing- und Preisnachbarschaften pro Niederlassung.",
                "Schema: `base_zipcode;neighbour_1;...;neighbour_10`. Beispiel: `80331;80539;80469;80538`.",
                false,
                "zipcode-neighbours",
                "Hoch",
                "Geo-Operations / Import-Workspace",
                "Standardisierte HQ-Stammdatenpflege ohne kundenspezifische Parserlogik.",
                "import_ZIPCODE_NEIGHBOURS.php"
        ));
        definitions.put("TOURS", archivedProcess(
                "TOURS",
                "Touren-Sammelimport",
                "Legacy-Bulkimport fuer Tour-/Stationslisten mit Tagessemantik. Im NG bleibt dieser Pfad bewusst als dokumentierter Archivfall fuer Altdateien und Nachvollziehbarkeit erhalten.",
                "Kernspalten laut Legacy-Readme: Tourname mit Datumsanteil, Kommissionsnummer, Firmenname, Bemerkung, Positionskennung, PLZ, Ort, Strasse, Hausnummer, optionale Abfahrtszeit.",
                false,
                "tours",
                "Hoch",
                "NG-Auftragsverwaltung / Tracking / bestehende XML-Services",
                "Im Repository liegen keine belastbaren aktuellen Staging-Dateien fuer diesen Massenimport; operative Neuanlage laeuft im NG ueber Auftragsdialoge und bereits portierte Schnittstellen, Altdateien bleiben nur archiviert.",
                "import_TOURS.php"
        ));
        definitions.put("JOB", archivedProcess(
                "JOB",
                "Standardisierte Job-Batchimporte",
                "Legacy-Auftragsimporte mit generischem CSV-Mapping, Preislogik oder Zonenbezug. Die Formate bleiben im NG als dokumentierte Archivgruppe sichtbar, werden aber nicht mehr nativ ausgefuehrt.",
                "Typische Legacy-Spalten: Auftragskennung, Tourname, Stationsdaten, Adresse, Zeitfenster, Preis- oder Zonenkontext.",
                false,
                "job",
                "Hoch",
                "NG-Auftragsverwaltung / portierte Preis- und Servicepfade",
                "Mehrere stark formatgebundene Varianten ohne belastbaren aktuellen Staging-Bestand; funktional durch NG-Auftragsanlage, Jobcalculator und bestehende Service-Schnittstellen ersetzt.",
                "import_JOB.php",
                "import_STANDARD_01.php",
                "import_STANDARD_02.php",
                "import_ZONE_JOBS.php",
                "import_JOBS_BY_PRICEMATRIX.php",
                "import_BMW.php"
        ));
        definitions.put("JOBARTICLE", archivedProcess(
                "JOBARTICLE",
                "Auftragsartikelimport",
                "Legacy-CSV fuer `tourarticle`-Beistellungen und Installationskontext. Im NG nur noch als archivierter Handover-Pfad dokumentiert.",
                "Typische Legacy-Spalten: Job-ID, Artikelnummer, Beschreibung, Artikelpreis, Tourartikelpreis, Menge, Lagerhinweis, Installationsmodus, Bemerkung.",
                false,
                "jobarticle",
                "Mittel",
                "NG-Auftragsdetail / Artikel- und Lagerpfade",
                "Die NG-Auftragsdetails und Lagerfunktionen decken den operativen Pflegefall ab; der CSV-Import war stark kundenspezifisch und bleibt daher archiviert.",
                "import_JOBARTICLE.php"
        ));
        definitions.put("JOBSERVICE", archivedProcess(
                "JOBSERVICE",
                "Auftragsleistungsimport",
                "Legacy-Sammelimport fuer Zusatzleistungen, Zahlungsarten und servicebezogene Abrechnungswerte. Im NG bleibt der Pfad als dokumentierter Archivfall sichtbar.",
                "Typische Legacy-Spalten: Job-ID, Leistungskennung, Zahlart, EV-/IKEA-/Unternehmerpreise, Rechnungsreferenzen, Statusflags.",
                false,
                "jobservice",
                "Mittel",
                "NG-Jobcalculator / Serviceprotokolle / Rechnungsfluss",
                "Die operative Service- und Preislogik ist im NG fachlich portiert; der CSV-Massenimport war ein proprietaerer Altpfad ohne aktuellen Standardbedarf.",
                "import_JOBSERVICE.php"
        ));
        definitions.put("COUNT", archivedProcess(
                "COUNT",
                "TOURS-Varianten",
                "Kundenspezifische Ableitungen des `TOURS`-Schemas mit festen Startzeit- oder Formatregeln.",
                "Varianten von `import_TOURS` mit gleicher Tour-/Stationsstruktur und kleinem Regelaufsatz.",
                false,
                "count",
                "Mittel",
                "NG-Auftragsverwaltung / Tracking",
                "Diese Varianten unterscheiden sich nur in Altformatdetails und werden ohne belastbare aktuelle Nutzung bewusst nicht mehr nativ nachgebaut.",
                "import_COUNT.php",
                "import_TIMS.php",
                "import_ZFBG.php"
        ));
        definitions.put("CUSTOMER_JOBS", archivedProcess(
                "CUSTOMER_JOBS",
                "Kundenspezifische Formatimporte",
                "Sammlung der historisch gewachsenen Kunden- und Partnerformate fuer Job- oder Sendungsimporte. Im NG dienen diese Eintraege nur noch der dokumentierten Archivierung von Altdateien.",
                "Nicht standardisierte Fremddaten mit proprietaeren Dateinamen, Mappingregeln und kundenspezifischen Sonderfaellen.",
                false,
                "customer-jobs",
                "Mittel",
                "Bestehende NG-Workspaces oder externe Partnerintegrationen",
                "Die Formate sind stark kundenindividuell, im Repository ohne belastbare aktuelle Importnutzung und daher bewusst als archivierte Sonderfaelle klassifiziert.",
                "import_ALLERGO.php",
                "import_APO.php",
                "import_BAUHAUS.php",
                "import_BAUHAUS_SRC_HORNBACH.php",
                "import_BAUHAUS_SRC_OBI.php",
                "import_DHL.php",
                "import_EXPRESS.php",
                "import_FAMO.php",
                "import_GLOBUS.php",
                "import_HAGEBAU.php",
                "import_HORNBACH.php",
                "import_KABUCO.php",
                "import_LYRECO.php",
                "import_OBI.php",
                "import_SCHWEMANN.php",
                "import_SERVONA.php",
                "import_TOOM.php",
                "import_UNIMET.php"
        ));
        definitions.put("ARTICLE_MASTER", archivedProcess(
                "ARTICLE_MASTER",
                "Artikel- und Artikelgruppenimporte",
                "Legacy-Stammdatenimporte fuer Artikel und Artikelgruppen. Im NG aktuell nur als dokumentierter Archivpfad vorhanden.",
                "Typische Legacy-Spalten: Artikelschluessel, Bezeichnung, Gewicht, Preisstaffeln, Packstuecke bzw. Gruppenschluessel und Gruppenname.",
                false,
                "article-master",
                "Niedrig",
                "Lager-/Artikelpflege ausserhalb des aktuellen Importworkspaces",
                "Die operativen Artikelpfade liegen nicht mehr in einem generischen HQ-CSV-Import; Altdateien werden deshalb nur noch archiviert und referenziert.",
                "import_ARTICLE.php",
                "import_ARTICLEGROUP.php"
        ));
        definitions.put("OBJECTS", archivedProcess(
                "OBJECTS",
                "Objektimport",
                "Historischer Spezialimport fuer Legacy-Objektstammdaten und Metafelder.",
                "Proprietaeres Objektformat mit eigener Sicherheits- und Dateinamenslogik.",
                false,
                "objects",
                "Niedrig",
                "Metafeld-Administration / manuelle Datenuebernahme",
                "Kein standardisierter NG-Nachfolgeschnitt im Importbereich; die Nutzung bleibt als Altpfad dokumentiert.",
                "import_OBJECTS.php"
        ));
        definitions.put("SOAP_HHA", archivedProcess(
                "SOAP_HHA",
                "Spezialkonnektoren und Fremdfeeds",
                "Historische Sonderimporte fuer HHA-, SOAP-, ITCS- und UTA-Datenfluesse.",
                "Proprietaere Partner- und Geraetestatusformate ausserhalb des standardisierten NG-Importkerns.",
                false,
                "soap-hha",
                "Niedrig",
                "Portierte XML-/Partnerintegrationen oder externe Betriebsstrecken",
                "Diese Pfade sind Integrationssonderfaelle mit proprietaeren Gegenstellen und werden im NG nicht als generische Dateiimporte weitergefuehrt.",
                "import_HHA.php",
                "import_SOAP_HHA.php",
                "import_ITCS.php",
                "import_UTA.php"
        ));
        definitions.put("SPECIAL_HANDOVER", archivedProcess(
                "SPECIAL_HANDOVER",
                "Einmalimporte und Technik-Handover",
                "Auffangkorb fuer generische Einmalskripte, Altmigrationen und technische Sonderwege.",
                "Beliebige Begleitdateien, Mappinglisten oder kundenbezogene Rohimporte ohne stabilen Legacy-Standard.",
                false,
                "special-handover",
                "Niedrig",
                "Manuelle Archivierung ausserhalb operativer NG-Prozesse",
                "Diese Skripte waren bereits im Legacy punktuelle Hilfspfade und werden nur noch als nachvollziehbares Archiv erhalten.",
                "import_generic.php",
                "import_job_.php",
                "import_job_cc.php",
                "import_job_ls.php",
                "import_saturn.php",
                "import_tourbo.php"
        ));
        return definitions;
    }

    private ProcessDefinition nativeProcess(String key,
                                            String label,
                                            String description,
                                            String schemaHint,
                                            boolean customerRequired,
                                            String directoryName,
                                            String priority,
                                            String successorWorkspace,
                                            String decisionSummary,
                                            String... legacySources) {
        return new ProcessDefinition(
                key,
                label,
                description,
                schemaHint,
                true,
                customerRequired,
                directoryName,
                "Nativ",
                priority,
                decisionSummary,
                successorWorkspace,
                List.of(legacySources)
        );
    }

    private ProcessDefinition archivedProcess(String key,
                                              String label,
                                              String description,
                                              String schemaHint,
                                              boolean customerRequired,
                                              String directoryName,
                                              String priority,
                                              String successorWorkspace,
                                              String decisionSummary,
                                              String... legacySources) {
        return new ProcessDefinition(
                key,
                label,
                description,
                schemaHint,
                false,
                customerRequired,
                directoryName,
                "Archiviert",
                priority,
                decisionSummary,
                successorWorkspace,
                List.of(legacySources)
        );
    }

    private static class ProcessDefinition {
        private final String key;
        private final String label;
        private final String description;
        private final String schemaHint;
        private final boolean nativeExecution;
        private final boolean customerRequired;
        private final String directoryName;
        private final String status;
        private final String priority;
        private final String decisionSummary;
        private final String successorWorkspace;
        private final List<String> legacySources;

        private ProcessDefinition(String key,
                                  String label,
                                  String description,
                                  String schemaHint,
                                  boolean nativeExecution,
                                  boolean customerRequired,
                                  String directoryName,
                                  String status,
                                  String priority,
                                  String decisionSummary,
                                  String successorWorkspace,
                                  List<String> legacySources) {
            this.key = key;
            this.label = label;
            this.description = description;
            this.schemaHint = schemaHint;
            this.nativeExecution = nativeExecution;
            this.customerRequired = customerRequired;
            this.directoryName = directoryName;
            this.status = status;
            this.priority = priority;
            this.decisionSummary = decisionSummary;
            this.successorWorkspace = successorWorkspace;
            this.legacySources = legacySources;
        }

        private ImportProcessDto toDto() {
            ImportProcessDto dto = new ImportProcessDto();
            dto.setKey(key);
            dto.setLabel(label);
            dto.setDescription(description);
            dto.setSchemaHint(schemaHint);
            dto.setNativeExecution(nativeExecution);
            dto.setCustomerRequired(customerRequired);
            dto.setStatus(status);
            dto.setPriority(priority);
            dto.setDecisionSummary(decisionSummary);
            dto.setSuccessorWorkspace(successorWorkspace);
            dto.setLegacySources(legacySources);
            return dto;
        }
    }

    private static class ZoneAccumulator {
        private Integer zoneNo;
        private BigDecimal price = BigDecimal.ZERO;
        private BigDecimal price2 = BigDecimal.ZERO;
        private BigDecimal price3 = BigDecimal.ZERO;
        private final Set<String> zipcodes = new LinkedHashSet<>();
    }

    private record RadiusRow(Integer areaNo, String zipcode, String district) {
    }
}
