package de.votian.services.service;

import de.votian.services.config.ParameterKeys;
import de.votian.services.dto.UserSessionDto;
import de.votian.services.security.AccessControlService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.text.Normalizer;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;

@Service
@Transactional
public class GeoOperationsWorkspaceService {

    private static final DateTimeFormatter DISPLAY_TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private static final Pattern MULTI_SPACE = Pattern.compile("\\s+");
    private static final int DEFAULT_TRAVELTIME_MINUTES = 30;
    private static final int MAX_ADDRESS_ROWS = 500;
    private static final int MAX_SIMILARITY_ROWS = 100;
    private static final int MAX_TRAVELTIME_ROWS = 4000;

    private final JdbcTemplate jdbcTemplate;
    private final AccessControlService accessControlService;
    private final ParameterService parameterService;

    public GeoOperationsWorkspaceService(JdbcTemplate jdbcTemplate,
                                         AccessControlService accessControlService,
                                         ParameterService parameterService) {
        this.jdbcTemplate = jdbcTemplate;
        this.accessControlService = accessControlService;
        this.parameterService = parameterService;
    }

    @Transactional(readOnly = true)
    public Bootstrap bootstrap(UserSessionDto user, Long headquartersId) {
        requireWorkspaceAccess(user, headquartersId);
        return new Bootstrap(
                accessControlService.canViewGlobalMap(user),
                accessControlService.canManageAddressDirectory(user),
                accessControlService.canViewCustomers(user),
                accessControlService.canViewCouriers(user),
                accessControlService.canManageTravelTimes(user),
                defaultCity(headquartersId),
                "1".equals(parameterService.getParameterValueWithFallback(ParameterKeys.MASK_ZIP_CHK_SYNTAX_DISABLED, headquartersId)),
                DEFAULT_TRAVELTIME_MINUTES
        );
    }

    @Transactional(readOnly = true)
    public MapDashboard loadMapDashboard(UserSessionDto user, Long headquartersId) {
        requireMapAccess(user, headquartersId);
        HeadquartersPoint headquarters = jdbcTemplate.query(
                """
                        SELECT hq_id,
                               COALESCE(hq_name, '') AS hq_name,
                               COALESCE(hq_mnemonic, '') AS hq_mnemonic,
                               hq_gps_lat,
                               hq_gps_long
                        FROM headquarters
                        WHERE hq_id = ?
                        """,
                rs -> rs.next()
                        ? new HeadquartersPoint(
                        rs.getLong("hq_id"),
                        blankToEmpty(rs.getString("hq_name")),
                        blankToEmpty(rs.getString("hq_mnemonic")),
                        nullableDouble(rs.getObject("hq_gps_lat")),
                        nullableDouble(rs.getObject("hq_gps_long"))
                )
                        : null,
                headquartersId
        );

        Timestamp staleThreshold = Timestamp.valueOf(LocalDateTime.now().minusDays(1));
        List<MapCourierRow> couriers = jdbcTemplate.query(
                """
                        SELECT cr.cr_id,
                               COALESCE(cr.cr_eid, '') AS cr_eid,
                               COALESCE(cr.cr_sid, '') AS cr_sid,
                               COALESCE(cmp.cmp_comp, '') AS cmp_comp,
                               COALESCE(mt.mt_value, '') AS vehicle_type,
                               COALESCE(cr.cr_available, '0') AS cr_available,
                               cr.cr_availabletime,
                               COALESCE(cr.cr_locationzipcode, '') AS cr_locationzipcode,
                               cr.cr_gps_time,
                               COALESCE(cr.cr_gps_type, 0) AS cr_gps_type,
                               COALESCE(cr.cr_mobile_pda, '') AS cr_mobile_pda,
                               COALESCE(usr.usr_phone, '') AS usr_phone,
                               COALESCE(usr.usr_phone2, '') AS usr_phone2,
                               CASE
                                   WHEN cr.cr_gps_lat IS NOT NULL AND cr.cr_gps_long IS NOT NULL THEN cr.cr_gps_lat
                                   ELSE srvp.srvp_latitude
                               END AS map_latitude,
                               CASE
                                   WHEN cr.cr_gps_lat IS NOT NULL AND cr.cr_gps_long IS NOT NULL THEN cr.cr_gps_long
                                   ELSE srvp.srvp_longitude
                               END AS map_longitude,
                               CASE
                                   WHEN cr.cr_gps_lat IS NOT NULL AND cr.cr_gps_long IS NOT NULL THEN 1
                                   ELSE 0
                               END AS gps_based
                        FROM courier AS cr
                        JOIN company AS cmp ON cmp.cmp_id = cr.cmp_id
                        JOIN user AS usr ON usr.usr_id = cr.usr_id
                        LEFT JOIN metatype AS mt ON mt.mt_type = 'vehicletype' AND mt.mt_sort = cr.vht_id
                        LEFT JOIN serviceplz AS srvp ON srvp.srvp_plz = cr.cr_locationzipcode AND srvp.srvp_valid = '1'
                        WHERE cr.hq_id = ?
                          AND COALESCE(cr.cr_sid, '') <> ''
                          AND (
                                cr.cr_gps_time >= ?
                                OR (srvp.srvp_latitude IS NOT NULL AND srvp.srvp_longitude IS NOT NULL)
                              )
                        ORDER BY cr.cr_gps_time IS NULL, cr.cr_gps_time DESC, cr.cr_sid
                        LIMIT 800
                        """,
                (rs, rowNum) -> {
                    Double latitude = nullableDouble(rs.getObject("map_latitude"));
                    Double longitude = nullableDouble(rs.getObject("map_longitude"));
                    boolean gpsBased = rs.getInt("gps_based") == 1;
                    return new MapCourierRow(
                            rs.getLong("cr_id"),
                            blankToEmpty(rs.getString("cr_eid")),
                            blankToEmpty(rs.getString("cr_sid")),
                            blankToEmpty(rs.getString("cmp_comp")),
                            blankToEmpty(rs.getString("vehicle_type")),
                            "1".equals(blankToEmpty(rs.getString("cr_available"))),
                            blankToEmpty(rs.getString("cr_locationzipcode")),
                            formatTimestamp(rs.getTimestamp("cr_availabletime")),
                            formatTimestamp(rs.getTimestamp("cr_gps_time")),
                            gpsBased ? gpsTypeLabel(rs.getInt("cr_gps_type")) : "PLZ-Fallback",
                            blankToEmpty(rs.getString("cr_mobile_pda")),
                            blankToEmpty(rs.getString("usr_phone")),
                            blankToEmpty(rs.getString("usr_phone2")),
                            latitude,
                            longitude,
                            gpsBased,
                            googleMapsUrl(latitude, longitude)
                    );
                },
                headquartersId,
                staleThreshold
        );

        long mappedCouriers = couriers.stream().filter(MapCourierRow::hasPoint).count();
        return new MapDashboard(
                formatTimestamp(Timestamp.valueOf(LocalDateTime.now())),
                headquarters,
                couriers.size(),
                (int) mappedCouriers,
                couriers
        );
    }

