import { Prisma } from '@prisma/client';
import { prisma } from '../../../config/database';
import { redis } from '../../../config/redis';
import { logger } from '../../../utils/logger';
import { env } from '../../../config/env';
import {
  calculateBounds,
  parseGeoJsonPolygon,
  boundsFromCenterZoom,
  isPointInPolygon,
  haversineDistance,
} from '../../../utils/spatial';
import { overpassService, type CandidateLocation } from './overpass.service';
import { narImportService } from './nar-import.service';
import { geocodingService } from '../geocoding/geocoding.service';
import type { AreaImportPreviewInput, AreaImportStartInput } from './area-import.schemas';

// ---- Types ----

export type SourceStatus = 'pending' | 'running' | 'complete' | 'failed' | 'skipped';

export interface AreaImportSourceProgress {
  status: SourceStatus;
  candidatesFound: number;
  failedQuadrants?: number;
  message?: string;
  error?: string;
}

export interface AreaImportProgress {
  status: 'initializing' | 'running' | 'creating-records' | 'complete' | 'failed';
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number } | null;
  areaSqKm: number;
  sources: {
    osm: AreaImportSourceProgress;
    nar: AreaImportSourceProgress;
    reverseGeocode: AreaImportSourceProgress;
  };
  locationsCreated: number;
  addressesCreated: number;
  skippedDuplicate: number;
  totalCandidates: number;
  error?: string;
}

export interface AreaImportPreviewResult {
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
  areaSqKm: number;
  existingLocations: number;
  estimates: {
    osm: number;
    nar: number;
    reverseGeocode: number;
  };
  narProvincesDetected: string[];
}

// ---- Helpers ----

const PROGRESS_TTL = 3600; // 1 hour
const PROGRESS_KEY_PREFIX = 'area-import:';

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

function coordKey(lat: number, lng: number): string {
  return `${roundCoord(lat)}:${roundCoord(lng)}`;
}

function areaSqKm(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }): number {
  // Approximate area in sq km using lat/lng → meters
  const latMeters = (bounds.maxLat - bounds.minLat) * 111320;
  const avgLat = (bounds.minLat + bounds.maxLat) / 2;
  const lngMeters = (bounds.maxLng - bounds.minLng) * 111320 * Math.cos((avgLat * Math.PI) / 180);
  return (latMeters * lngMeters) / 1_000_000;
}

async function writeProgress(importId: string, progress: AreaImportProgress): Promise<void> {
  try {
    await redis.set(
      `${PROGRESS_KEY_PREFIX}${importId}`,
      JSON.stringify(progress),
      'EX',
      PROGRESS_TTL,
    );
  } catch (err) {
    logger.warn('Failed to write area import progress to Redis', err);
  }
}

/**
 * Resolve bounding box from area type options.
 */
async function resolveBounds(
  options: AreaImportPreviewInput | AreaImportStartInput,
): Promise<{
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
  cutPolygons?: number[][][];
}> {
  if (options.areaType === 'cut' && options.cutId) {
    const cut = await prisma.cut.findUnique({ where: { id: options.cutId } });
    if (!cut) throw new Error('Cut not found');

    const polygons = parseGeoJsonPolygon(cut.geojson);
    // Flatten all polygon coordinates for bounds calculation
    const allCoords = polygons.flat();
    const bounds = calculateBounds(allCoords);

    return { bounds, cutPolygons: polygons };
  }

  if (options.areaType === 'viewport' && options.center && options.zoom !== undefined) {
    const bounds = boundsFromCenterZoom(
      options.center.lat,
      options.center.lng,
      options.zoom,
      options.viewportWidth,
      options.viewportHeight,
    );
    return { bounds };
  }

  throw new Error('Invalid area type configuration');
}

/**
 * Load existing location coordinates within bounds for deduplication.
 */
