import { GeocodeProvider } from '@prisma/client';
import crypto from 'crypto';
import { env } from '../../../config/env';
import { redis } from '../../../config/redis';
import { logger } from '../../../utils/logger';
import { cm_geocode_cache_hits, cm_geocode_cache_misses } from '../../../utils/metrics';

export interface BoundsQuery {
  minLat: number;
  maxLat: number;
  minLng: number;
  maxLng: number;
}

export interface GeocodeResult {
  latitude: number;
  longitude: number;
  confidence: number;
  provider: GeocodeProvider;
  formattedAddress?: string;
}

export interface ReverseGeocodeResult {
  address: string;
  city?: string;
  province?: string;
  country?: string;
}

interface ProviderResult {
  latitude: number;
  longitude: number;
  rawConfidence: number;
  formattedAddress?: string;
  provider: GeocodeProvider;
}

// Redis cache configuration
const CACHE_KEY_PREFIX = 'GEOCODE_CACHE:';

interface CachedGeocodeResult extends GeocodeResult {
  cachedAt: number;
}

// Helper function to hash address for cache key
function hashAddress(address: string): string {
  return crypto.createHash('sha256').update(address).digest('hex').substring(0, 16);
}

// Helper functions for Redis cache operations
async function getCachedResult(address: string): Promise<GeocodeResult | null> {
  if (env.GEOCODING_CACHE_ENABLED !== 'true') return null;

  try {
    const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`;
    const cached = await redis.get(key);

    if (!cached) {
      cm_geocode_cache_misses.inc();
      return null;
    }

    const parsed: CachedGeocodeResult = JSON.parse(cached);
    cm_geocode_cache_hits.inc();

    // Return result without cachedAt timestamp
    const { cachedAt, ...result } = parsed;
    return result;
  } catch (err) {
    logger.warn('Failed to get cached geocode result:', err);
    cm_geocode_cache_misses.inc();
    return null;
  }
}

async function setCachedResult(address: string, result: GeocodeResult): Promise<void> {
  if (env.GEOCODING_CACHE_ENABLED !== 'true') return;

  try {
    const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`;
    const ttlSeconds = env.GEOCODING_CACHE_TTL_HOURS * 60 * 60;
    const cachedResult: CachedGeocodeResult = {
      ...result,
      cachedAt: Date.now(),
    };

    await redis.setex(key, ttlSeconds, JSON.stringify(cachedResult));
  } catch (err) {
    logger.warn('Failed to cache geocode result:', err);
  }
}

// Address abbreviation expansions (comprehensive Canadian/US terms)
const abbreviations: Record<string, string> = {
  // 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: string): string {
  // 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: string, city?: string, province?: string): string {
  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: string): string {
  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: Record<string, BoundsQuery> = {
  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: string): string | null {
  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: ProviderResult): number {
  let confidence = result.rawConfidence;

  // Provider-specific adjustments based on historical accuracy
  const providerAdjustment: Record<GeocodeProvider, number> = {
    [GeocodeProvider.GOOGLE]: +10,        // Most accurate
    [GeocodeProvider.MAPBOX]: +5,         // High accuracy
    [GeocodeProvider.NOMINATIM]: 0,       // Baseline
    [GeocodeProvider.ARCGIS]: -5,         // Less accurate for residential
    [GeocodeProvider.PHOTON]: -10,        // Least accurate
    [GeocodeProvider.LOCATIONIQ]: 0,
    [GeocodeProvider.UNKNOWN]: 0,
  };

  confidence += providerAdjustment[result.provider] || 0;

  // Clamp to 0-100 range
  return Math.max(0, Math.min(100, confidence));
}

function calculateValidationScore(input: string, returned: string | undefined): number {
  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: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function geocodeGoogleMaps(address: string, bounds?: BoundsQuery): Promise<ProviderResult | null> {
  if (!env.GOOGLE_MAPS_API_KEY || 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.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() as {
      status: string;
      results: Array<{
        geometry: {
          location: { lat: number; lng: number };
          location_type: string;
        };
        formatted_address: string;
      }>;
    };

    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: Record<string, number> = {
      '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: GeocodeProvider.GOOGLE,
    };
  } catch (err) {
    logger.warn('Google Maps geocode failed:', err);
    return null;
  }
}

async function geocodeNominatim(address: string, bounds?: BoundsQuery): Promise<ProviderResult | null> {
  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() as Array<{ lat: string; lon: string; display_name: string; importance: number }>;
    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: GeocodeProvider.NOMINATIM,
    };
  } catch (err) {
    logger.warn('Nominatim geocode failed:', err);
    return null;
  }
}