    @Transactional(readOnly = true)
    public AddressSearchResult searchAddresses(UserSessionDto user,
                                               Long headquartersId,
                                               String street,
                                               String zipcode,
                                               String city,
                                               String country) {
        requireAddressAccess(user, headquartersId);
        String normalizedStreet = safeTrim(street);
        String normalizedZipcode = safeTrim(zipcode);
        String normalizedCity = safeTrim(city);
        String normalizedCountry = normalizeCountry(country);
        if (normalizedCity.isBlank()) {
            normalizedCity = defaultCity(headquartersId);
        }

        if (!isValidAddressSearch(normalizedStreet, normalizedZipcode, normalizedCity)) {
            throw new IllegalArgumentException("Eingabe erfordert mindestens 5 Zeichen in der Strasse oder mindestens 2 Zeichen in Verbindung mit Ort oder PLZ.");
        }

        List<AddressRow> addressRows = jdbcTemplate.query(
                """
                        SELECT ad_id,
                               COALESCE(ad_street, '') AS ad_street,
                               COALESCE(ad_zipcode, '') AS ad_zipcode,
                               COALESCE(ad_city, '') AS ad_city,
                               COALESCE(ad_country, '') AS ad_country
                        FROM address
                        WHERE (? = '' OR ad_street LIKE CONCAT(?, '%'))
                          AND (? = '' OR ad_zipcode LIKE CONCAT(?, '%'))
                          AND (? = '' OR ad_city LIKE CONCAT(?, '%'))
                          AND (? = '' OR ad_country LIKE CONCAT(?, '%'))
                        ORDER BY ad_street, ad_zipcode
                        LIMIT ?
                        """,
                (rs, rowNum) -> new AddressRow(
                        rs.getLong("ad_id"),
                        blankToEmpty(rs.getString("ad_street")),
                        blankToEmpty(rs.getString("ad_zipcode")),
                        blankToEmpty(rs.getString("ad_city")),
                        blankToEmpty(rs.getString("ad_country"))
                ),
                normalizedStreet,
                normalizedStreet,
                normalizedZipcode,
                normalizedZipcode,
                normalizedCity,
                normalizedCity,
                normalizedCountry,
                normalizedCountry,
                MAX_ADDRESS_ROWS
        );

        List<ValidatedStreetRow> validatedStreetRows = jdbcTemplate.query(
                """
                        SELECT st_id,
                               COALESCE(st_district, '') AS st_district,
                               COALESCE(st_street, '') AS st_street,
                               COALESCE(st_zipcode, '') AS st_zipcode,
                               COALESCE(st_city, '') AS st_city,
                               COALESCE(st_country, '') AS st_country
                        FROM phoenix_special.street
                        WHERE (? = '' OR st_street LIKE CONCAT(?, '%'))
                          AND (? = '' OR st_zipcode LIKE CONCAT(?, '%'))
                          AND (? = '' OR st_city LIKE CONCAT(?, '%'))
                          AND (? = '' OR COALESCE(st_country, '') LIKE CONCAT(?, '%'))
                        ORDER BY st_street, st_zipcode
                        LIMIT ?
                        """,
                (rs, rowNum) -> new ValidatedStreetRow(
                        rs.getLong("st_id"),
                        blankToEmpty(rs.getString("st_district")),
                        blankToEmpty(rs.getString("st_street")),
                        blankToEmpty(rs.getString("st_zipcode")),
                        blankToEmpty(rs.getString("st_city")),
                        blankToEmpty(rs.getString("st_country"))
                ),
                normalizedStreet,
                normalizedStreet,
                normalizedZipcode,
                normalizedZipcode,
                normalizedCity,
                normalizedCity,
                normalizedCountry,
                normalizedCountry,
                MAX_ADDRESS_ROWS
        );

        return new AddressSearchResult(addressRows, validatedStreetRows);
    }