async function loadExistingCoords(
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
): Promise<Set<string>> {
  const existing = await prisma.location.findMany({
    where: {
      latitude: { gte: new Prisma.Decimal(bounds.minLat.toString()), lte: new Prisma.Decimal(bounds.maxLat.toString()) },
      longitude: { gte: new Prisma.Decimal(bounds.minLng.toString()), lte: new Prisma.Decimal(bounds.maxLng.toString()) },
    },
    select: { latitude: true, longitude: true },
  });

  const coords = new Set<string>();
  for (const loc of existing) {
    coords.add(coordKey(Number(loc.latitude), Number(loc.longitude)));
  }
  return coords;
}

/**
 * Rough province bounding boxes for auto-detecting which NAR datasets
 * might overlap with the import area.
 */
const PROVINCE_BOUNDS: Record<string, { minLat: number; maxLat: number; minLng: number; maxLng: number }> = {
  '10': { minLat: 46.6, maxLat: 60.4, minLng: -67.8, maxLng: -52.6 }, // NL
  '11': { minLat: 45.9, maxLat: 47.1, minLng: -64.4, maxLng: -62.0 }, // PE
  '12': { minLat: 43.4, maxLat: 47.0, minLng: -66.4, maxLng: -59.7 }, // NS
  '13': { minLat: 44.6, maxLat: 48.1, minLng: -69.1, maxLng: -63.8 }, // NB
  '24': { minLat: 45.0, maxLat: 62.6, minLng: -79.8, maxLng: -57.1 }, // QC
  '35': { minLat: 41.7, maxLat: 56.9, minLng: -95.2, maxLng: -74.3 }, // ON
  '46': { minLat: 49.0, maxLat: 60.0, minLng: -102.0, maxLng: -88.9 }, // MB
  '47': { minLat: 49.0, maxLat: 60.0, minLng: -110.0, maxLng: -101.4 }, // SK
  '48': { minLat: 49.0, maxLat: 60.0, minLng: -120.0, maxLng: -110.0 }, // AB
  '59': { minLat: 48.3, maxLat: 60.0, minLng: -139.1, maxLng: -114.1 }, // BC
  '60': { minLat: 60.0, maxLat: 69.6, minLng: -141.0, maxLng: -124.0 }, // YT
  '61': { minLat: 60.0, maxLat: 78.8, minLng: -136.5, maxLng: -102.0 }, // NT
  '62': { minLat: 51.7, maxLat: 83.1, minLng: -120.4, maxLng: -61.2 }, // NU
};

function detectOverlappingProvinces(
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
): string[] {
  const overlapping: string[] = [];
  for (const [code, pb] of Object.entries(PROVINCE_BOUNDS)) {
    const overlaps = bounds.minLat <= pb.maxLat && bounds.maxLat >= pb.minLat &&
      bounds.minLng <= pb.maxLng && bounds.maxLng >= pb.minLng;
    if (overlaps) overlapping.push(code);
  }
  return overlapping;
}

/**
 * Generate grid points within bounds for reverse geocoding.
 */
function generateGridPoints(
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
  spacingMeters: number,
  maxPoints: number,
  cutPolygons?: number[][][],
): { lat: number; lng: number }[] {
  const avgLat = (bounds.minLat + bounds.maxLat) / 2;
  const latStep = spacingMeters / 111320;
  const lngStep = spacingMeters / (111320 * Math.cos((avgLat * Math.PI) / 180));

  const points: { lat: number; lng: number }[] = [];
  for (let lat = bounds.minLat; lat <= bounds.maxLat; lat += latStep) {
    for (let lng = bounds.minLng; lng <= bounds.maxLng; lng += lngStep) {
      if (points.length >= maxPoints) break;

      // If cut polygon provided, only include points inside it
      if (cutPolygons && cutPolygons.length > 0) {
        const inside = cutPolygons.some((ring) => isPointInPolygon(lat, lng, ring));
        if (!inside) continue;
      }

      points.push({ lat, lng });
    }
    if (points.length >= maxPoints) break;
  }

  return points;
}

// ---- Main Service ----