async function geocodeArcGIS(address: string, bounds?: BoundsQuery): Promise<ProviderResult | null> {
  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() as { candidates: Array<{ location: { x: number; y: number }; score: number; address: string }> };
    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: GeocodeProvider.ARCGIS,
    };
  } catch (err) {
    logger.warn('ArcGIS geocode failed:', err);
    return null;
  }
}

async function geocodePhoton(address: string, bounds?: BoundsQuery): Promise<ProviderResult | null> {
  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() as { features: Array<{ geometry: { coordinates: number[] }; properties: { name?: string; street?: string; city?: string; state?: string; country?: string; osm_value?: string } }> };
    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: GeocodeProvider.PHOTON,
    };
  } catch (err) {
    logger.warn('Photon geocode failed:', err);
    return null;
  }
}

async function geocodeMapbox(address: string, bounds?: BoundsQuery): Promise<ProviderResult | null> {
  if (!env.MAPBOX_API_KEY) return null;
  try {
    let url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?access_token=${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() as { features: Array<{ center: number[]; relevance: number; place_name: string }> };
    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: GeocodeProvider.MAPBOX,
    };
  } catch (err) {
    logger.warn('Mapbox geocode failed:', err);
    return null;
  }
}

export const geocodingService = {
  async geocodeBatch(
    addresses: string[],
    bounds?: BoundsQuery
  ): Promise<(GeocodeResult | null)[]> {
    if (env.GEOCODING_PARALLEL_ENABLED !== 'true') {
      // Sequential fallback
      const results: (GeocodeResult | null)[] = [];
      for (const addr of addresses) {
        const result = await this.geocode(addr, bounds);
        results.push(result);
      }
      return results;
    }

    // Parallel batch processing
    const batchSize = env.GEOCODING_BATCH_SIZE;
    const results: (GeocodeResult | null)[] = [];

    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.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.GEOCODING_RATE_LIMIT_MS);
      }
    }

    return results;
  },

  async geocode(address: string, bounds?: BoundsQuery): Promise<GeocodeResult | null> {
    // 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: 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.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.GEOCODING_RATE_LIMIT_MS);
      }
    }

    return null;
  },

  async reverseGeocode(lat: number, lng: number): Promise<ReverseGeocodeResult | null> {
    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() as {
        display_name: string;
        address?: { city?: string; town?: string; village?: string; state?: string; country?: string };
      };
      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.warn('Reverse geocode failed:', err);
      return null;
    }
  },

  async search(query: string, limit: number = 5): Promise<Array<{
    latitude: number;
    longitude: number;
    displayName: string;
    type: string;
    provider: string;
  }>> {
    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() as Array<{
        lat: string; lon: string; display_name: string; type: string; class: string;
      }>;
      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.warn('Nominatim search failed:', err);
      return [];
    }
  },

  async clearCache() {
    if (env.GEOCODING_CACHE_ENABLED !== 'true') return;

    try {
      // Find all cache keys and delete them
      const keys = await redis.keys(`${CACHE_KEY_PREFIX}*`);
      if (keys.length > 0) {
        await redis.del(...keys);
        logger.info(`Cleared ${keys.length} geocoding cache entries`);
      }
    } catch (err) {
      logger.error('Failed to clear geocoding cache:', err);
      throw err;
    }
  },

  // Add method to get cache statistics
  async getCacheStats(): Promise<{ keys: number; hits: number; misses: number }> {
    try {
      const keys = await redis.keys(`${CACHE_KEY_PREFIX}*`);
      const hitsMetric = await cm_geocode_cache_hits.get();
      const missesMetric = await cm_geocode_cache_misses.get();
      return {
        keys: keys.length,
        hits: hitsMetric.values[0]?.value || 0,
        misses: missesMetric.values[0]?.value || 0,
      };
    } catch (err) {
      logger.error('Failed to get cache stats:', err);
      return { keys: 0, hits: 0, misses: 0 };
    }
  },
};