    public AddressRow updateAddress(UserSessionDto user,
                                    Long headquartersId,
                                    Long addressId,
                                    String street,
                                    String zipcode,
                                    String city,
                                    String country) {
        requireAddressAccess(user, headquartersId);
        String normalizedStreet = requireText(street, "Strasse");
        String normalizedZipcode = requireText(zipcode, "PLZ");
        String normalizedCity = requireText(city, "Ort");
        String normalizedCountry = normalizeCountry(country);

        int updated = jdbcTemplate.update(
                """
                        UPDATE address
                        SET ad_street = ?,
                            ad_zipcode = ?,
                            ad_city = ?,
                            ad_country = ?
                        WHERE ad_id = ?
                        """,
                normalizedStreet,
                normalizedZipcode,
                normalizedCity,
                normalizedCountry,
                addressId
        );
        if (updated != 1) {
            throw new IllegalArgumentException("Adresse konnte nicht aktualisiert werden.");
        }
        return findAddress(addressId);
    }

    public ValidatedStreetRow createValidatedStreet(UserSessionDto user,
                                                    Long headquartersId,
                                                    String street,
                                                    String zipcode,
                                                    String city,
                                                    String country) {
        requireAddressAccess(user, headquartersId);
        String normalizedStreet = requireText(street, "Strasse");
        String normalizedZipcode = requireText(zipcode, "PLZ");
        String normalizedCity = requireText(city, "Ort");
        String normalizedCountry = normalizeCountry(country);

        Integer existing = jdbcTemplate.queryForObject(
                """
                        SELECT COUNT(*)
                        FROM phoenix_special.street
                        WHERE st_street = ?
                          AND st_zipcode = ?
                          AND st_city = ?
                        """,
                Integer.class,
                normalizedStreet,
                normalizedZipcode,
                normalizedCity
        );
        if (existing != null && existing > 0) {
            throw new IllegalArgumentException("Die Adresse existiert bereits in der Validierung.");
        }

        KeyHolder keyHolder = new GeneratedKeyHolder();
        jdbcTemplate.update(connection -> {
            PreparedStatement statement = connection.prepareStatement(
                    "INSERT INTO phoenix_special.street (st_street, st_zipcode, st_city, st_country) VALUES (?, ?, ?, ?)",
                    Statement.RETURN_GENERATED_KEYS
            );
            statement.setString(1, normalizedStreet);
            statement.setString(2, normalizedZipcode);
            statement.setString(3, normalizedCity);
            statement.setString(4, normalizedCountry);
            return statement;
        }, keyHolder);

        Long createdId = generatedId(keyHolder, "Validierungsadresse");
        return findValidatedStreet(createdId);
    }

    public void deleteValidatedStreet(UserSessionDto user, Long headquartersId, Long validatedStreetId) {
        requireAddressAccess(user, headquartersId);
        jdbcTemplate.update("DELETE FROM phoenix_special.street WHERE st_id = ?", validatedStreetId);
    }

    @Transactional(readOnly = true)
    public List<SimilarityResultRow> searchSimilarity(UserSessionDto user,
                                                      Long headquartersId,
                                                      String compareType,
                                                      String phrase) {
        requireWorkspaceAccess(user, headquartersId);
        SimilarityType similarityType = normalizeSimilarityType(compareType);
        requireSimilarityAccess(user, headquartersId, similarityType);

        List<String> terms = similarityTerms(phrase);
        if (terms.isEmpty()) {
            return List.of();
        }

        boolean filterHeadquarters = "1".equals(parameterService.getParameterValueWithFallback(
                ParameterKeys.SIMILARITY_SEARCH_FILTER_HQ,
                headquartersId
        ));

        List<SimilarityCandidate> candidates = similarityType == SimilarityType.COURIER
                ? loadCourierSimilarityCandidates(headquartersId, filterHeadquarters)
                : loadCustomerSimilarityCandidates(headquartersId, filterHeadquarters);

        return candidates.stream()
                .map(candidate -> toSimilarityResult(candidate, similarityType, terms))
                .filter(Objects::nonNull)
                .sorted(Comparator
                        .comparingInt(SimilarityResultRow::score).reversed()
                        .thenComparing(SimilarityResultRow::companyName, String.CASE_INSENSITIVE_ORDER)
                        .thenComparing(SimilarityResultRow::entityCode, String.CASE_INSENSITIVE_ORDER))
                .limit(MAX_SIMILARITY_ROWS)
                .toList();
    }