export const areaImportService = {
  /**
   * Preview area import: returns bounds, area, estimates, and existing count.
   */
  async previewAreaImport(options: AreaImportPreviewInput): Promise<AreaImportPreviewResult> {
    const { bounds, cutPolygons } = await resolveBounds(options);
    const area = areaSqKm(bounds);

    // Count existing locations in bounds
    const existingCount = await prisma.location.count({
      where: {
        latitude: { gte: new Prisma.Decimal(bounds.minLat.toString()), lte: new Prisma.Decimal(bounds.maxLat.toString()) },
        longitude: { gte: new Prisma.Decimal(bounds.minLng.toString()), lte: new Prisma.Decimal(bounds.maxLng.toString()) },
      },
    });

    const estimates = { osm: 0, nar: 0, reverseGeocode: 0 };
    const narProvincesDetected: string[] = [];

    // OSM estimate
    if (options.sources.osm) {
      const count = await overpassService.estimateCount(bounds);
      estimates.osm = count >= 0 ? count : -1;
    }

    // NAR estimate: detect provinces, count total address file sizes as rough guide
    if (options.sources.nar) {
      const overlapping = detectOverlappingProvinces(bounds);
      narProvincesDetected.push(...overlapping);

      if (overlapping.length > 0) {
        const { datasets } = await narImportService.listDatasets();
        for (const code of overlapping) {
          const ds = datasets.find((d) => d.provinceCode === code);
          if (ds && ds.addressFiles.length > 0) {
            // Very rough estimate: ~50 bytes per address row, filtered by area ratio
            const provinceArea = areaSqKm(PROVINCE_BOUNDS[code]!);
            const overlapRatio = Math.min(1, area / provinceArea);
            const totalRows = ds.totalAddressSize / 50;
            estimates.nar += Math.round(totalRows * overlapRatio);
          }
        }
      }
    }

    // Reverse geocode estimate: number of grid points
    if (options.sources.reverseGeocode) {
      const rgConfig = typeof options.sources.reverseGeocode === 'object'
        ? options.sources.reverseGeocode
        : { gridSpacingMeters: 100, maxPoints: env.AREA_IMPORT_MAX_GRID_POINTS };
      const points = generateGridPoints(bounds, rgConfig.gridSpacingMeters, rgConfig.maxPoints, cutPolygons);
      estimates.reverseGeocode = points.length;
    }

    return { bounds, areaSqKm: area, existingLocations: existingCount, estimates, narProvincesDetected };
  },

  /**
   * Get import progress from Redis.
   */
  async getProgress(importId: string): Promise<AreaImportProgress | null> {
    const data = await redis.get(`${PROGRESS_KEY_PREFIX}${importId}`);
    if (!data) return null;
    return JSON.parse(data) as AreaImportProgress;
  },

  /**
   * Run the full area import (fire-and-forget).
   */
  async runAreaImport(
    userId: string,
    options: AreaImportStartInput,
    importId: string,
  ): Promise<void> {
    const progress: AreaImportProgress = {
      status: 'initializing',
      bounds: null,
      areaSqKm: 0,
      sources: {
        osm: { status: 'pending', candidatesFound: 0 },
        nar: { status: 'pending', candidatesFound: 0 },
        reverseGeocode: { status: 'pending', candidatesFound: 0 },
      },
      locationsCreated: 0,
      addressesCreated: 0,
      skippedDuplicate: 0,
      totalCandidates: 0,
    };

    const updateProgress = async (updates?: Partial<AreaImportProgress>) => {
      if (updates) Object.assign(progress, updates);
      await writeProgress(importId, progress);
    };

    try {
      // 1. Resolve bounds
      const { bounds, cutPolygons } = await resolveBounds(options);
      const area = areaSqKm(bounds);
      await updateProgress({ bounds, areaSqKm: area, status: 'running' });

      // 2. Load existing coords for dedup
      const existingCoords = options.deduplicateRadius > 0
        ? await loadExistingCoords(bounds)
        : new Set<string>();

      // 3. Run enabled sources in parallel (OSM=network, NAR=disk — they don't compete)
      const allCandidates: CandidateLocation[] = [];

      // Mark skipped sources
      if (!options.sources.osm) progress.sources.osm.status = 'skipped';
      if (!options.sources.nar) progress.sources.nar.status = 'skipped';
      if (!options.sources.reverseGeocode) progress.sources.reverseGeocode.status = 'skipped';
      await updateProgress();

      const sourcePromises: Promise<void>[] = [];

      // --- OSM Source ---
      if (options.sources.osm) {
        sourcePromises.push((async () => {
          progress.sources.osm.status = 'running';
          await updateProgress();
          try {
            const osmResult = await overpassService.queryArea(bounds, (msg) => {
              progress.sources.osm.message = msg;
              writeProgress(importId, progress).catch(() => {});
            });

            // Filter by cut polygon if applicable
            let filtered = osmResult.candidates;
            if (cutPolygons && cutPolygons.length > 0) {
              filtered = osmResult.candidates.filter((c) =>
                cutPolygons.some((ring) => isPointInPolygon(c.latitude, c.longitude, ring)),
              );
            }

            for (const c of filtered) allCandidates.push(c);
            progress.sources.osm.status = 'complete';
            progress.sources.osm.candidatesFound = filtered.length;
            if (osmResult.failedQuadrants > 0) {
              progress.sources.osm.failedQuadrants = osmResult.failedQuadrants;
              progress.sources.osm.message = `${filtered.length} found, ${osmResult.failedQuadrants} quadrant(s) failed`;
            }
            await updateProgress();
            logger.info(`OSM source: ${filtered.length} candidates (${osmResult.candidates.length} pre-filter, ${osmResult.failedQuadrants} failed quadrants)`);
          } catch (err) {
            const msg = err instanceof Error ? err.message : 'Unknown error';
            progress.sources.osm.status = 'failed';
            progress.sources.osm.error = msg;
            await updateProgress();
            logger.error(`OSM source failed: ${msg}`);
          }
        })());
      }

      // --- NAR Source ---
      if (options.sources.nar) {
        sourcePromises.push((async () => {
          progress.sources.nar.status = 'running';
          progress.sources.nar.message = 'Detecting provinces...';
          await updateProgress();
          try {
            const overlapping = detectOverlappingProvinces(bounds);
            if (overlapping.length === 0) {
              progress.sources.nar.status = 'complete';
              progress.sources.nar.message = 'No NAR data for this area';
              await updateProgress();
              return;
            }

            const { datasets } = await narImportService.listDatasets();
            const narCandidates: CandidateLocation[] = [];
            const narConfig = typeof options.sources.nar === 'object' ? options.sources.nar : { residentialOnly: true };

            for (const code of overlapping) {
              const dataset = datasets.find((d) => d.provinceCode === code);
              if (!dataset || dataset.addressFiles.length === 0) continue;

              progress.sources.nar.message = `Processing ${dataset.provinceName}...`;
              await updateProgress();

              // Load location lookup for this province
              const locationLookup = dataset.locationFiles.length > 0
                ? await narImportService.loadLocationLookup(dataset.locationFiles)
                : new Map<string, { lat: number; lng: number; fedDistrict?: string }>();

              // Stream address files and filter by bounds
              for (const addressFile of dataset.addressFiles) {
                const { parse } = await import('csv-parse');
                const fs = await import('fs');

                const parser = fs.createReadStream(addressFile.fullPath).pipe(
                  parse({ columns: true, skip_empty_lines: true, trim: true, bom: true }),
                );

                for await (const record of parser) {
                  const locGuid = (record.LOC_GUID ?? '').trim();
                  if (!locGuid) continue;

                  // Residential filter
                  const buUse = parseInt(record.BU_USE ?? '', 10);
                  if (narConfig.residentialOnly && buUse === 3) continue;

                  // Get coordinates
                  let lat: number | undefined;
                  let lng: number | undefined;
                  let federalDistrict: string | undefined;

                  const locData = locationLookup.get(locGuid);
                  if (locData) {
                    lat = locData.lat;
                    lng = locData.lng;
                    federalDistrict = locData.fedDistrict;
                  }

                  if (lat === undefined || lng === undefined) continue;

                  // Bounds filter
                  if (lat < bounds.minLat || lat > bounds.maxLat || lng < bounds.minLng || lng > bounds.maxLng) continue;

                  // Cut polygon filter
                  if (cutPolygons && cutPolygons.length > 0) {
                    const inside = cutPolygons.some((ring) => isPointInPolygon(lat!, lng!, ring));
                    if (!inside) continue;
                  }

                  // Build address string
                  const civicNo = (record.CIVIC_NO ?? '').trim();
                  const civicSuffix = (record.CIVIC_NO_SUFFIX ?? '').trim();
                  const streetName = (record.OFFICIAL_STREET_NAME ?? '').trim();
                  const streetType = (record.OFFICIAL_STREET_TYPE ?? '').trim();
                  const streetDir = (record.OFFICIAL_STREET_DIR ?? '').trim();
                  const city = (record.MAIL_MUN_NAME ?? record.CSD_ENG_NAME ?? '').trim();
                  const prov = (record.MAIL_PROV_ABVN ?? '').trim();
                  const postalCode = (record.MAIL_POSTAL_CODE ?? '').trim() || undefined;

                  if (!streetName) continue;

                  const streetParts = [
                    civicNo + (civicSuffix || ''),
                    streetName,
                    streetType,
                    streetDir,
                  ].filter(Boolean);
                  let address = streetParts.join(' ');
                  if (city) address += `, ${city}`;
                  if (prov) address += `, ${prov}`;

                  narCandidates.push({
                    latitude: lat,
                    longitude: lng,
                    address,
                    postalCode,
                    city: city || undefined,
                    province: prov || undefined,
                    source: 'nar',
                    confidence: 90,
                    priority: 3,
                  });
                }
              }
            }

            for (const c of narCandidates) allCandidates.push(c);
            progress.sources.nar.status = 'complete';
            progress.sources.nar.candidatesFound = narCandidates.length;
            await updateProgress();
            logger.info(`NAR source: ${narCandidates.length} candidates`);
          } catch (err) {
            const msg = err instanceof Error ? err.message : 'Unknown error';
            progress.sources.nar.status = 'failed';
            progress.sources.nar.error = msg;
            await updateProgress();
            logger.error(`NAR source failed: ${msg}`);
          }
        })());
      }

      // Wait for OSM and NAR to finish before starting reverse geocode
      await Promise.all(sourcePromises);

      // --- Reverse Geocode Source (runs after OSM/NAR for better dedup) ---
      if (options.sources.reverseGeocode) {
        progress.sources.reverseGeocode.status = 'running';
        await updateProgress();
        try {
          const rgConfig = typeof options.sources.reverseGeocode === 'object'
            ? options.sources.reverseGeocode
            : { gridSpacingMeters: 100, maxPoints: env.AREA_IMPORT_MAX_GRID_POINTS };

          const gridPoints = generateGridPoints(
            bounds,
            rgConfig.gridSpacingMeters,
            rgConfig.maxPoints,
            cutPolygons,
          );

          // Build set of already-discovered locations for proximity skip
          const discoveredCoords = new Set<string>();
          for (const c of allCandidates) {
            discoveredCoords.add(coordKey(c.latitude, c.longitude));
          }

          const rgCandidates: CandidateLocation[] = [];
          let processed = 0;

          for (const point of gridPoints) {
            // Skip points near already-discovered locations
            const pk = coordKey(point.lat, point.lng);
            if (discoveredCoords.has(pk) || existingCoords.has(pk)) {
              processed++;
              continue;
            }

            // Also check proximity (30m) to any discovered candidate
            let tooClose = false;
            for (const c of allCandidates) {
              if (haversineDistance(point.lat, point.lng, c.latitude, c.longitude) < 30) {
                tooClose = true;
                break;
              }
            }
            if (tooClose) {
              processed++;
              continue;
            }

            try {
              const result = await geocodingService.reverseGeocode(point.lat, point.lng);
              if (result && result.address) {
                const candidate: CandidateLocation = {
                  latitude: point.lat,
                  longitude: point.lng,
                  address: result.address,
                  city: result.city,
                  province: result.province,
                  source: 'reverse-geocode',
                  confidence: 40,
                  priority: 1,
                };
                rgCandidates.push(candidate);
                discoveredCoords.add(pk);
              }
            } catch {
              // Skip failed reverse geocode points
            }

            processed++;
            if (processed % 10 === 0) {
              progress.sources.reverseGeocode.message = `${processed}/${gridPoints.length} grid points`;
              progress.sources.reverseGeocode.candidatesFound = rgCandidates.length;
              await updateProgress();
            }

            // Nominatim rate limit: ~1 request/second
            await new Promise((resolve) => setTimeout(resolve, 1100));
          }

          for (const c of rgCandidates) allCandidates.push(c);
          progress.sources.reverseGeocode.status = 'complete';
          progress.sources.reverseGeocode.candidatesFound = rgCandidates.length;
          await updateProgress();
          logger.info(`Reverse geocode source: ${rgCandidates.length} candidates from ${gridPoints.length} grid points`);
        } catch (err) {
          const msg = err instanceof Error ? err.message : 'Unknown error';
          progress.sources.reverseGeocode.status = 'failed';
          progress.sources.reverseGeocode.error = msg;
          await updateProgress();
          logger.error(`Reverse geocode source failed: ${msg}`);
        }
      }

      // 4. Cross-source dedup: coordinate hash, priority ordering (NAR > OSM > ReverseGeocode)
      progress.totalCandidates = allCandidates.length;
      progress.status = 'creating-records';
      await updateProgress();

      // Sort by priority descending so highest-priority source wins on duplicate coords
      allCandidates.sort((a, b) => b.priority - a.priority);

      const dedupMap = new Map<string, CandidateLocation>();
      let skippedDuplicate = 0;

      for (const candidate of allCandidates) {
        const key = coordKey(candidate.latitude, candidate.longitude);

        // Skip if already exists in DB
        if (existingCoords.has(key)) {
          skippedDuplicate++;
          continue;
        }

        // Cross-source dedup: first (highest priority) wins
        if (!dedupMap.has(key)) {
          dedupMap.set(key, candidate);
        } else {
          skippedDuplicate++;
        }
      }

      progress.skippedDuplicate = skippedDuplicate;
      await updateProgress();

      // 5. Batch create Location + Address records
      const uniqueCandidates = Array.from(dedupMap.values());
      logger.info(`Creating ${uniqueCandidates.length} locations (${skippedDuplicate} duplicates skipped)`);

      const batchSize = ('batchSize' in options) ? options.batchSize : 1000;
      let locationsCreated = 0;
      let addressesCreated = 0;

      for (let i = 0; i < uniqueCandidates.length; i += batchSize) {
        const batch = uniqueCandidates.slice(i, i + batchSize);
        const locationBatch: Prisma.LocationCreateManyInput[] = [];
        const addressBatch: Prisma.AddressCreateManyInput[] = [];

        for (const c of batch) {
          const locationId = `loc_${Date.now()}_${Math.random().toString(36).substring(7)}`;

          locationBatch.push({
            id: locationId,
            latitude: c.latitude,
            longitude: c.longitude,
            address: c.address,
            postalCode: c.postalCode,
            province: c.province,
            geocodeConfidence: c.confidence,
            geocodeProvider: c.source === 'osm' ? 'NOMINATIM' : c.source === 'nar' ? 'UNKNOWN' : 'NOMINATIM',
            buildingType: 'SINGLE_FAMILY',
            totalUnits: 1,
            createdByUserId: userId,
          });

          addressBatch.push({
            id: `addr_${Date.now()}_${Math.random().toString(36).substring(7)}`,
            locationId,
            createdByUserId: userId,
          });
        }

        await prisma.location.createMany({ data: locationBatch, skipDuplicates: true });
        await prisma.address.createMany({ data: addressBatch, skipDuplicates: true });

        locationsCreated += locationBatch.length;
        addressesCreated += addressBatch.length;

        progress.locationsCreated = locationsCreated;
        progress.addressesCreated = addressesCreated;
        await updateProgress();
      }

      progress.status = 'complete';
      await updateProgress();
      logger.info(`Area import ${importId} complete: ${locationsCreated} locations, ${addressesCreated} addresses`);
    } catch (err) {
      const msg = err instanceof Error ? err.message : 'Unknown error';
      progress.status = 'failed';
      progress.error = msg;
      await updateProgress();
      logger.error(`Area import ${importId} failed: ${msg}`);
    }
  },
};
