import { Prisma, SupportLevel, GeocodeProvider, LocationHistoryAction } from '@prisma/client';
import { parse } from 'csv-parse/sync';
import { stringify } from 'csv-stringify/sync';
import proj4 from 'proj4';
import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler';
import { geocodingService } from '../geocoding/geocoding.service';
import { logger } from '../../../utils/logger';
import { recordLocationQuery } from '../../../utils/metrics';
import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
import type { CreateLocationInput, UpdateLocationInput, ListLocationsInput, BulkImportInput } from './locations.schemas';

// Statistics Canada Lambert Conformal Conic projection (EPSG:3347) → WGS84 (EPSG:4326)
proj4.defs('EPSG:3347', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +units=m +no_defs');

interface ImportFilters {
  cutPolygon?: number[][][];
  bounds?: { north: number; south: number; east: number; west: number };
  city?: string;
  province?: string;
}

interface CsvRow {
  address?: string;
  firstName?: string;
  lastName?: string;
  email?: string;
  phone?: string;
  unitNumber?: string;
  supportLevel?: string;
  sign?: string;
  signSize?: string;
  notes?: string;
  latitude?: string;
  longitude?: string;
}

// Map CSV header names to our fields (case-insensitive, flexible)
const CSV_HEADER_MAP: Record<string, keyof CsvRow> = {
  'address': 'address',
  'street': 'address',
  'street address': 'address',
  'first name': 'firstName',
  'firstname': 'firstName',
  'first': 'firstName',
  'last name': 'lastName',
  'lastname': 'lastName',
  'last': 'lastName',
  'email': 'email',
  'e-mail': 'email',
  'phone': 'phone',
  'telephone': 'phone',
  'tel': 'phone',
  'phone number': 'phone',
  'unit': 'unitNumber',
  'unit number': 'unitNumber',
  'unitnumber': 'unitNumber',
  'apt': 'unitNumber',
  'apartment': 'unitNumber',
  'suite': 'unitNumber',
  'support level': 'supportLevel',
  'supportlevel': 'supportLevel',
  'support': 'supportLevel',
  'level': 'supportLevel',
  'sign': 'sign',
  'lawn sign': 'sign',
  'sign size': 'signSize',
  'signsize': 'signSize',
  'notes': 'notes',
  'note': 'notes',
  'comments': 'notes',
  'latitude': 'latitude',
  'lat': 'latitude',
  'longitude': 'longitude',
  'lng': 'longitude',
  'lon': 'longitude',
};

// NAR (National Address Register) column name mapping → internal keys
// Supports both 2025 NAR format (CIVIC_NO, OFFICIAL_STREET_NAME, BG_X/BG_Y)
// and legacy format (STR_NBR, STR_NME, LAT/LNG)
const NAR_HEADER_MAP: Record<string, string> = {
  // 2025 NAR Address file columns
  'civic_no': 'CIVIC_NO',
  'civic_no_suffix': 'CIVIC_NO_SUFFIX',
  'official_street_name': 'STREET_NAME',
  'official_street_type': 'STREET_TYPE',
  'official_street_dir': 'STREET_DIR',
  'apt_no_label': 'UNIT_NBR',
  'bg_x': 'BG_X',
  'bg_y': 'BG_Y',
  'bg_latitude': 'LAT',
  'bg_longitude': 'LNG',
  'mail_mun_name': 'CITY',
  'csd_eng_name': 'CSD_NAME',
  'mail_prov_abvn': 'PROV',
  'prov_code': 'PROV_CODE',
  'mail_postal_code': 'POSTAL_CODE',
  'fed_eng_name': 'FED_DISTRICT',
  'fed_code': 'FED_CODE',
  'bu_use': 'BU_USE',
  // Legacy column support (backward compatibility)
  'str_nbr': 'CIVIC_NO', 'street_number': 'CIVIC_NO', 'house_number': 'CIVIC_NO',
  'str_nme': 'STREET_NAME', 'street_name': 'STREET_NAME',
  'str_typ': 'STREET_TYPE', 'street_type': 'STREET_TYPE',
  'str_dir': 'STREET_DIR', 'street_direction': 'STREET_DIR',
  'unit_nbr': 'UNIT_NBR', 'unit': 'UNIT_NBR', 'suite': 'UNIT_NBR',
  'lat': 'LAT', 'latitude': 'LAT',
  'lng': 'LNG', 'lon': 'LNG', 'longitude': 'LNG', 'long': 'LNG',
  'city': 'CITY', 'mun_nme': 'CITY', 'municipality': 'CITY',
  'prov': 'PROV', 'province': 'PROV', 'prv_nme': 'PROV',
};

// Columns that indicate NAR format (match 3+ = NAR)
const NAR_DETECT_COLUMNS = [
  'CIVIC_NO', 'OFFICIAL_STREET_NAME', 'OFFICIAL_STREET_TYPE',  // 2025 NAR
  'BG_X', 'BG_Y', 'MAIL_POSTAL_CODE', 'MAIL_PROV_ABVN',      // 2025 NAR
  'BG_LATITUDE', 'BG_LONGITUDE',                                // 2025 NAR Location files
  'STR_NBR', 'STR_NME', 'STR_TYP', 'LAT', 'LNG',             // Legacy
];

function detectNarFormat(headers: string[]): boolean {
  const normalizedHeaders = headers.map((h) => h.trim().toUpperCase());
  let matchCount = 0;
  const matched = new Set<string>();
  for (const col of NAR_DETECT_COLUMNS) {
    if (normalizedHeaders.includes(col) && !matched.has(col)) {
      matched.add(col);
      matchCount++;
    }
  }
  // Also check via aliases (lowercase header → uppercase target)
  for (const h of normalizedHeaders) {
    const alias = NAR_HEADER_MAP[h.toLowerCase()];
    if (alias && !matched.has(h)) {
      matched.add(h);
      matchCount++;
    }
  }
  return matchCount >= 3;
}

/** Convert BG_X/BG_Y (EPSG:3347 Lambert) to [lat, lng] (WGS84) */
function lambertToLatLng(bgX: number, bgY: number): [number, number] {
  const [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);
  return [lat, lng];
}

function roundCoord(val: number, decimals: number = 5): number {
  const factor = Math.pow(10, decimals);
  return Math.round(val * factor) / factor;
}

function parseSupportLevel(value: string): SupportLevel | undefined {
  const v = value.trim();
  if (v === '1' || v.toLowerCase() === 'level_1') return SupportLevel.LEVEL_1;
  if (v === '2' || v.toLowerCase() === 'level_2') return SupportLevel.LEVEL_2;
  if (v === '3' || v.toLowerCase() === 'level_3') return SupportLevel.LEVEL_3;
  if (v === '4' || v.toLowerCase() === 'level_4') return SupportLevel.LEVEL_4;
  return undefined;
}

function parseBoolean(value: string): boolean {
  const v = value.trim().toLowerCase();
  return v === 'true' || v === 'yes' || v === '1' || v === 'y';
}

/**
 * Record location history for audit trail
 */
async function recordHistory(
  locationId: string,
  userId: string | null,
  action: LocationHistoryAction,
  changes?: { field: string; oldValue: unknown; newValue: unknown }[],
  metadata?: Record<string, unknown>
) {
  const historyRecords = changes?.map(({ field, oldValue, newValue }) => ({
    locationId,
    userId,
    action,
    field,
    oldValue: oldValue != null ? String(oldValue) : null,
    newValue: newValue != null ? String(newValue) : null,
    metadata: metadata ? (metadata as unknown as Prisma.InputJsonValue) : undefined,
  })) ?? [{
    locationId,
    userId,
    action,
    field: null,
    oldValue: null,
    newValue: null,
    metadata: metadata ? (metadata as unknown as Prisma.InputJsonValue) : undefined,
  }];

  await prisma.locationHistory.createMany({ data: historyRecords as Prisma.LocationHistoryCreateManyInput[] });
}

export const locationsService = {
  async findAll(filters: ListLocationsInput) {
    const { page, limit, search, supportLevel, hasSign, confidenceLevel, sortBy, sortOrder } = filters;
    const skip = (page - 1) * limit;

    const where: Prisma.LocationWhereInput = {};

    // Address-level filters (search contacts, supportLevel, sign)
    const addressWhere: Prisma.AddressWhereInput = {};
    let hasAddressFilters = false;

    if (search) {
      // Search both location address and address contact info
      where.OR = [
        { address: { contains: search, mode: 'insensitive' } },
        {
          addresses: {
            some: {
              OR: [
                { firstName: { contains: search, mode: 'insensitive' } },
                { lastName: { contains: search, mode: 'insensitive' } },
                { email: { contains: search, mode: 'insensitive' } },
              ],
            },
          },
        },
      ];
    }

    if (supportLevel) {
      addressWhere.supportLevel = supportLevel;
      hasAddressFilters = true;
    }
    if (hasSign !== undefined) {
      addressWhere.sign = hasSign;
      hasAddressFilters = true;
    }

    if (hasAddressFilters) {
      where.addresses = { some: addressWhere };
    }

    // Confidence level filter (Location-level)
    if (confidenceLevel) {
      if (confidenceLevel === 'high') {
        where.geocodeConfidence = { gte: 85 };
      } else if (confidenceLevel === 'medium') {
        where.geocodeConfidence = { gte: 60, lt: 85 };
      } else if (confidenceLevel === 'low') {
        where.geocodeConfidence = { lt: 60, gt: 0 };
      } else if (confidenceLevel === 'none') {
        const noConfidenceConditions = [{ geocodeConfidence: null }, { geocodeConfidence: 0 }];
        where.OR = where.OR ? [...(where.OR as any[]), ...noConfidenceConditions] : noConfidenceConditions;
      }
    }

    const orderBy: Prisma.LocationOrderByWithRelationInput = { [sortBy]: sortOrder };

    const [locations, total] = await Promise.all([
      prisma.location.findMany({
        where,
        skip,
        take: limit,
        orderBy,
        include: {
          addresses: {
            select: {
              id: true,
              unitNumber: true,
              firstName: true,
              lastName: true,
              supportLevel: true,
              sign: true,
            },
          },
        },
      }),
      prisma.location.count({ where }),
    ]);

    return {
      locations,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  },

  async findById(id: string) {
    const location = await prisma.location.findUnique({
      where: { id },
      include: {
        addresses: {
          select: {
            id: true,
            unitNumber: true,
            firstName: true,
            lastName: true,
            email: true,
            phone: true,
            supportLevel: true,
            sign: true,
            signSize: true,
            notes: true,
          },
        },
      },
    });
    if (!location) {
      throw new AppError(404, 'Location not found', 'LOCATION_NOT_FOUND');
    }
    return location;
  },

  async create(data: CreateLocationInput, userId: string | null) {
    // Split data into Location (building) and Address (unit) fields
    const locationData: Prisma.LocationUncheckedCreateInput = {
      address: data.address,
      latitude: data.latitude as unknown as Prisma.Decimal,
      longitude: data.longitude as unknown as Prisma.Decimal,
      createdByUserId: userId,
    };

    const addressData = {
      unitNumber: data.unitNumber,
      firstName: data.firstName,
      lastName: data.lastName,
      email: data.email,
      phone: data.phone,
      supportLevel: data.supportLevel,
      sign: data.sign,
      signSize: data.signSize,
      notes: data.notes,
    };

    // Auto-geocode if address provided and no lat/lng
    let geocodeMetadata: Record<string, unknown> | undefined;
    if (data.address && data.latitude == null && data.longitude == null) {
      const result = await geocodingService.geocode(data.address);
      if (result) {
        locationData.latitude = result.latitude as unknown as Prisma.Decimal;
        locationData.longitude = result.longitude as unknown as Prisma.Decimal;
        locationData.geocodeConfidence = result.confidence;
        locationData.geocodeProvider = result.provider;
        geocodeMetadata = {
          provider: result.provider,
          confidence: result.confidence,
          geocoded: true,
        };
      }
    }

    // Create location, address, and record history in transaction
    const location = await prisma.$transaction(async (tx) => {
      const newLocation = await tx.location.create({ data: locationData });

      // Create default Address record (even if no occupant data)
      await tx.address.create({
        data: {
          locationId: newLocation.id,
          unitNumber: addressData.unitNumber || null,
          firstName: addressData.firstName || null,
          lastName: addressData.lastName || null,
          email: addressData.email || null,
          phone: addressData.phone || null,
          supportLevel: addressData.supportLevel || null,
          sign: addressData.sign ?? false,
          signSize: addressData.signSize || null,
          notes: addressData.notes || null,
          createdByUserId: userId,
        },
      });

      // Record creation history
      await tx.locationHistory.create({
        data: {
          locationId: newLocation.id,
          userId,
          action: geocodeMetadata ? LocationHistoryAction.GEOCODED : LocationHistoryAction.CREATED,
          metadata: geocodeMetadata ? (geocodeMetadata as unknown as Prisma.InputJsonValue) : undefined,
        },
      });

      return newLocation;
    });

    return location;
  },

  async update(id: string, data: UpdateLocationInput, userId: string) {
    const existing = await prisma.location.findUnique({
      where: { id },
      include: { addresses: true },
    });
    if (!existing) {
      throw new AppError(404, 'Location not found', 'LOCATION_NOT_FOUND');
    }

    // Split data into Location and Address fields
    const locationUpdateData: Prisma.LocationUncheckedUpdateInput = {
      updatedByUserId: userId,
    };

    const addressUpdateData: Prisma.AddressUncheckedUpdateInput = {
      updatedByUserId: userId,
    };

    // Location fields
    if (data.address !== undefined && data.address) locationUpdateData.address = data.address as string;
    if (data.latitude !== undefined) locationUpdateData.latitude = data.latitude as unknown as Prisma.Decimal;
    if (data.longitude !== undefined) locationUpdateData.longitude = data.longitude as unknown as Prisma.Decimal;
    if (data.postalCode !== undefined) locationUpdateData.postalCode = data.postalCode ?? undefined;
    if (data.province !== undefined) locationUpdateData.province = data.province ?? undefined;
    if (data.buildingType !== undefined) locationUpdateData.buildingType = data.buildingType ?? undefined;
    if (data.buildingNotes !== undefined) locationUpdateData.buildingNotes = data.buildingNotes ?? undefined;

    // Address fields (will update first address or create one)
    const addressFields = ['unitNumber', 'firstName', 'lastName', 'email', 'phone', 'supportLevel', 'sign', 'signSize', 'notes'] as const;
    let hasAddressUpdates = false;
    for (const field of addressFields) {
      if (data[field] !== undefined) {
        (addressUpdateData as Record<string, unknown>)[field] = data[field] === '' ? null : data[field];
        hasAddressUpdates = true;
      }
    }

    // Track field changes for history
    const changes: { field: string; oldValue: unknown; newValue: unknown }[] = [];
    let action: LocationHistoryAction = LocationHistoryAction.UPDATED;
    let metadata: Record<string, unknown> | undefined;

    // Check for explicit lat/lng changes (MOVED_ON_MAP)
    if (data.latitude !== undefined && Number(data.latitude) !== Number(existing.latitude)) {
      changes.push({ field: 'latitude', oldValue: existing.latitude, newValue: data.latitude });
      action = LocationHistoryAction.MOVED_ON_MAP;
    }
    if (data.longitude !== undefined && Number(data.longitude) !== Number(existing.longitude)) {
      changes.push({ field: 'longitude', oldValue: existing.longitude, newValue: data.longitude });
      action = LocationHistoryAction.MOVED_ON_MAP;
    }

    // Re-geocode if address changed and no explicit lat/lng provided
    if (data.address && data.address !== existing.address && data.latitude === undefined && data.longitude === undefined) {
      const result = await geocodingService.geocode(data.address as string);
      if (result) {
        locationUpdateData.latitude = result.latitude as unknown as Prisma.Decimal;
        locationUpdateData.longitude = result.longitude as unknown as Prisma.Decimal;
        locationUpdateData.geocodeConfidence = result.confidence;
        locationUpdateData.geocodeProvider = result.provider;
        action = LocationHistoryAction.GEOCODED;
        metadata = {
          provider: result.provider,
          confidence: result.confidence,
        };
        changes.push({ field: 'latitude', oldValue: existing.latitude, newValue: result.latitude });
        changes.push({ field: 'longitude', oldValue: existing.longitude, newValue: result.longitude });
      }
    }

    // Track location field changes
    if (data.address !== undefined && data.address !== existing.address) {
      changes.push({ field: 'address', oldValue: existing.address, newValue: data.address });
    }

    // Update location, address, and record history in transaction
    const location = await prisma.$transaction(async (tx) => {
      const updated = await tx.location.update({
        where: { id },
        data: locationUpdateData,
      });

      // Update address if there are address fields
      if (hasAddressUpdates) {
        if (existing.addresses.length > 0) {
          // Update first address (default unit)
          await tx.address.update({
            where: { id: existing.addresses[0].id },
            data: addressUpdateData,
          });
        } else {
          // Create address if none exists
          const { updatedByUserId, ...addressFields } = addressUpdateData;
          await tx.address.create({
            data: {
              locationId: id,
              ...addressFields,
              createdByUserId: userId,
            } as Prisma.AddressUncheckedCreateInput,
          });
        }
      }

      // Record history if there were changes
      if (changes.length > 0) {
        const historyRecords = changes.map(({ field, oldValue, newValue }) => ({
          locationId: id,
          userId,
          action,
          field,
          oldValue: oldValue != null ? String(oldValue) : null,
          newValue: newValue != null ? String(newValue) : null,
          metadata: metadata ? (metadata as unknown as Prisma.InputJsonValue) : null,
        }));
        await tx.locationHistory.createMany({ data: historyRecords as unknown as Prisma.LocationHistoryCreateManyInput[] });
      }

      return updated;
    });

    return location;
  },

  async delete(id: string) {
    const existing = await prisma.location.findUnique({ where: { id } });
    if (!existing) {
      throw new AppError(404, 'Location not found', 'LOCATION_NOT_FOUND');
    }
    await prisma.location.delete({ where: { id } });
  },

  async bulkDelete(ids: string[]) {
    const result = await prisma.location.deleteMany({
      where: { id: { in: ids } },
    });
    return { deleted: result.count };
  },

  async getHistory(locationId: string, page: number = 1, limit: number = 20) {
    const skip = (page - 1) * limit;
    const [history, total] = await Promise.all([
      prisma.locationHistory.findMany({
        where: { locationId },
        include: {
          user: {
            select: { id: true, email: true, name: true, role: true },
          },
        },
        orderBy: { createdAt: 'desc' },
        skip,
        take: limit,
      }),
      prisma.locationHistory.count({ where: { locationId } }),
    ]);

    return {
      history,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    };
  },

  async geocodeAddress(address: string) {
    const result = await geocodingService.geocode(address);
    if (!result) {
      throw new AppError(404, 'Could not geocode address', 'GEOCODE_FAILED');
    }
    return result;
  },

  async geocodeMissing() {
    // NOTE: In V2 schema, latitude/longitude/address are all non-nullable,
    // so this will return empty results. Kept for API compatibility.
    const missing = await prisma.location.findMany({
      where: {
        geocodeConfidence: null, // Find ungeocoded locations by confidence
      },
      select: { id: true, address: true },
      take: 100, // Limit batch size
    });

    let geocoded = 0;
    let failed = 0;

    for (const loc of missing) {
      if (!loc.address) continue;
      try {
        const result = await geocodingService.geocode(loc.address);
        if (result) {
          await prisma.location.update({
            where: { id: loc.id },
            data: {
              latitude: result.latitude,
              longitude: result.longitude,
              geocodeConfidence: result.confidence,
              geocodeProvider: result.provider,
            },
          });
          geocoded++;
        } else {
          failed++;
        }
      } catch (err) {
        logger.warn(`Failed to geocode location ${loc.id}:`, err);
        failed++;
      }
    }

    return { total: missing.length, geocoded, failed };
  },

  async getStats() {
    try {
      const [
        total,
        singleFamily,
        multiUnit,
        mixedUse,
        commercial,
        geocoded,
        // Confidence distribution
        confidenceHigh,
        confidenceMedium,
        confidenceLow,
        confidenceNone,
        // Provider breakdown
        nominatim,
        mapbox,
        arcgis,
        photon,
        google,
        locationiq,
        manual,
      ] = await Promise.all([
        prisma.location.count(),
        prisma.location.count({ where: { buildingType: 'SINGLE_FAMILY' } }),
        prisma.location.count({ where: { buildingType: 'MULTI_UNIT' } }),
        prisma.location.count({ where: { buildingType: 'MIXED_USE' } }),
        prisma.location.count({ where: { buildingType: 'COMMERCIAL' } }),
        prisma.location.count(), // All locations (latitude/longitude are non-nullable)
        // Confidence queries
        prisma.location.count({ where: { geocodeConfidence: { gte: 85 } } }),
        prisma.location.count({ where: { geocodeConfidence: { gte: 60, lt: 85 } } }),
        prisma.location.count({ where: { geocodeConfidence: { lt: 60 } } }),
        prisma.location.count({ where: { OR: [{ geocodeConfidence: null }, { geocodeConfidence: 0 }] } }),
        // Provider queries
        prisma.location.count({ where: { geocodeProvider: GeocodeProvider.NOMINATIM } }),
        prisma.location.count({ where: { geocodeProvider: GeocodeProvider.MAPBOX } }),
        prisma.location.count({ where: { geocodeProvider: GeocodeProvider.ARCGIS } }),
        prisma.location.count({ where: { geocodeProvider: GeocodeProvider.PHOTON } }),
        prisma.location.count({ where: { geocodeProvider: GeocodeProvider.GOOGLE } }),
        prisma.location.count({ where: { geocodeProvider: GeocodeProvider.LOCATIONIQ } }),
        prisma.location.count({ where: { OR: [{ geocodeProvider: GeocodeProvider.UNKNOWN }, { geocodeProvider: null }] } }),
      ]);

      // Calculate average confidence
      const avgConfidenceResult = await prisma.location.aggregate({
        _avg: { geocodeConfidence: true },
        where: { geocodeConfidence: { gt: 0 } }, // gt: 0 already excludes null
      });

      return {
        total,
        buildingTypes: {
          SINGLE_FAMILY: singleFamily,
          MULTI_UNIT: multiUnit,
          MIXED_USE: mixedUse,
          COMMERCIAL: commercial,
        },
        geocoded,
        ungeocoded: total - geocoded,
        // Confidence stats
        confidence: {
          high: confidenceHigh,
          medium: confidenceMedium,
          low: confidenceLow,
          none: confidenceNone,
          average: avgConfidenceResult._avg.geocodeConfidence
            ? Math.round(avgConfidenceResult._avg.geocodeConfidence)
            : null,
        },
        // Provider breakdown
        providers: {
          nominatim,
          mapbox,
          arcgis,
          photon,
          google,
          locationiq,
          manual,
        },
      };
    } catch (error) {
      logger.error('Failed to fetch location stats:', error);
      throw error; // Re-throw to trigger 500 response
    }
  },

  async getAllForMap(bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number }, limit: number = 5000) {
    const startTime = Date.now();
    const where: Prisma.LocationWhereInput = {};

    if (bounds) {
      // Fix Decimal type handling - convert bounds to Prisma.Decimal
      where.latitude = {
        gte: new Prisma.Decimal(bounds.minLat.toString()),
        lte: new Prisma.Decimal(bounds.maxLat.toString()),
      };
      where.longitude = {
        gte: new Prisma.Decimal(bounds.minLng.toString()),
        lte: new Prisma.Decimal(bounds.maxLng.toString()),
      };
    }

    const locations = await prisma.location.findMany({
      where,
      orderBy: { createdAt: 'desc' },
      take: limit, // Zoom-aware limit from frontend
      include: {
        addresses: {
          select: {
            id: true,
            unitNumber: true,
            firstName: true,
            lastName: true,
            email: true,
            phone: true,
            supportLevel: true,
            sign: true,
            signSize: true,
            notes: true,
          },
        },
      },
    });

    const durationSeconds = (Date.now() - startTime) / 1000;
    recordLocationQuery('admin_all', !!bounds, locations.length, durationSeconds);

    return locations;
  },

  /**
   * Public heatmap aggregate: buckets locations to ~1.1km precision (2 decimal places
   * of lat/lng) and returns counts only. No PII (addresses, support levels, signs,
   * unit numbers) is exposed to unauthenticated callers.
   *
   * Previously this endpoint returned raw coordinates + support levels + sign data,
   * which let adversaries build targeting databases of supporters. Hardened 2026-04-12.
   */
  async getPublicHeatmap(bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number }) {
    const startTime = Date.now();

    // Build bounds filter as SQL fragment (Prisma parameterizes via $queryRaw tagged template).
    // We use ROUND(..., 2) — 2 decimal places ≈ 1.1km at the equator. Buckets aggregate many
    // individual addresses into a single heatmap point, preventing reverse-lookup of residents.
    const rows = bounds
      ? await prisma.$queryRaw<Array<{ lat: number; lng: number; count: bigint }>>`
          SELECT
            ROUND(latitude::numeric, 2)::float8 AS lat,
            ROUND(longitude::numeric, 2)::float8 AS lng,
            COUNT(*)::bigint AS count
          FROM "locations"
          WHERE latitude BETWEEN ${bounds.minLat}::numeric AND ${bounds.maxLat}::numeric
            AND longitude BETWEEN ${bounds.minLng}::numeric AND ${bounds.maxLng}::numeric
          GROUP BY 1, 2
          LIMIT 10000
        `
      : await prisma.$queryRaw<Array<{ lat: number; lng: number; count: bigint }>>`
          SELECT
            ROUND(latitude::numeric, 2)::float8 AS lat,
            ROUND(longitude::numeric, 2)::float8 AS lng,
            COUNT(*)::bigint AS count
          FROM "locations"
          GROUP BY 1, 2
          LIMIT 10000
        `;

    const points = rows.map((r) => ({ lat: r.lat, lng: r.lng, count: Number(r.count) }));

    const durationSeconds = (Date.now() - startTime) / 1000;
    recordLocationQuery('public', !!bounds, points.length, durationSeconds);

    return { points };
  },

  async importFromCsv(buffer: Buffer, userId: string) {
    let records: Record<string, string>[];
    try {
      records = parse(buffer, {
        columns: true,
        skip_empty_lines: true,
        trim: true,
        bom: true,
      }) as Record<string, string>[];
    } catch {
      throw new AppError(400, 'Invalid CSV file format', 'INVALID_CSV');
    }

    if (!records.length) {
      throw new AppError(400, 'CSV file is empty', 'EMPTY_CSV');
    }

    // Detect column mapping from headers
    const headers = Object.keys(records[0]!);
    const columnMap: Record<string, keyof CsvRow> = {};
    for (const header of headers) {
      const normalized = header.toLowerCase().trim();
      const mapped = CSV_HEADER_MAP[normalized];
      if (mapped) {
        columnMap[header] = mapped;
      }
    }

    let success = 0;
    let warnings = 0;
    let failed = 0;
    const errors: string[] = [];

    for (let i = 0; i < records.length; i++) {
      const record = records[i]!;
      try {
        const row: CsvRow = {};
        for (const [header, field] of Object.entries(columnMap)) {
          const value = record[header];
          if (value !== undefined && value !== '') {
            row[field] = value;
          }
        }

        if (!row.address) {
          errors.push(`Row ${i + 2}: Missing address`);
          failed++;
          continue;
        }

        // Split into Location and Address data
        const locationData: Partial<Prisma.LocationUncheckedCreateInput> & { address: string; createdByUserId: string } = {
          address: row.address,
          createdByUserId: userId,
        };

        const addressData = {
          firstName: row.firstName || null,
          lastName: row.lastName || null,
          email: row.email || null,
          phone: row.phone || null,
          unitNumber: row.unitNumber || null,
          supportLevel: row.supportLevel ? parseSupportLevel(row.supportLevel) : null,
          sign: row.sign ? parseBoolean(row.sign) : false,
          signSize: row.signSize || null,
          notes: row.notes || null,
        };

        // Use provided lat/lng or geocode
        if (row.latitude && row.longitude) {
          const lat = parseFloat(row.latitude);
          const lng = parseFloat(row.longitude);
          if (!isNaN(lat) && !isNaN(lng)) {
            locationData.latitude = lat;
            locationData.longitude = lng;
          }
        } else {
          const result = await geocodingService.geocode(row.address);
          if (result) {
            locationData.latitude = result.latitude;
            locationData.longitude = result.longitude;
            locationData.geocodeConfidence = result.confidence;
            locationData.geocodeProvider = result.provider;
          } else {
            warnings++;
          }
        }

        // Create Location and Address in transaction
        const newLocation = await prisma.location.create({ data: locationData as Prisma.LocationUncheckedCreateInput });
        await prisma.address.create({
          data: {
            locationId: newLocation.id,
            ...addressData,
            createdByUserId: userId,
          },
        });
        success++;
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Unknown error';
        errors.push(`Row ${i + 2}: ${msg}`);
        failed++;
      }
    }

    return { total: records.length, success, warnings, failed, errors: errors.slice(0, 50) };
  },

  async reverseGeocode(latitude: number, longitude: number) {
    const result = await geocodingService.reverseGeocode(latitude, longitude);
    if (!result) {
      throw new AppError(404, 'Could not reverse geocode coordinates', 'REVERSE_GEOCODE_FAILED');
    }
    return result;
  },

  async importBulk(
    buffer: Buffer,
    userId: string,
    options: BulkImportInput,
    filters?: ImportFilters,
  ) {
    let records: Record<string, string>[];
    try {
      records = parse(buffer, {
        columns: true,
        skip_empty_lines: true,
        trim: true,
        bom: true,
      }) as Record<string, string>[];
    } catch {
      throw new AppError(400, 'Invalid CSV file format', 'INVALID_CSV');
    }

    if (!records.length) {
      throw new AppError(400, 'CSV file is empty', 'EMPTY_CSV');
    }

    const headers = Object.keys(records[0]!);
    const isNar = options.format === 'nar' || detectNarFormat(headers);

    // Build header mapping for NAR (map original CSV header → internal key)
    const headerMap: Record<string, string> = {};
    if (isNar) {
      for (const header of headers) {
        const lower = header.trim().toLowerCase();
        const alias = NAR_HEADER_MAP[lower];
        if (alias) {
          headerMap[header] = alias;
        }
      }
    }

    // Load existing coordinates for deduplication
    const existingCoords = new Set<string>();
    if (options.deduplicateRadius > 0) {
      const existing = await prisma.location.findMany({
        select: { latitude: true, longitude: true },
      }); // No where clause needed - latitude/longitude are non-nullable
      for (const loc of existing) {
        const key = `${roundCoord(Number(loc.latitude))}:${roundCoord(Number(loc.longitude))}`;
        existingCoords.add(key);
      }
    }

    const inFileCoords = new Set<string>();
    let created = 0;
    let skippedDuplicate = 0;
    let skippedOutOfBounds = 0;
    let skippedInvalid = 0;
    const errors: string[] = [];

    // Phase 1: Parse all records and collect addresses needing geocoding
    interface ParsedRecord {
      rowIndex: number;
      address: string;
      unitNumber?: string;
      lat?: number;
      lng?: number;
      postalCode?: string;
      province?: string;
      federalDistrict?: string;
      buildingUse?: number;
      needsGeocoding: boolean;
    }

    const parsedRecords: ParsedRecord[] = [];
    const addressesToGeocode: string[] = [];
    const geocodeIndexMap: Map<number, number> = new Map(); // maps parsedRecord index → geocode result index

    for (let i = 0; i < records.length; i++) {
      const record = records[i]!;

      try {
        let address: string | undefined;
        let unitNumber: string | undefined;
        let lat: number | undefined;
        let lng: number | undefined;
        let postalCode: string | undefined;
        let province: string | undefined;
        let federalDistrict: string | undefined;
        let buildingUse: number | undefined;

        if (isNar) {
          // Build address from NAR fields (supports 2025 + legacy format)
          const getValue = (narKey: string): string => {
            for (const [header, mapped] of Object.entries(headerMap)) {
              if (mapped === narKey) return (record[header] ?? '').trim();
            }
            return '';
          };

          // Build civic address: "123A Main ST W"
          const civicNo = getValue('CIVIC_NO');
          const civicSuffix = getValue('CIVIC_NO_SUFFIX');
          const streetName = getValue('STREET_NAME');
          const streetType = getValue('STREET_TYPE');
          const streetDir = getValue('STREET_DIR');

          const streetParts = [
            civicNo + (civicSuffix || ''),
            streetName,
            streetType,
            streetDir,
          ].filter(Boolean);
          address = streetParts.join(' ');

          const city = getValue('CITY') || getValue('CSD_NAME');
          province = getValue('PROV') || undefined;

          // City/province filters (fast string check before coordinate work)
          if (filters?.city && city.toLowerCase() !== filters.city.toLowerCase()) {
            skippedOutOfBounds++;
            continue;
          }
          if (filters?.province && province && province.toLowerCase() !== filters.province.toLowerCase()) {
            skippedOutOfBounds++;
            continue;
          }

          if (city) address += `, ${city}`;
          if (province) address += `, ${province}`;

          unitNumber = getValue('UNIT_NBR') || undefined;
          postalCode = getValue('POSTAL_CODE') || undefined;
          federalDistrict = getValue('FED_DISTRICT') || undefined;

          const buUse = getValue('BU_USE');
          if (buUse) {
            const parsed = parseInt(buUse, 10);
            if (!isNaN(parsed)) buildingUse = parsed;
          }

          // Residential filter: skip non-residential (BU_USE 3) if filtering
          if (options.residentialOnly && buildingUse === 3) {
            skippedOutOfBounds++;
            continue;
          }

          // Coordinates: prefer BG_LATITUDE/BG_LONGITUDE, fallback to BG_X/BG_Y (Lambert→WGS84), then LAT/LNG
          const bgLatStr = getValue('LAT');
          const bgLngStr = getValue('LNG');
          if (bgLatStr && bgLngStr) {
            lat = parseFloat(bgLatStr);
            lng = parseFloat(bgLngStr);
            if (isNaN(lat) || isNaN(lng)) { lat = undefined; lng = undefined; }
          }

          if (lat === undefined || lng === undefined) {
            const bgXStr = getValue('BG_X');
            const bgYStr = getValue('BG_Y');
            if (bgXStr && bgYStr) {
              const bgX = parseFloat(bgXStr);
              const bgY = parseFloat(bgYStr);
              if (!isNaN(bgX) && !isNaN(bgY)) {
                [lat, lng] = lambertToLatLng(bgX, bgY);
              }
            }
          }
        } else {
          // Standard CSV - use existing header mapping
          const row: CsvRow = {};
          for (const [header, field] of Object.entries(CSV_HEADER_MAP)) {
            for (const h of headers) {
              if (h.toLowerCase().trim() === header) {
                const value = record[h];
                if (value !== undefined && value !== '') {
                  row[field] = value;
                }
              }
            }
          }
          address = row.address;
          unitNumber = row.unitNumber;
          if (row.latitude && row.longitude) {
            lat = parseFloat(row.latitude);
            lng = parseFloat(row.longitude);
            if (isNaN(lat) || isNaN(lng)) { lat = undefined; lng = undefined; }
          }
        }

        if (!address) {
          skippedInvalid++;
          if (i < 20) errors.push(`Row ${i + 2}: Missing address`);
          continue;
        }

        const needsGeocoding = lat === undefined && lng === undefined && !options.skipGeocoding;

        parsedRecords.push({
          rowIndex: i,
          address,
          unitNumber,
          lat,
          lng,
          postalCode,
          province,
          federalDistrict,
          buildingUse,
          needsGeocoding,
        });

        if (needsGeocoding) {
          geocodeIndexMap.set(parsedRecords.length - 1, addressesToGeocode.length);
          addressesToGeocode.push(address);
        }
      } catch (err) {
        skippedInvalid++;
        if (errors.length < 50) {
          const msg = err instanceof Error ? err.message : 'Unknown error';
          errors.push(`Row ${i + 2}: ${msg}`);
        }
      }
    }

    // Phase 2: Batch geocode all addresses in parallel
    let geocodeResults: (typeof geocodingService.geocode extends (...args: any) => Promise<infer R> ? R : never)[] = [];
    if (addressesToGeocode.length > 0 && !options.skipGeocoding) {
      logger.info(`Batch geocoding ${addressesToGeocode.length} addresses...`);
      geocodeResults = await geocodingService.geocodeBatch(addressesToGeocode);
      logger.info(`Batch geocoding complete: ${geocodeResults.filter(r => r !== null).length} succeeded`);
    }

    // Phase 3: Apply geocoding results and create location records
    const batch: Prisma.LocationCreateManyInput[] = [];

    for (let i = 0; i < parsedRecords.length; i++) {
      const parsed = parsedRecords[i]!;
      let { lat, lng } = parsed;

      try {
        // Apply geocoding result if needed
        if (parsed.needsGeocoding) {
          const geocodeIndex = geocodeIndexMap.get(i);
          if (geocodeIndex !== undefined) {
            const result = geocodeResults[geocodeIndex];
            if (result) {
              lat = result.latitude;
              lng = result.longitude;
            }
          }
        }

        // Skip if no coordinates
        if (lat === undefined || lng === undefined) {
          skippedInvalid++;
          if (errors.length < 20) errors.push(`Row ${parsed.rowIndex + 2}: No coordinates`);
          continue;
        }

        // Cut boundary filter
        if (filters?.cutPolygon && filters.cutPolygon.length > 0) {
          const inside = filters.cutPolygon.some((ring) => isPointInPolygon(lat!, lng!, ring));
          if (!inside) {
            skippedOutOfBounds++;
            continue;
          }
        }

        // Bounding box filter (map area)
        if (filters?.bounds) {
          const { north, south, east, west } = filters.bounds;
          if (lat! < south || lat! > north || lng! < west || lng! > east) {
            skippedOutOfBounds++;
            continue;
          }
        }

        // Deduplication
        if (options.deduplicateRadius > 0) {
          const coordKey = `${roundCoord(lat)}:${roundCoord(lng)}`;
          if (existingCoords.has(coordKey) || inFileCoords.has(coordKey)) {
            skippedDuplicate++;
            continue;
          }
          inFileCoords.add(coordKey);
        }

        batch.push({
          address: parsed.address,
          latitude: lat,
          longitude: lng,
          postalCode: parsed.postalCode,
          province: parsed.province,
          federalDistrict: parsed.federalDistrict,
          buildingUse: parsed.buildingUse,
          createdByUserId: userId,
        });

        // Flush batch
        if (batch.length >= options.batchSize) {
          const result = await prisma.location.createMany({ data: [...batch], skipDuplicates: true });
          created += result.count;
          batch.length = 0;
        }
      } catch (err) {
        skippedInvalid++;
        if (errors.length < 50) {
          const msg = err instanceof Error ? err.message : 'Unknown error';
          errors.push(`Row ${parsed.rowIndex + 2}: ${msg}`);
        }
      }
    }

    // Flush remaining
    if (batch.length > 0) {
      const result = await prisma.location.createMany({ data: batch, skipDuplicates: true });
      created += result.count;
    }

    return {
      total: records.length,
      created,
      skippedDuplicate,
      skippedOutOfBounds,
      skippedInvalid,
      errors: errors.slice(0, 50),
    };
  },

  async exportToCsv(filters?: { supportLevel?: SupportLevel; hasSign?: boolean }) {
    const where: Prisma.LocationWhereInput = {};

    // Build address filters
    const addressWhere: Prisma.AddressWhereInput = {};
    if (filters?.supportLevel) addressWhere.supportLevel = filters.supportLevel;
    if (filters?.hasSign !== undefined) addressWhere.sign = filters.hasSign;

    // If there are address filters, only include locations with matching addresses
    if (Object.keys(addressWhere).length > 0) {
      where.addresses = { some: addressWhere };
    }

    const locations = await prisma.location.findMany({
      where,
      include: { addresses: true },
      orderBy: { createdAt: 'desc' },
    });

    // Sanitize a field value against CSV formula injection.
    // Spreadsheet apps treat cells starting with =, +, -, @, \t, \r as formulas.
    // Prefixing with an apostrophe causes Excel/Sheets to treat the value as plain text.
    function sanitizeCsvField(value: string): string {
      if (/^[=+\-@\t\r]/.test(value)) return `'${value}`;
      return value;
    }

    // Flatten: one row per address
    const rows: Array<Record<string, string>> = [];
    for (const loc of locations) {
      for (const addr of loc.addresses) {
        // Skip addresses that don't match filters
        if (filters?.supportLevel && addr.supportLevel !== filters.supportLevel) continue;
        if (filters?.hasSign !== undefined && addr.sign !== filters.hasSign) continue;

        rows.push({
          address: sanitizeCsvField(loc.address || ''),
          unitNumber: sanitizeCsvField(addr.unitNumber || ''),
          firstName: sanitizeCsvField(addr.firstName || ''),
          lastName: sanitizeCsvField(addr.lastName || ''),
          email: sanitizeCsvField(addr.email || ''),
          phone: sanitizeCsvField(addr.phone || ''),
          supportLevel: addr.supportLevel || '',
          sign: addr.sign ? 'Yes' : 'No',
          signSize: addr.signSize || '',
          notes: sanitizeCsvField(addr.notes || ''),
          latitude: loc.latitude?.toString() || '',
          longitude: loc.longitude?.toString() || '',
          geocodeConfidence: loc.geocodeConfidence?.toString() || '',
          geocodeProvider: loc.geocodeProvider || '',
          createdAt: loc.createdAt.toISOString(),
        });
      }
    }

    return stringify(rows, { header: true });
  },
};