    @Transactional(readOnly = true)
    public TravelTimeResult loadTravelTimes(UserSessionDto user,
                                            Long headquartersId,
                                            String mode,
                                            String zipcodeFrom,
                                            String zipcodeTo) {
        requireTravelTimeAccess(user, headquartersId);
        String normalizedMode = normalizeTravelTimeMode(mode);
        String normalizedFrom = requireZipcodeRangeValue(zipcodeFrom, headquartersId);
        String normalizedTo = requireZipcodeRangeValue(zipcodeTo, headquartersId);
        if (normalizedFrom.compareTo(normalizedTo) > 0) {
            String swap = normalizedFrom;
            normalizedFrom = normalizedTo;
            normalizedTo = swap;
        }

        List<TravelTimeRow> rows;
        if ("bwv".equals(normalizedMode)) {
            rows = jdbcTemplate.query(
                    """
                            SELECT srvp_id,
                                   srvp_plz,
                                   srvp_traveltime
                            FROM serviceplz
                            WHERE srvp_plz >= ?
                              AND srvp_plz <= ?
                              AND srvp_valid = '1'
                            ORDER BY srvp_plz
                            LIMIT ?
                            """,
                    (rs, rowNum) -> new TravelTimeRow(
                            rs.getLong("srvp_id"),
                            blankToEmpty(rs.getString("srvp_plz")),
                            nullableInteger(rs.getObject("srvp_traveltime"))
                    ),
                    normalizedFrom,
                    normalizedTo,
                    MAX_TRAVELTIME_ROWS
            );
        } else {
            rows = jdbcTemplate.query(
                    """
                            SELECT srvp.srvp_id,
                                   srvp.srvp_plz,
                                   srvpt.srvpt_traveltime
                            FROM serviceplz AS srvp
                            LEFT JOIN serviceplztraveltime AS srvpt
                                   ON srvpt.srvp_id = srvp.srvp_id
                                  AND srvpt.hq_id = ?
                            WHERE srvp.srvp_plz >= ?
                              AND srvp.srvp_plz <= ?
                              AND srvp.srvp_valid = '1'
                            ORDER BY srvp.srvp_plz
                            LIMIT ?
                            """,
                    (rs, rowNum) -> new TravelTimeRow(
                            rs.getLong("srvp_id"),
                            blankToEmpty(rs.getString("srvp_plz")),
                            nullableInteger(rs.getObject("srvpt_traveltime"))
                    ),
                    headquartersId,
                    normalizedFrom,
                    normalizedTo,
                    MAX_TRAVELTIME_ROWS
            );
        }

        return new TravelTimeResult(normalizedMode, rows.size(), rows);
    }

    public SaveResult saveTravelTimes(UserSessionDto user,
                                      Long headquartersId,
                                      String mode,
                                      List<TravelTimeUpdate> rows) {
        requireTravelTimeAccess(user, headquartersId);
        String normalizedMode = normalizeTravelTimeMode(mode);
        if (rows == null || rows.isEmpty()) {
            throw new IllegalArgumentException("Es wurden keine Anfahrtszeiten uebergeben.");
        }

        int updatedCount = 0;
        for (TravelTimeUpdate row : rows) {
            if (row == null || row.serviceZipId() == null || row.serviceZipId() <= 0) {
                continue;
            }
            int travelTime = row.travelTime() != null && row.travelTime() > 0
                    ? row.travelTime()
                    : DEFAULT_TRAVELTIME_MINUTES;

            if ("bwv".equals(normalizedMode)) {
                jdbcTemplate.update(
                        "UPDATE serviceplz SET srvp_traveltime = ? WHERE srvp_id = ?",
                        travelTime,
                        row.serviceZipId()
                );
            } else {
                int count = jdbcTemplate.update(
                        "UPDATE serviceplztraveltime SET srvpt_traveltime = ? WHERE srvp_id = ? AND hq_id = ?",
                        travelTime,
                        row.serviceZipId(),
                        headquartersId
                );
                if (count == 0) {
                    jdbcTemplate.update(
                            "INSERT INTO serviceplztraveltime (srvp_id, hq_id, srvpt_traveltime) VALUES (?, ?, ?)",
                            row.serviceZipId(),
                            headquartersId,
                            travelTime
                    );
                }
            }
            updatedCount++;
        }
        return new SaveResult(updatedCount);
    }

    private void requireWorkspaceAccess(UserSessionDto user, Long headquartersId) {
        if (!accessControlService.canViewGeoOperations(user)
                || !accessControlService.canAccessHeadquarters(user, headquartersId)) {
            throw new IllegalArgumentException("Geo-Workspace ist fuer den Benutzer nicht freigegeben.");
        }
    }

    private void requireMapAccess(UserSessionDto user, Long headquartersId) {
        requireWorkspaceAccess(user, headquartersId);
        if (!accessControlService.canViewGlobalMap(user)) {
            throw new IllegalArgumentException("Kartenuebersicht ist fuer den Benutzer nicht freigegeben.");
        }
    }

    private void requireAddressAccess(UserSessionDto user, Long headquartersId) {
        requireWorkspaceAccess(user, headquartersId);
        if (!accessControlService.canManageAddressDirectory(user)) {
            throw new IllegalArgumentException("Adressverwaltung ist fuer den Benutzer nicht freigegeben.");
        }
    }

