/**
 * Geohash encoding/decoding utilities for zkGPS
 *
 * Geohash is a hierarchical spatial encoding that converts lat/lng to a string.
 * Each character adds precision, enabling variable-granularity location sharing.
 *
 * Precision table:
 *   1 char  = ~5000 km (continent)
 *   4 chars = ~39 km (metro)
 *   6 chars = ~1.2 km (neighborhood)
 *   8 chars = ~38 m (building)
 *   10 chars = ~1.2 m (exact)
 */

// Base32 alphabet used by geohash (excludes a, i, l, o to avoid confusion)
const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz';
const BASE32_MAP = new Map(BASE32.split('').map((c, i) => [c, i]));

/**
 * Geohash precision levels with approximate cell sizes
 */
export const GEOHASH_PRECISION = {
  CONTINENT: 1,      // ~5000 km
  LARGE_COUNTRY: 2,  // ~1250 km
  STATE: 3,          // ~156 km
  METRO: 4,          // ~39 km
  DISTRICT: 5,       // ~5 km
  NEIGHBORHOOD: 6,   // ~1.2 km
  BLOCK: 7,          // ~153 m
  BUILDING: 8,       // ~38 m
  ROOM: 9,           // ~5 m
  EXACT: 10,         // ~1.2 m
} as const;

export type GeohashPrecision = typeof GEOHASH_PRECISION[keyof typeof GEOHASH_PRECISION];

/**
 * Approximate cell dimensions at each precision level (meters)
 */
export const PRECISION_CELL_SIZE: Record<number, { lat: number; lng: number }> = {
  1: { lat: 5000000, lng: 5000000 },
  2: { lat: 1250000, lng: 625000 },
  3: { lat: 156000, lng: 156000 },
  4: { lat: 39000, lng: 19500 },
  5: { lat: 4900, lng: 4900 },
  6: { lat: 1200, lng: 610 },
  7: { lat: 153, lng: 153 },
  8: { lat: 38, lng: 19 },
  9: { lat: 4.8, lng: 4.8 },
  10: { lat: 1.2, lng: 0.6 },
  11: { lat: 0.15, lng: 0.15 },
  12: { lat: 0.037, lng: 0.019 },
};

/**
 * Bounding box for a geohash cell
 */
export interface GeohashBounds {
  minLat: number;
  maxLat: number;
  minLng: number;
  maxLng: number;
}

/**
 * Encode latitude/longitude to geohash string
 *
 * @param lat Latitude (-90 to 90)
 * @param lng Longitude (-180 to 180)
 * @param precision Number of characters (1-12)
 * @returns Geohash string
 */
export function encode(lat: number, lng: number, precision: number = 9): string {
  if (precision < 1 || precision > 12) {
    throw new Error('Precision must be between 1 and 12');
  }
  if (lat < -90 || lat > 90) {
    throw new Error('Latitude must be between -90 and 90');
  }
  if (lng < -180 || lng > 180) {
    throw new Error('Longitude must be between -180 and 180');
  }

  let minLat = -90, maxLat = 90;
  let minLng = -180, maxLng = 180;
  let hash = '';
  let bit = 0;
  let ch = 0;
  let isLng = true; // Alternate between lng and lat

  while (hash.length < precision) {
    if (isLng) {
      const mid = (minLng + maxLng) / 2;
      if (lng >= mid) {
        ch |= 1 << (4 - bit);
        minLng = mid;
      } else {
        maxLng = mid;
      }
    } else {
      const mid = (minLat + maxLat) / 2;
      if (lat >= mid) {
        ch |= 1 << (4 - bit);
        minLat = mid;
      } else {
        maxLat = mid;
      }
    }

    isLng = !isLng;
    bit++;

    if (bit === 5) {
      hash += BASE32[ch];
      bit = 0;
      ch = 0;
    }
  }

  return hash;
}

/**
 * Decode geohash string to latitude/longitude (center of cell)
 *
 * @param hash Geohash string
 * @returns { lat, lng } center point
 */
export function decode(hash: string): { lat: number; lng: number } {
  const bounds = decodeBounds(hash);
  return {
    lat: (bounds.minLat + bounds.maxLat) / 2,
    lng: (bounds.minLng + bounds.maxLng) / 2,
  };
}

