"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.geocodingService = void 0;
const client_1 = require("@prisma/client");
const crypto_1 = __importDefault(require("crypto"));
const env_1 = require("../../../config/env");
const redis_1 = require("../../../config/redis");
const logger_1 = require("../../../utils/logger");
const metrics_1 = require("../../../utils/metrics");
// Redis cache configuration
const CACHE_KEY_PREFIX = 'GEOCODE_CACHE:';
// Helper function to hash address for cache key
function hashAddress(address) {
    return crypto_1.default.createHash('sha256').update(address).digest('hex').substring(0, 16);
}
// Helper functions for Redis cache operations
async function getCachedResult(address) {
    if (env_1.env.GEOCODING_CACHE_ENABLED !== 'true')
        return null;
    try {
        const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`;
        const cached = await redis_1.redis.get(key);
        if (!cached) {
            metrics_1.cm_geocode_cache_misses.inc();
            return null;
        }
        const parsed = JSON.parse(cached);
        metrics_1.cm_geocode_cache_hits.inc();
        // Return result without cachedAt timestamp
        const { cachedAt, ...result } = parsed;
        return result;
    }
    catch (err) {
        logger_1.logger.warn('Failed to get cached geocode result:', err);
        metrics_1.cm_geocode_cache_misses.inc();
        return null;
    }
}
async function setCachedResult(address, result) {
    if (env_1.env.GEOCODING_CACHE_ENABLED !== 'true')
        return;
    try {
        const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`;
        const ttlSeconds = env_1.env.GEOCODING_CACHE_TTL_HOURS * 60 * 60;
        const cachedResult = {
            ...result,
            cachedAt: Date.now(),
        };
        await redis_1.redis.setex(key, ttlSeconds, JSON.stringify(cachedResult));
    }
    catch (err) {
        logger_1.logger.warn('Failed to cache geocode result:', err);
    }
}
// Address abbreviation expansions (comprehensive Canadian/US terms)
const abbreviations = {
    // Street types
    'st': 'street',
    'ave': 'avenue',
    'blvd': 'boulevard',
    'dr': 'drive',
    'rd': 'road',
    'ln': 'lane',
    'ct': 'court',
    'crt': 'court',
    'pl': 'place',
    'cres': 'crescent',
    'terr': 'terrace',
    'hwy': 'highway',
    'pkwy': 'parkway',
    'sq': 'square',
    'mews': 'mews',
    'circ': 'circle',
    'cir': 'circle',
    'way': 'way',
    'trail': 'trail',
    'grove': 'grove',
    'heights': 'heights',
    'gdns': 'gardens',
    'gdn': 'garden',
    'gate': 'gate',
    'bay': 'bay',
    'close': 'close',
    'walk': 'walk',
    // Directional suffixes
    'n': 'north',
    'ne': 'northeast',
    'e': 'east',
    'se': 'southeast',
    's': 'south',
    'sw': 'southwest',
    'w': 'west',
    'nw': 'northwest',
    // Unit types
    'apt': 'apartment',
    'unit': 'unit',
    'ste': 'suite',
    'fl': 'floor',
    'bldg': 'building',
    'dept': 'department',
};
/**
 * Normalize postal code format
 * K1A0B1 → K1A 0B1
 * k1a 0b1 → K1A 0B1
 */
function normalizePostalCode(address) {
    // Canadian postal code pattern: A1A 1A1 or A1A1A1
    const postalCodeRegex = /\b([A-Za-z]\d[A-Za-z])\s*(\d[A-Za-z]\d)\b/g;
    return address.replace(postalCodeRegex, (match, p1, p2) => {
        return `${p1.toUpperCase()} ${p2.toUpperCase()}`;
    });
}
/**
 * Normalize address: lowercase, trim whitespace, normalize postal codes
 */