    private void requireTravelTimeAccess(UserSessionDto user, Long headquartersId) {
        requireWorkspaceAccess(user, headquartersId);
        if (!accessControlService.canManageTravelTimes(user)) {
            throw new IllegalArgumentException("Anfahrtszeiten duerfen fuer den Benutzer nicht gepflegt werden.");
        }
    }

    private void requireSimilarityAccess(UserSessionDto user, Long headquartersId, SimilarityType type) {
        requireWorkspaceAccess(user, headquartersId);
        if (!accessControlService.canViewSimilaritySearch(user)) {
            throw new IllegalArgumentException("Aehnlichkeitssuche ist fuer den Benutzer nicht freigegeben.");
        }
        if (type == SimilarityType.CUSTOMER && !accessControlService.canViewCustomers(user)) {
            throw new IllegalArgumentException("Kundensuche ist fuer den Benutzer nicht freigegeben.");
        }
        if (type == SimilarityType.COURIER && !accessControlService.canViewCouriers(user)) {
            throw new IllegalArgumentException("Kuriersuche ist fuer den Benutzer nicht freigegeben.");
        }
    }

    private String defaultCity(Long headquartersId) {
        return jdbcTemplate.query(
                """
                        SELECT COALESCE(ad.ad_city, '') AS ad_city
                        FROM headquarters AS hq
                        JOIN company AS cmp ON cmp.cmp_id = hq.cmp_id
                        LEFT JOIN address AS ad ON ad.ad_id = cmp.ad_id
                        WHERE hq.hq_id = ?
                        """,
                rs -> rs.next() ? blankToEmpty(rs.getString("ad_city")) : "",
                headquartersId
        );
    }

    private AddressRow findAddress(Long addressId) {
        return jdbcTemplate.query(
                """
                        SELECT ad_id,
                               COALESCE(ad_street, '') AS ad_street,
                               COALESCE(ad_zipcode, '') AS ad_zipcode,
                               COALESCE(ad_city, '') AS ad_city,
                               COALESCE(ad_country, '') AS ad_country
                        FROM address
                        WHERE ad_id = ?
                        """,
                rs -> rs.next()
                        ? new AddressRow(
                        rs.getLong("ad_id"),
                        blankToEmpty(rs.getString("ad_street")),
                        blankToEmpty(rs.getString("ad_zipcode")),
                        blankToEmpty(rs.getString("ad_city")),
                        blankToEmpty(rs.getString("ad_country"))
                )
                        : null,
                addressId
        );
    }

    private ValidatedStreetRow findValidatedStreet(Long validatedStreetId) {
        return jdbcTemplate.query(
                """
                        SELECT st_id,
                               COALESCE(st_district, '') AS st_district,
                               COALESCE(st_street, '') AS st_street,
                               COALESCE(st_zipcode, '') AS st_zipcode,
                               COALESCE(st_city, '') AS st_city,
                               COALESCE(st_country, '') AS st_country
                        FROM phoenix_special.street
                        WHERE st_id = ?
                        """,
                rs -> rs.next()
                        ? new ValidatedStreetRow(
                        rs.getLong("st_id"),
                        blankToEmpty(rs.getString("st_district")),
                        blankToEmpty(rs.getString("st_street")),
                        blankToEmpty(rs.getString("st_zipcode")),
                        blankToEmpty(rs.getString("st_city")),
                        blankToEmpty(rs.getString("st_country"))
                )
                        : null,
                validatedStreetId
        );
    }

    private boolean isValidAddressSearch(String street, String zipcode, String city) {
        return street.length() > 4 || (street.length() > 1 && (zipcode.length() > 1 || city.length() > 1));
    }

    private SimilarityType normalizeSimilarityType(String compareType) {
        if ("cr".equalsIgnoreCase(compareType) || "courier".equalsIgnoreCase(compareType)) {
            return SimilarityType.COURIER;
        }
        return SimilarityType.CUSTOMER;
    }