/**
 * Decode geohash string to bounding box
 *
 * @param hash Geohash string
 * @returns Bounding box of the cell
 */
export function decodeBounds(hash: string): GeohashBounds {
  let minLat = -90, maxLat = 90;
  let minLng = -180, maxLng = 180;
  let isLng = true;

  for (const c of hash.toLowerCase()) {
    const bits = BASE32_MAP.get(c);
    if (bits === undefined) {
      throw new Error(`Invalid geohash character: ${c}`);
    }

    for (let i = 4; i >= 0; i--) {
      const bit = (bits >> i) & 1;
      if (isLng) {
        const mid = (minLng + maxLng) / 2;
        if (bit) {
          minLng = mid;
        } else {
          maxLng = mid;
        }
      } else {
        const mid = (minLat + maxLat) / 2;
        if (bit) {
          minLat = mid;
        } else {
          maxLat = mid;
        }
      }
      isLng = !isLng;
    }
  }

  return { minLat, maxLat, minLng, maxLng };
}

/**
 * Get all 8 neighboring geohash cells
 *
 * @param hash Geohash string
 * @returns Array of 8 neighboring geohash strings
 */
export function neighbors(hash: string): string[] {
  const { lat, lng } = decode(hash);
  const bounds = decodeBounds(hash);
  const latDelta = bounds.maxLat - bounds.minLat;
  const lngDelta = bounds.maxLng - bounds.minLng;
  const precision = hash.length;

  const directions = [
    { dLat: latDelta, dLng: 0 },         // N
    { dLat: latDelta, dLng: lngDelta },  // NE
    { dLat: 0, dLng: lngDelta },         // E
    { dLat: -latDelta, dLng: lngDelta }, // SE
    { dLat: -latDelta, dLng: 0 },        // S
    { dLat: -latDelta, dLng: -lngDelta }, // SW
    { dLat: 0, dLng: -lngDelta },        // W
    { dLat: latDelta, dLng: -lngDelta }, // NW
  ];

  return directions.map(({ dLat, dLng }) => {
    let newLat = lat + dLat;
    let newLng = lng + dLng;

    // Wrap longitude
    if (newLng > 180) newLng -= 360;
    if (newLng < -180) newLng += 360;

    // Clamp latitude (can't wrap)
    newLat = Math.max(-90, Math.min(90, newLat));

    return encode(newLat, newLng, precision);
  });
}

/**
 * Check if a point is inside a geohash cell
 *
 * @param lat Latitude
 * @param lng Longitude
 * @param hash Geohash string
 * @returns true if point is inside the cell
 */
export function contains(lat: number, lng: number, hash: string): boolean {
  const bounds = decodeBounds(hash);
  return (
    lat >= bounds.minLat &&
    lat <= bounds.maxLat &&
    lng >= bounds.minLng &&
    lng <= bounds.maxLng
  );
}

/**
 * Get all geohash cells that intersect a circle
 *
 * @param centerLat Center latitude
 * @param centerLng Center longitude
 * @param radiusMeters Radius in meters
 * @param precision Geohash precision
 * @returns Array of geohash strings that intersect the circle
 */
export function cellsInRadius(
  centerLat: number,
  centerLng: number,
  radiusMeters: number,
  precision: number
): string[] {
  const cells = new Set<string>();
  const centerHash = encode(centerLat, centerLng, precision);
  cells.add(centerHash);

  // BFS to find all intersecting cells
  const queue = [centerHash];
  const visited = new Set<string>([centerHash]);

  while (queue.length > 0) {
    const current = queue.shift()!;
    const neighborList = neighbors(current);

    for (const neighbor of neighborList) {
      if (visited.has(neighbor)) continue;
      visited.add(neighbor);

      // Check if this cell intersects the circle
      if (cellIntersectsCircle(neighbor, centerLat, centerLng, radiusMeters)) {
        cells.add(neighbor);
        queue.push(neighbor);
      }
    }
  }

  return Array.from(cells);
}

/**
 * Check if a geohash cell intersects a circle
 */
function cellIntersectsCircle(
  hash: string,
  centerLat: number,
  centerLng: number,
  radiusMeters: number
): boolean {
  const bounds = decodeBounds(hash);

  // Find closest point on cell to circle center
  const closestLat = Math.max(bounds.minLat, Math.min(bounds.maxLat, centerLat));
  const closestLng = Math.max(bounds.minLng, Math.min(bounds.maxLng, centerLng));

  // Calculate distance to closest point
  const distance = haversineDistance(
    centerLat,
    centerLng,
    closestLat,
    closestLng
  );

  return distance <= radiusMeters;
}