function normalizeAddress(address, city, province) {
    let normalized = address.trim().toLowerCase();
    // Trim consecutive whitespace
    normalized = normalized.replace(/\s+/g, ' ');
    // Normalize postal code before lowercasing
    normalized = normalizePostalCode(normalized);
    // Append city/province for better geocoding context (if available)
    if (city) {
        normalized += `, ${city.toLowerCase()}`;
    }
    if (province) {
        normalized += `, ${province.toLowerCase()}`;
    }
    return normalized;
}
/**
 * Expand common address abbreviations
 */
function expandAbbreviations(address) {
    let expanded = address.toLowerCase();
    for (const [abbr, full] of Object.entries(abbreviations)) {
        // Case-insensitive replacement with word boundaries
        const regex = new RegExp(`\\b${abbr}\\b`, 'gi');
        expanded = expanded.replace(regex, full);
    }
    return expanded;
}
// Default geographic bounds for filtering
const DEFAULT_BOUNDS = {
    CANADA: { minLat: 41.67, maxLat: 83.11, minLng: -141.00, maxLng: -52.62 },
    USA: { minLat: 24.52, maxLat: 49.38, minLng: -125.00, maxLng: -66.93 },
};
// Detect country from address (look for Canadian province codes or "Canada")
function detectCountry(address) {
    const upper = address.toUpperCase();
    const canadianProvinces = ['ON', 'QC', 'BC', 'AB', 'MB', 'SK', 'NS', 'NB', 'PE', 'NL', 'NT', 'NU', 'YT'];
    for (const prov of canadianProvinces) {
        if (upper.includes(` ${prov} `) || upper.includes(` ${prov},`) || upper.endsWith(` ${prov}`)) {
            return 'CANADA';
        }
    }
    if (upper.includes('CANADA'))
        return 'CANADA';
    if (upper.includes('USA') || upper.includes('UNITED STATES'))
        return 'USA';
    return null;
}
// Adjust confidence based on provider reputation and result quality
function adjustConfidence(result) {
    let confidence = result.rawConfidence;
    // Provider-specific adjustments based on historical accuracy
    const providerAdjustment = {
        [client_1.GeocodeProvider.GOOGLE]: +10, // Most accurate
        [client_1.GeocodeProvider.MAPBOX]: +5, // High accuracy
        [client_1.GeocodeProvider.NOMINATIM]: 0, // Baseline
        [client_1.GeocodeProvider.ARCGIS]: -5, // Less accurate for residential
        [client_1.GeocodeProvider.PHOTON]: -10, // Least accurate
        [client_1.GeocodeProvider.LOCATIONIQ]: 0,
        [client_1.GeocodeProvider.UNKNOWN]: 0,
    };
    confidence += providerAdjustment[result.provider] || 0;
    // Clamp to 0-100 range
    return Math.max(0, Math.min(100, confidence));
}
function calculateValidationScore(input, returned) {
    if (!returned)
        return 50;
    const inputParts = input.toLowerCase().split(/[\s,]+/).filter(Boolean);
    const returnedLower = returned.toLowerCase();
    let matches = 0;
    for (const part of inputParts) {
        if (part.length > 2 && returnedLower.includes(part))
            matches++;
    }
    return inputParts.length > 0 ? Math.round((matches / inputParts.length) * 100) : 50;
}
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
async function geocodeGoogleMaps(address, bounds) {
    if (!env_1.env.GOOGLE_MAPS_API_KEY || env_1.env.GOOGLE_MAPS_ENABLED !== 'true')
        return null;
    try {
        const url = new URL('https://maps.googleapis.com/maps/api/geocode/json');
        url.searchParams.set('address', address);
        url.searchParams.set('key', env_1.env.GOOGLE_MAPS_API_KEY);
        // Add bounds to bias results toward the specified region
        if (bounds) {
            const boundsStr = `${bounds.minLat},${bounds.minLng}|${bounds.maxLat},${bounds.maxLng}`;
            url.searchParams.set('bounds', boundsStr);
        }
        const res = await fetch(url.toString());
        if (!res.ok)
            return null;
        const data = await res.json();
        if (data.status !== 'OK' || !data.results[0])
            return null;
        const result = data.results[0];
        const locationType = result.geometry.location_type;
        // Map Google's location_type to confidence score
        const confidenceMap = {
            'ROOFTOP': 100, // Precise address
            'RANGE_INTERPOLATED': 85, // Interpolated between two points
            'GEOMETRIC_CENTER': 70, // Center of a polygon (street, neighborhood)
            'APPROXIMATE': 50, // Approximate (city, postal code)
        };
        return {
            latitude: result.geometry.location.lat,
            longitude: result.geometry.location.lng,
            rawConfidence: confidenceMap[locationType] || 70,
            formattedAddress: result.formatted_address,
            provider: client_1.GeocodeProvider.GOOGLE,
        };
    }
    catch (err) {
        logger_1.logger.warn('Google Maps geocode failed:', err);
        return null;
    }
}
async function geocodeNominatim(address, bounds) {
    try {
        let url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1&addressdetails=1`;
        // Add viewbox (bounds) to bias results toward the specified region
        if (bounds) {
            const viewbox = `${bounds.minLng},${bounds.minLat},${bounds.maxLng},${bounds.maxLat}`;
            url += `&viewbox=${viewbox}&bounded=1`;
        }
        const res = await fetch(url, {
            headers: { 'User-Agent': 'ChangemakerLite/2.0 (contact@cmlite.org)' },
        });
        if (!res.ok)
            return null;
        const data = await res.json();
        if (!data.length)
            return null;
        const item = data[0];
        return {
            latitude: parseFloat(item.lat),
            longitude: parseFloat(item.lon),
            rawConfidence: Math.round((item.importance ?? 0.5) * 100),
            formattedAddress: item.display_name,
            provider: client_1.GeocodeProvider.NOMINATIM,
        };
    }
    catch (err) {
        logger_1.logger.warn('Nominatim geocode failed:', err);
        return null;
    }
}
async function geocodeArcGIS(address, bounds) {
    try {
        let url = `https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates?SingleLine=${encodeURIComponent(address)}&f=json&maxLocations=1`;
        // Add searchExtent (bounds) to filter results to the specified region
        if (bounds) {
            const extent = `${bounds.minLng},${bounds.minLat},${bounds.maxLng},${bounds.maxLat}`;
            url += `&searchExtent=${extent}`;
        }
        const res = await fetch(url);
        if (!res.ok)
            return null;
        const data = await res.json();
        if (!data.candidates?.length)
            return null;
        const item = data.candidates[0];
        return {
            latitude: item.location.y,
            longitude: item.location.x,
            rawConfidence: Math.round(item.score),
            formattedAddress: item.address,
            provider: client_1.GeocodeProvider.ARCGIS,
        };
    }
    catch (err) {
        logger_1.logger.warn('ArcGIS geocode failed:', err);
        return null;
    }
}
async function geocodePhoton(address, bounds) {
    try {
        let url = `https://photon.komoot.io/api/?q=${encodeURIComponent(address)}&limit=1`;
        // Add bbox (bounds) to filter results to the specified region
        if (bounds) {
            const bbox = `${bounds.minLng},${bounds.minLat},${bounds.maxLng},${bounds.maxLat}`;
            url += `&bbox=${bbox}`;
        }
        const res = await fetch(url);
        if (!res.ok)
            return null;
        const data = await res.json();
        if (!data.features?.length)
            return null;
        const feat = data.features[0];
        const coords = feat.geometry.coordinates;
        const props = feat.properties;
        const formatted = [props.name, props.street, props.city, props.state, props.country].filter(Boolean).join(', ');
        // Use improved confidence scoring instead of hardcoded 65%
        // Photon doesn't provide confidence, so estimate based on result completeness
        let confidence = 50; // Base confidence
        if (props.name)
            confidence += 10;
        if (props.street)
            confidence += 15;
        if (props.city)
            confidence += 10;
        return {
            latitude: coords[1],
            longitude: coords[0],
            rawConfidence: confidence,
            formattedAddress: formatted || undefined,
            provider: client_1.GeocodeProvider.PHOTON,
        };
    }
    catch (err) {
        logger_1.logger.warn('Photon geocode failed:', err);
        return null;
    }
}
async function geocodeMapbox(address, bounds) {
    if (!env_1.env.MAPBOX_API_KEY)
        return null;
    try {
        let url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?access_token=${env_1.env.MAPBOX_API_KEY}&limit=1`;
        // Add bbox (bounds) to bias results toward the specified region
        if (bounds) {
            const bbox = `${bounds.minLng},${bounds.minLat},${bounds.maxLng},${bounds.maxLat}`;
            url += `&bbox=${bbox}`;
        }
        const res = await fetch(url);
        if (!res.ok)
            return null;
        const data = await res.json();
        if (!data.features?.length)
            return null;
        const feat = data.features[0];
        return {
            latitude: feat.center[1],
            longitude: feat.center[0],
            rawConfidence: Math.round((feat.relevance ?? 0.5) * 100),
            formattedAddress: feat.place_name,
            provider: client_1.GeocodeProvider.MAPBOX,
        };
    }
    catch (err) {
        logger_1.logger.warn('Mapbox geocode failed:', err);
        return null;
    }
}
exports.geocodingService = {
    async geocodeBatch(addresses, bounds) {
        if (env_1.env.GEOCODING_PARALLEL_ENABLED !== 'true') {
            // Sequential fallback
            const results = [];
            for (const addr of addresses) {
                const result = await this.geocode(addr, bounds);
                results.push(result);
            }
            return results;
        }
        // Parallel batch processing
        const batchSize = env_1.env.GEOCODING_BATCH_SIZE;
        const results = [];
        for (let i = 0; i < addresses.length; i += batchSize) {
            const batch = addresses.slice(i, i + batchSize);
            const batchPromises = batch.map((addr) => this.geocode(addr, bounds).catch((err) => {
                logger_1.logger.warn(`Batch geocode failed for address: ${addr}`, err);
                return null;
            }));
            const batchResults = await Promise.allSettled(batchPromises);
            const resolvedResults = batchResults.map((r) => r.status === 'fulfilled' ? r.value : null);
            results.push(...resolvedResults);
            // Respect rate limits: delay between batches
            if (i + batchSize < addresses.length) {
                await sleep(env_1.env.GEOCODING_RATE_LIMIT_MS);
            }
        }
        return results;
    },
    async geocode(address, bounds) {
        // Check Redis cache
        const normalizedAddress = normalizeAddress(address);
        const cached = await getCachedResult(normalizedAddress);
        if (cached) {
            return cached;
        }
        // Auto-detect country and apply default bounds if not provided
        if (!bounds) {
            const detectedCountry = detectCountry(address);
            if (detectedCountry) {
                bounds = DEFAULT_BOUNDS[detectedCountry];
            }
        }
        // Try address variations
        const variations = [address, expandAbbreviations(address)];
        // Remove duplicates
        const uniqueVariations = [...new Set(variations.map((v) => v.trim()))];
        // Provider chain: Google Maps first (if enabled), then fallbacks
        const providers = [
            geocodeGoogleMaps,
            geocodeNominatim,
            geocodeArcGIS,
            geocodePhoton,
            geocodeMapbox,
        ];
        for (const variation of uniqueVariations) {
            for (const provider of providers) {
                const result = await provider(variation, bounds);
                if (result) {
                    // Apply provider-specific confidence adjustments
                    const adjustedConfidence = adjustConfidence(result);
                    const validationScore = calculateValidationScore(address, result.formattedAddress);
                    const combinedConfidence = Math.round((adjustedConfidence + validationScore) / 2);
                    const geocodeResult = {
                        latitude: result.latitude,
                        longitude: result.longitude,
                        confidence: combinedConfidence,
                        provider: result.provider,
                        formattedAddress: result.formattedAddress,
                    };
                    // Cache the result in Redis
                    await setCachedResult(normalizedAddress, geocodeResult);
                    if (combinedConfidence >= 85) {
                        return geocodeResult;
                    }
                    // If confidence is decent but not great, keep looking
                    // but store as best so far
                    let bestResult = geocodeResult;
                    // Try remaining providers for this variation
                    const providerIndex = providers.indexOf(provider);
                    for (let i = providerIndex + 1; i < providers.length; i++) {
                        await sleep(env_1.env.GEOCODING_RATE_LIMIT_MS);
                        const altResult = await providers[i](variation, bounds);
                        if (altResult) {
                            const altAdjusted = adjustConfidence(altResult);
                            const altValidation = calculateValidationScore(address, altResult.formattedAddress);
                            const altConfidence = Math.round((altAdjusted + altValidation) / 2);
                            if (altConfidence > bestResult.confidence) {
                                bestResult = {
                                    latitude: altResult.latitude,
                                    longitude: altResult.longitude,
                                    confidence: altConfidence,
                                    provider: altResult.provider,
                                    formattedAddress: altResult.formattedAddress,
                                };
                            }
                            if (altConfidence >= 85)
                                break;
                        }
                    }
                    // Cache the best result in Redis
                    await setCachedResult(normalizedAddress, bestResult);
                    return bestResult;
                }
                await sleep(env_1.env.GEOCODING_RATE_LIMIT_MS);
            }
        }
        return null;
    },
    async reverseGeocode(lat, lng) {
        try {
            const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`;
            const res = await fetch(url, {
                headers: { 'User-Agent': 'ChangemakerLite/2.0 (contact@cmlite.org)' },
            });
            if (!res.ok)
                return null;
            const data = await res.json();
            return {
                address: data.display_name,
                city: data.address?.city || data.address?.town || data.address?.village,
                province: data.address?.state,
                country: data.address?.country,
            };
        }
        catch (err) {
            logger_1.logger.warn('Reverse geocode failed:', err);
            return null;
        }
    },
    async search(query, limit = 5) {
        try {
            const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=${limit}&addressdetails=1`;
            const res = await fetch(url, {
                headers: { 'User-Agent': 'ChangemakerLite/2.0 (contact@cmlite.org)' },
            });
            if (!res.ok)
                return [];
            const data = await res.json();
            return data.map((item) => ({
                latitude: parseFloat(item.lat),
                longitude: parseFloat(item.lon),
                displayName: item.display_name,
                type: item.type || item.class || 'place',
                provider: 'nominatim',
            }));
        }
        catch (err) {
            logger_1.logger.warn('Nominatim search failed:', err);
            return [];
        }
    },
    async clearCache() {
        if (env_1.env.GEOCODING_CACHE_ENABLED !== 'true')
            return;
        try {
            // Find all cache keys and delete them
            const keys = await redis_1.redis.keys(`${CACHE_KEY_PREFIX}*`);
            if (keys.length > 0) {
                await redis_1.redis.del(...keys);
                logger_1.logger.info(`Cleared ${keys.length} geocoding cache entries`);
            }
        }
        catch (err) {
            logger_1.logger.error('Failed to clear geocoding cache:', err);
            throw err;
        }
    },
    // Add method to get cache statistics
    async getCacheStats() {
        try {
            const keys = await redis_1.redis.keys(`${CACHE_KEY_PREFIX}*`);
            const hitsMetric = await metrics_1.cm_geocode_cache_hits.get();
            const missesMetric = await metrics_1.cm_geocode_cache_misses.get();
            return {
                keys: keys.length,
                hits: hitsMetric.values[0]?.value || 0,
                misses: missesMetric.values[0]?.value || 0,
            };
        }
        catch (err) {
            logger_1.logger.error('Failed to get cache stats:', err);
            return { keys: 0, hits: 0, misses: 0 };
        }
    },
};
//# sourceMappingURL=geocoding.service.js.map