    private List<SimilarityCandidate> loadCustomerSimilarityCandidates(Long headquartersId, boolean filterHeadquarters) {
        if (filterHeadquarters) {
            return jdbcTemplate.query(
                    """
                            SELECT cs.cs_id AS entity_id,
                                   COALESCE(cs.cs_eid, '') AS entity_code,
                                   cmp.cmp_id,
                                   COALESCE(cmp.cmp_comp, '') AS cmp_comp,
                                   COALESCE(cmp.cmp_comp2, '') AS cmp_comp2,
                                   COALESCE(cmp.cmp_match, '') AS cmp_match,
                                   COALESCE(cmp.cmp_match2, '') AS cmp_match2,
                                   COALESCE(hq.hq_mnemonic, '') AS hq_mnemonic
                            FROM customer AS cs
                            JOIN company AS cmp ON cmp.cmp_id = cs.cmp_id
                            JOIN headquarters AS hq ON hq.hq_id = cs.hq_id
                            WHERE COALESCE(cmp.cmp_archived, '0') = '0'
                              AND cs.hq_id = ?
                            ORDER BY cmp.cmp_comp, cs.cs_eid
                            """,
                    (rs, rowNum) -> new SimilarityCandidate(
                            rs.getLong("entity_id"),
                            blankToEmpty(rs.getString("entity_code")),
                            rs.getLong("cmp_id"),
                            blankToEmpty(rs.getString("cmp_comp")),
                            blankToEmpty(rs.getString("cmp_comp2")),
                            blankToEmpty(rs.getString("cmp_match")),
                            blankToEmpty(rs.getString("cmp_match2")),
                            blankToEmpty(rs.getString("hq_mnemonic"))
                    ),
                    headquartersId
            );
        }
        return jdbcTemplate.query(
                """
                        SELECT cs.cs_id AS entity_id,
                               COALESCE(cs.cs_eid, '') AS entity_code,
                               cmp.cmp_id,
                               COALESCE(cmp.cmp_comp, '') AS cmp_comp,
                               COALESCE(cmp.cmp_comp2, '') AS cmp_comp2,
                               COALESCE(cmp.cmp_match, '') AS cmp_match,
                               COALESCE(cmp.cmp_match2, '') AS cmp_match2,
                               COALESCE(hq.hq_mnemonic, '') AS hq_mnemonic
                        FROM customer AS cs
                        JOIN company AS cmp ON cmp.cmp_id = cs.cmp_id
                        JOIN headquarters AS hq ON hq.hq_id = cs.hq_id
                        WHERE COALESCE(cmp.cmp_archived, '0') = '0'
                        ORDER BY cmp.cmp_comp, cs.cs_eid
                        """,
                (rs, rowNum) -> new SimilarityCandidate(
                        rs.getLong("entity_id"),
                        blankToEmpty(rs.getString("entity_code")),
                        rs.getLong("cmp_id"),
                        blankToEmpty(rs.getString("cmp_comp")),
                        blankToEmpty(rs.getString("cmp_comp2")),
                        blankToEmpty(rs.getString("cmp_match")),
                        blankToEmpty(rs.getString("cmp_match2")),
                        blankToEmpty(rs.getString("hq_mnemonic"))
                )
        );
    }

    private List<SimilarityCandidate> loadCourierSimilarityCandidates(Long headquartersId, boolean filterHeadquarters) {
        if (filterHeadquarters) {
            return jdbcTemplate.query(
                    """
                            SELECT cr.cr_id AS entity_id,
                                   COALESCE(cr.cr_eid, '') AS entity_code,
                                   cmp.cmp_id,
                                   COALESCE(cmp.cmp_comp, '') AS cmp_comp,
                                   COALESCE(cmp.cmp_comp2, '') AS cmp_comp2,
                                   COALESCE(cmp.cmp_match, '') AS cmp_match,
                                   COALESCE(cmp.cmp_match2, '') AS cmp_match2,
                                   COALESCE(hq.hq_mnemonic, '') AS hq_mnemonic
                            FROM courier AS cr
                            JOIN company AS cmp ON cmp.cmp_id = cr.cmp_id
                            JOIN headquarters AS hq ON hq.hq_id = cr.hq_id
                            WHERE COALESCE(cmp.cmp_archived, '0') = '0'
                              AND cr.hq_id = ?
                            ORDER BY cmp.cmp_comp, cr.cr_eid
                            """,
                    (rs, rowNum) -> new SimilarityCandidate(
                            rs.getLong("entity_id"),
                            blankToEmpty(rs.getString("entity_code")),
                            rs.getLong("cmp_id"),
                            blankToEmpty(rs.getString("cmp_comp")),
                            blankToEmpty(rs.getString("cmp_comp2")),
                            blankToEmpty(rs.getString("cmp_match")),
                            blankToEmpty(rs.getString("cmp_match2")),
                            blankToEmpty(rs.getString("hq_mnemonic"))
                    ),
                    headquartersId
            );
        }
        return jdbcTemplate.query(
                """
                        SELECT cr.cr_id AS entity_id,
                               COALESCE(cr.cr_eid, '') AS entity_code,
                               cmp.cmp_id,
                               COALESCE(cmp.cmp_comp, '') AS cmp_comp,
                               COALESCE(cmp.cmp_comp2, '') AS cmp_comp2,
                               COALESCE(cmp.cmp_match, '') AS cmp_match,
                               COALESCE(cmp.cmp_match2, '') AS cmp_match2,
                               COALESCE(hq.hq_mnemonic, '') AS hq_mnemonic
                        FROM courier AS cr
                        JOIN company AS cmp ON cmp.cmp_id = cr.cmp_id
                        JOIN headquarters AS hq ON hq.hq_id = cr.hq_id
                        WHERE COALESCE(cmp.cmp_archived, '0') = '0'
                        ORDER BY cmp.cmp_comp, cr.cr_eid
                        """,
                (rs, rowNum) -> new SimilarityCandidate(
                        rs.getLong("entity_id"),
                        blankToEmpty(rs.getString("entity_code")),
                        rs.getLong("cmp_id"),
                        blankToEmpty(rs.getString("cmp_comp")),
                        blankToEmpty(rs.getString("cmp_comp2")),
                        blankToEmpty(rs.getString("cmp_match")),
                        blankToEmpty(rs.getString("cmp_match2")),
                        blankToEmpty(rs.getString("hq_mnemonic"))
                )
        );
    }