/**
 * Haversine distance between two points (meters)
 */
function haversineDistance(
  lat1: number,
  lng1: number,
  lat2: number,
  lng2: number
): number {
  const R = 6371000; // Earth radius in meters
  const dLat = toRad(lat2 - lat1);
  const dLng = toRad(lng2 - lng1);
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

function toRad(deg: number): number {
  return (deg * Math.PI) / 180;
}

/**
 * Get geohash cells that cover a polygon (approximation)
 *
 * @param polygon Array of [lat, lng] points forming a closed polygon
 * @param precision Geohash precision
 * @returns Array of geohash strings that intersect the polygon
 */
export function cellsInPolygon(
  polygon: [number, number][],
  precision: number
): string[] {
  // Find bounding box of polygon
  let minLat = Infinity, maxLat = -Infinity;
  let minLng = Infinity, maxLng = -Infinity;

  for (const [lat, lng] of polygon) {
    minLat = Math.min(minLat, lat);
    maxLat = Math.max(maxLat, lat);
    minLng = Math.min(minLng, lng);
    maxLng = Math.max(maxLng, lng);
  }

  // Get cell size at this precision
  const cellSize = PRECISION_CELL_SIZE[precision] || PRECISION_CELL_SIZE[12];
  const latStep = cellSize.lat / 111000; // meters to degrees (rough)
  const lngStep = cellSize.lng / (111000 * Math.cos(toRad((minLat + maxLat) / 2)));

  const cells = new Set<string>();

  // Sample points in bounding box
  for (let lat = minLat; lat <= maxLat; lat += latStep * 0.5) {
    for (let lng = minLng; lng <= maxLng; lng += lngStep * 0.5) {
      if (pointInPolygon(lat, lng, polygon)) {
        cells.add(encode(lat, lng, precision));
      }
    }
  }

  return Array.from(cells);
}

/**
 * Ray casting algorithm for point-in-polygon test
 */
function pointInPolygon(lat: number, lng: number, polygon: [number, number][]): boolean {
  let inside = false;
  const n = polygon.length;

  for (let i = 0, j = n - 1; i < n; j = i++) {
    const [yi, xi] = polygon[i];
    const [yj, xj] = polygon[j];

    if (
      yi > lat !== yj > lat &&
      lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
    ) {
      inside = !inside;
    }
  }

  return inside;
}

/**
 * Truncate geohash to lower precision (reveal less location info)
 *
 * @param hash Full geohash
 * @param precision Target precision (must be <= current length)
 * @returns Truncated geohash
 */
export function truncate(hash: string, precision: number): string {
  if (precision >= hash.length) return hash;
  if (precision < 1) return '';
  return hash.slice(0, precision);
}

/**
 * Check if two geohashes share a common prefix (are in same area)
 *
 * @param hash1 First geohash
 * @param hash2 Second geohash
 * @param minLength Minimum prefix length to match
 * @returns true if they share a prefix of at least minLength
 */
export function sharesPrefix(hash1: string, hash2: string, minLength: number): boolean {
  const prefix1 = truncate(hash1, minLength);
  const prefix2 = truncate(hash2, minLength);
  return prefix1 === prefix2;
}

/**
 * Estimate appropriate precision for a given radius
 *
 * @param radiusMeters Desired radius in meters
 * @returns Recommended geohash precision
 */
export function precisionForRadius(radiusMeters: number): number {
  for (let p = 12; p >= 1; p--) {
    const cellSize = PRECISION_CELL_SIZE[p];
    if (cellSize && Math.max(cellSize.lat, cellSize.lng) <= radiusMeters * 2) {
      return p;
    }
  }
  return 1;
}

// =============================================================================
// Convenience Aliases
// =============================================================================

/**
 * Alias for encode() - encode latitude/longitude to geohash
 */
export const encodeGeohash = encode;

/**
 * Alias for decode() - decode geohash to latitude/longitude
 */
export const decodeGeohash = decode;

/**
 * Alias for decodeBounds() - get bounding box for a geohash
 */
export const getGeohashBounds = decodeBounds;