    private SimilarityResultRow toSimilarityResult(SimilarityCandidate candidate,
                                                   SimilarityType type,
                                                   List<String> terms) {
        String comparisonText = normalizeSimilarityText(
                String.join(" ",
                        candidate.companyName(),
                        candidate.companyName2(),
                        candidate.matchCode(),
                        candidate.matchCode2())
        );
        int score = similarityScore(comparisonText, terms);
        if (score <= 0) {
            return null;
        }
        return new SimilarityResultRow(
                type.code(),
                candidate.entityId(),
                candidate.entityCode(),
                candidate.companyId(),
                candidate.companyName(),
                candidate.companyName2(),
                candidate.headquartersMnemonic(),
                score
        );
    }

    private List<String> similarityTerms(String phrase) {
        String normalized = normalizeSimilarityText(phrase);
        if (normalized.isBlank()) {
            return List.of();
        }
        String[] tokens = normalized.split(" ");
        List<String> result = new ArrayList<>();
        for (String token : tokens) {
            if (!token.isBlank()) {
                result.add(token);
            }
            if (result.size() == 3) {
                break;
            }
        }
        return result;
    }

    private String normalizeSimilarityText(String value) {
        String text = safeTrim(value).toLowerCase(Locale.ROOT);
        text = Normalizer.normalize(text, Normalizer.Form.NFD).replaceAll("\\p{M}+", "");
        text = text.replace(" mit ", " ")
                .replace(" und ", " ")
                .replace(" oder ", " ")
                .replace(" gmbh", " ")
                .replace(" co.", " ")
                .replace(" kg", " ")
                .replace("&", " ")
                .replace("#", " ")
                .replace("'", " ");
        text = text.replaceAll("[^\\p{Alnum}\\s]", " ");
        return MULTI_SPACE.matcher(text).replaceAll(" ").trim();
    }

    private int similarityScore(String comparisonText, List<String> terms) {
        if (comparisonText.isBlank() || terms.isEmpty()) {
            return 0;
        }
        List<String> tokens = List.of(comparisonText.split(" "));
        int score = 0;
        for (String term : terms) {
            int termScore = scoreTerm(comparisonText, tokens, term);
            if (termScore == 0) {
                return 0;
            }
            score += termScore;
        }
        return score;
    }

    private int scoreTerm(String comparisonText, List<String> tokens, String term) {
        if (tokens.contains(term)) {
            return 100;
        }
        if (tokens.stream().anyMatch(token -> token.startsWith(term))) {
            return 70;
        }
        if (comparisonText.contains(term)) {
            return 50;
        }
        if (buildRepeatedPattern(term).matcher(comparisonText).find()) {
            return 35;
        }
        if (buildSingleReplacementPattern(term).matcher(comparisonText).find()) {
            return 25;
        }
        if (buildOrderedPattern(term).matcher(comparisonText).find()) {
            return 20;
        }
        if (tokens.stream().anyMatch(token -> Math.abs(token.length() - term.length()) <= 1 && levenshtein(token, term) <= 1)) {
            return 15;
        }
        return 0;
    }

    private Pattern buildRepeatedPattern(String term) {
        StringBuilder pattern = new StringBuilder();
        for (char ch : term.toCharArray()) {
            pattern.append(Pattern.quote(String.valueOf(ch))).append('+');
        }
        return Pattern.compile(pattern.toString());
    }

    private Pattern buildSingleReplacementPattern(String term) {
        List<String> parts = new ArrayList<>();
        for (int index = 0; index < term.length(); index++) {
            String prefix = term.substring(0, index);
            String suffix = term.substring(index + 1);
            parts.add(Pattern.quote(prefix) + "[\\p{Alnum}]?" + Pattern.quote(suffix));
        }
        return Pattern.compile(String.join("|", parts));
    }

    private Pattern buildOrderedPattern(String term) {
        StringBuilder pattern = new StringBuilder();
        for (char ch : term.toCharArray()) {
            pattern.append(Pattern.quote(String.valueOf(ch))).append("+[\\p{Alnum}\\s]*");
        }
        return Pattern.compile(pattern.toString());
    }

    private int levenshtein(String left, String right) {
        int[][] dp = new int[left.length() + 1][right.length() + 1];
        for (int i = 0; i <= left.length(); i++) {
            dp[i][0] = i;
        }
        for (int j = 0; j <= right.length(); j++) {
            dp[0][j] = j;
        }
        for (int i = 1; i <= left.length(); i++) {
            for (int j = 1; j <= right.length(); j++) {
                int cost = left.charAt(i - 1) == right.charAt(j - 1) ? 0 : 1;
                dp[i][j] = Math.min(
                        Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),
                        dp[i - 1][j - 1] + cost
                );
            }
        }
        return dp[left.length()][right.length()];
    }

    private String normalizeTravelTimeMode(String mode) {
        return "bwv".equalsIgnoreCase(mode) ? "bwv" : "classic";
    }

    private String requireZipcodeRangeValue(String value, Long headquartersId) {
        String normalized = requireText(value, "PLZ-Bereich");
        boolean syntaxDisabled = "1".equals(parameterService.getParameterValueWithFallback(
                ParameterKeys.MASK_ZIP_CHK_SYNTAX_DISABLED,
                headquartersId
        ));
        if (!syntaxDisabled && !normalized.chars().allMatch(Character::isDigit)) {
            throw new IllegalArgumentException("Bitte geben Sie eine gueltige PLZ fuer den Bereich an.");
        }
        return normalized;
    }

    private String requireText(String value, String label) {
        String normalized = safeTrim(value);
        if (normalized.isBlank()) {
            throw new IllegalArgumentException(label + " darf nicht leer sein.");
        }
        return normalized;
    }

    private String normalizeCountry(String country) {
        String normalized = safeTrim(country).toUpperCase(Locale.ROOT);
        return normalized.isBlank() ? "DE" : normalized;
    }

    private String safeTrim(String value) {
        return value != null ? value.trim() : "";
    }

    private String blankToEmpty(String value) {
        return value != null ? value.trim() : "";
    }

    private Double nullableDouble(Object value) {
        if (value instanceof Number number) {
            return number.doubleValue();
        }
        return null;
    }

    private Integer nullableInteger(Object value) {
        if (value instanceof Number number) {
            return number.intValue();
        }
        return null;
    }

    private Long generatedId(KeyHolder keyHolder, String label) {
        Number key = keyHolder.getKey();
        if (key == null) {
            throw new IllegalArgumentException(label + " konnte nicht erzeugt werden.");
        }
        return key.longValue();
    }

    private String formatTimestamp(Timestamp timestamp) {
        return timestamp != null ? DISPLAY_TIMESTAMP.format(timestamp.toLocalDateTime()) : "";
    }

    private String gpsTypeLabel(int gpsType) {
        return switch (gpsType) {
            case 1 -> "LBS";
            case 2 -> "GPS";
            case 3 -> "Network";
            case 9 -> "Ortung aus";
            default -> "Unbestimmt";
        };
    }

    private String googleMapsUrl(Double latitude, Double longitude) {
        if (latitude == null || longitude == null) {
            return "";
        }
        String query = latitude + "," + longitude;
        return "https://www.google.com/maps/search/?api=1&query="
                + URLEncoder.encode(query, StandardCharsets.UTF_8);
    }

    private enum SimilarityType {
        CUSTOMER("customer"),
        COURIER("courier");

        private final String code;

        SimilarityType(String code) {
            this.code = code;
        }

        public String code() {
            return code;
        }
    }

    private record SimilarityCandidate(Long entityId,
                                       String entityCode,
                                       Long companyId,
                                       String companyName,
                                       String companyName2,
                                       String matchCode,
                                       String matchCode2,
                                       String headquartersMnemonic) {
    }

    public record Bootstrap(boolean mapVisible,
                            boolean addressAdminVisible,
                            boolean customerSearchVisible,
                            boolean courierSearchVisible,
                            boolean travelTimeAdminVisible,
                            String defaultCity,
                            boolean zipSyntaxDisabled,
                            int defaultTravelTimeMinutes) {
    }

    public record HeadquartersPoint(Long id,
                                    String name,
                                    String mnemonic,
                                    Double latitude,
                                    Double longitude) {
        public boolean hasPoint() {
            return latitude != null && longitude != null;
        }
    }

    public record MapDashboard(String generatedAt,
                               HeadquartersPoint headquarters,
                               int courierCount,
                               int mappedCourierCount,
                               List<MapCourierRow> couriers) {
    }

    public record MapCourierRow(Long courierId,
                                String courierCode,
                                String sid,
                                String companyName,
                                String vehicleType,
                                boolean available,
                                String locationZipcode,
                                String availableTime,
                                String gpsTime,
                                String gpsTypeLabel,
                                String mobilePda,
                                String phone,
                                String phone2,
                                Double latitude,
                                Double longitude,
                                boolean gpsBased,
                                String googleMapsUrl) {
        public boolean hasPoint() {
            return latitude != null && longitude != null;
        }
    }

    public record AddressSearchResult(List<AddressRow> addresses, List<ValidatedStreetRow> validatedStreets) {
    }

    public record AddressRow(Long id, String street, String zipcode, String city, String country) {
    }

    public record ValidatedStreetRow(Long id,
                                     String district,
                                     String street,
                                     String zipcode,
                                     String city,
                                     String country) {
    }

    public record SimilarityResultRow(String type,
                                      Long entityId,
                                      String entityCode,
                                      Long companyId,
                                      String companyName,
                                      String companyName2,
                                      String headquartersMnemonic,
                                      int score) {
    }

    public record TravelTimeResult(String mode, int rowCount, List<TravelTimeRow> rows) {
    }

    public record TravelTimeRow(Long serviceZipId, String zipcode, Integer travelTime) {
    }

    public record TravelTimeUpdate(Long serviceZipId, String zipcode, Integer travelTime) {
    }

    public record SaveResult(int updatedCount) {
    }
}
