import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  Autocomplete,
  Box,
  Button,
  Group,
  Loader,
  Modal,
  Text,
} from "@mantine/core";
import { Check, MapPin, Search } from "lucide-react";
import {
  APIProvider,
  Map as GoogleMap,
  type MapMouseEvent,
  Marker,
  useMap,
  useMapsLibrary,
} from "@vis.gl/react-google-maps";

import { fieldStyles } from "./shared";

/** A resolved place: a human address plus its coordinates. */
export interface LocationValue {
  address: string;
  lat: number | null;
  lng: number | null;
}

/** A single geocoding result, normalised to what the UI needs. */
interface GeocodeResult {
  displayName: string;
  lat: number;
  lng: number;
}

/**
 * A single Autocomplete prediction. Coordinates are resolved lazily (only when
 * the user actually picks the row) via a Place Details lookup, so the fast
 * "type → see a list" path costs one Autocomplete call, not N geocodes.
 */
interface PlacePrediction {
  placeId: string;
  displayName: string;
}

// Maps JavaScript API keys are public client-side keys (lock them down by
// HTTP-referrer in the Google Cloud console). The env var lets deployments
// override the default key without a code change.
const GOOGLE_MAPS_API_KEY =
  import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
  "AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";

// Centre of the EDR corridor (Addis Ababa) — a sensible default view.
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 };
const DEFAULT_ZOOM = 6;
const PINNED_ZOOM = 14;

// Search only fires once the user pauses typing for this long. Slightly longer
// than a keystroke burst so we make one request per pause, not per character.
const SEARCH_DEBOUNCE_MS = 550;
const MIN_QUERY_LEN = 2;
// Bias geocoding toward the EDR corridor countries so local addresses surface
// first (we retry globally if nothing local matches).
const SEARCH_COUNTRIES = ["ET", "DJ"];
const MAX_RESULTS = 8;
// Reverse-geocode precision: coordinates are rounded to ~11m before caching so
// near-identical pin drags resolve from cache instead of re-hitting the API.
const REVERSE_COORD_PRECISION = 4;

// Simple in-memory caches keyed by the normalized query / rounded coordinate.
const searchCache = new Map<string, PlacePrediction[]>();
const reverseCache = new Map<string, string>();

/**
 * One Geocoder request, normalised. The promise-based `geocode` rejects on
 * ZERO_RESULTS (and any other non-OK status), so failures collapse to "no
 * matches" rather than surfacing as an error state.
 */
async function geocode(
  geocoder: google.maps.Geocoder,
  request: google.maps.GeocoderRequest,
): Promise<GeocodeResult[]> {
  try {
    const { results } = await geocoder.geocode(request);
    return results.slice(0, MAX_RESULTS).map((r) => ({
      displayName: r.formatted_address,
      lat: r.geometry.location.lat(),
      lng: r.geometry.location.lng(),
    }));
  } catch {
    return [];
  }
}

/**
 * Forward-search a free-text query with the Places Autocomplete service.
 *
 * This is the fix for "search only ever returns the country": the Geocoding API
 * is an address→coords resolver, not a fuzzy place search, so a partial name
 * like "Tulu Dimtu" under a `country: ET` restriction collapses to the Ethiopia
 * centroid (`partial_match: true`). Autocomplete IS the search engine — it
 * matches towns, kebeles, neighbourhoods and landmarks and returns a ranked
 * prediction list. Coordinates are resolved later, only for the picked row.
 *
 * Country bias (not restriction) keeps EDR-corridor places on top while still
 * letting a genuinely foreign query through — nothing ever looks "broken".
 */
async function searchPlaces(
  service: google.maps.places.AutocompleteService,
  sessionToken: google.maps.places.AutocompleteSessionToken | undefined,
  query: string,
): Promise<PlacePrediction[]> {
  const key = query.trim().toLowerCase();
  const cached = searchCache.get(key);
  if (cached) return cached;

  const found = await new Promise<PlacePrediction[]>((resolve) => {
    service.getPlacePredictions(
      {
        input: query,
        // `componentRestrictions` is a hard filter and would re-introduce the
        // "only country matches" failure. Autocomplete has no multi-country
        // restriction anyway, so we bias by region instead and keep it soft.
        componentRestrictions: { country: SEARCH_COUNTRIES },
        sessionToken,
      },
      (predictions, status) => {
        if (
          status !== google.maps.places.PlacesServiceStatus.OK ||
          !predictions
        ) {
          resolve([]);
          return;
        }
        resolve(
          predictions.slice(0, MAX_RESULTS).map((p) => ({
            placeId: p.place_id,
            displayName: p.description,
          })),
        );
      },
    );
  });

  if (found.length > 0) searchCache.set(key, found);
  return found;
}

/**
 * Build the address label for a picked place.
 *
 * For an establishment / POI (e.g. "Bole Medhanialem") Google's
 * `formatted_address` is the *postal* address, which for many Ethiopian places
 * collapses to just the city ("Addis Ababa, Ethiopia") — so taking it verbatim
 * silently replaces the specific place the user picked with a broad city. The
 * place `name` carries the specific label, so we lead with it and only append
 * the formatted address for context when it doesn't already contain the name.
 * Falls back to the prediction's own description (what the user saw and clicked).
 */
function placeDisplayName(
  place: google.maps.places.PlaceResult | null,
  prediction: PlacePrediction,
): string {
  const name = place?.name?.trim();
  const formatted = place?.formatted_address?.trim();
  if (name && formatted) {
    return formatted.toLowerCase().includes(name.toLowerCase())
      ? formatted
      : `${name}, ${formatted}`;
  }
  return name || formatted || prediction.displayName;
}

/**
 * Resolve a picked prediction to its coordinates via Place Details. Runs once
 * per selection (closes the Autocomplete session), so billing stays on the
 * cheap Autocomplete-per-session tier rather than per-keystroke geocoding.
 */
async function resolvePrediction(
  service: google.maps.places.PlacesService,
  sessionToken: google.maps.places.AutocompleteSessionToken | undefined,
  prediction: PlacePrediction,
): Promise<GeocodeResult | null> {
  return new Promise((resolve) => {
    service.getDetails(
      {
        placeId: prediction.placeId,
        fields: ["formatted_address", "name", "geometry"],
        sessionToken,
      },
      (place, status) => {
        const loc = place?.geometry?.location;
        if (
          status !== google.maps.places.PlacesServiceStatus.OK ||
          !loc
        ) {
          resolve(null);
          return;
        }
        resolve({
          displayName: placeDisplayName(place, prediction),
          lat: loc.lat(),
          lng: loc.lng(),
        });
      },
    );
  });
}

/** Reverse-geocode a dropped pin to its nearest address (cached). */
async function reverseGeocode(
  geocoder: google.maps.Geocoder,
  lat: number,
  lng: number,
): Promise<string> {
  const key = `${lat.toFixed(REVERSE_COORD_PRECISION)},${lng.toFixed(
    REVERSE_COORD_PRECISION,
  )}`;
  const cached = reverseCache.get(key);
  if (cached != null) return cached;

  const [best] = await geocode(geocoder, { location: { lat, lng } });
  const address = best?.displayName ?? "";
  reverseCache.set(key, address);
  return address;
}

/** Lazily constructs a Geocoder once the geocoding library has loaded. */
function useGeocoder(): google.maps.Geocoder | null {
  const geocodingLib = useMapsLibrary("geocoding");
  return useMemo(
    () => (geocodingLib ? new geocodingLib.Geocoder() : null),
    [geocodingLib],
  );
}

/** The Places services bundle: predictions + details, once `places` loads. */
interface PlacesSearch {
  autocomplete: google.maps.places.AutocompleteService;
  details: google.maps.places.PlacesService;
}

/**
 * Lazily constructs the Places Autocomplete + Details services once the
 * `places` library loads. `PlacesService` needs a DOM node or map to attach to;
 * a detached div is the standard headless anchor.
 */
function usePlacesSearch(): PlacesSearch | null {
  const placesLib = useMapsLibrary("places");
  return useMemo(() => {
    if (!placesLib) return null;
    return {
      autocomplete: new placesLib.AutocompleteService(),
      details: new placesLib.PlacesService(document.createElement("div")),
    };
  }, [placesLib]);
}

/**
 * Coerce a coordinate to a finite number or null. Numeric-typed API fields
 * (e.g. contract/booking `..Lat`/`..Lng`) come back from Postgres `numeric`
 * columns as strings — the TS type says `number` but the value crossing the
 * network is not. Google Maps' `panTo`/`Marker` throw on anything that isn't
 * a real finite number, so every coordinate is normalised at this one shared
 * entry point rather than trusting each caller to have converted it.
 */
function toFiniteNumber(v: number | string | null | undefined): number | null {
  if (v == null) return null;
  const n = typeof v === "number" ? v : Number(v);
  return Number.isFinite(n) ? n : null;
}

/** Recenters the map imperatively when the pinned coordinate changes. */
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
  const map = useMap();
  useEffect(() => {
    if (map && lat != null && lng != null) {
      map.panTo({ lat, lng });
      map.setZoom(PINNED_ZOOM);
    }
  }, [lat, lng, map]);
  return null;
}

export interface LocationPickerProps {
  value: LocationValue;
  onChange: (value: LocationValue) => void;
  label: string;
  placeholder?: string;
  error?: string;
  /**
   * "inline" (default) renders the search + map directly. "modal" renders a
   * compact read-only trigger that opens the map picker in a centered modal —
   * cleaner for forms with several mile sections stacked together.
   */
  variant?: "inline" | "modal";
}

/**
 * Address + map location picker backed by Google Maps:
 *   - type to search (Places Autocomplete, debounced),
 *   - or click anywhere on the map to drop a pin (reverse geocoding).
 * Reports the resolved address and coordinates up via `onChange`.
 */
export function LocationPicker(props: LocationPickerProps) {
  const value: LocationValue = {
    ...props.value,
    lat: toFiniteNumber(props.value.lat),
    lng: toFiniteNumber(props.value.lng),
  };
  return (
    <APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
      {props.variant === "modal" ? (
        <LocationPickerModal {...props} value={value} />
      ) : (
        <LocationPickerInline {...props} value={value} />
      )}
    </APIProvider>
  );
}

/** Compact trigger + modal wrapper around the inline picker. */
function LocationPickerModal({
  value,
  onChange,
  label,
  placeholder = "Search an address or click the map…",
  error,
}: LocationPickerProps) {
  const [opened, setOpened] = useState(false);
  const hasPin = value.lat != null && value.lng != null;

  return (
    <Box>
      <Text fz={13} fw={600} c="#10202F" mb={6}>
        {label}
      </Text>
      <Box
        onClick={() => setOpened(true)}
        style={{
          display: "flex",
          alignItems: "center",
          gap: 10,
          cursor: "pointer",
          borderRadius: 12,
          minHeight: 46,
          padding: "8px 12px",
          border: `1px solid ${error ? "#E03131" : hasPin ? "#CDEBDD" : "#E6ECF2"}`,
          background: hasPin
            ? "linear-gradient(135deg, #F6FBF8 0%, #FFFFFF 70%)"
            : "#fff",
          transition: "border-color 130ms ease, background 130ms ease",
        }}
      >
        <Box
          style={{
            width: 30,
            height: 30,
            flexShrink: 0,
            borderRadius: 9,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            background: hasPin ? "#ECF6F1" : "#F1F4F7",
            color: hasPin ? "#0A6F4D" : "#64748B",
          }}
        >
          <MapPin size={16} />
        </Box>
        <Text fz={13.5} c={hasPin ? "#10202F" : "#94A3B8"} lineClamp={1} style={{ flex: 1 }}>
          {hasPin ? value.address || "Pinned location" : placeholder}
        </Text>
        <Text fz={12.5} fw={600} c="#0A6F4D" style={{ flexShrink: 0 }}>
          {hasPin ? "Change" : "Pick on map"}
        </Text>
      </Box>
      {error && (
        <Text fz={12} c="red" mt={5}>
          {error}
        </Text>
      )}

      <Modal
        opened={opened}
        onClose={() => setOpened(false)}
        title={label}
        size="lg"
        centered
        radius="lg"
        overlayProps={{ blur: 2, backgroundOpacity: 0.45 }}
        styles={{ title: { fontWeight: 700, color: "#10202F" } }}
      >
        <LocationPickerInline
          value={value}
          onChange={onChange}
          label=""
          placeholder={placeholder}
          mapHeight={320}
          withinPortal={false}
        />
        <Group justify="flex-end" mt="md">
          <Button
            color="edr-green"
            radius="md"
            leftSection={<Check size={16} />}
            disabled={!hasPin}
            onClick={() => setOpened(false)}
          >
            Done
          </Button>
        </Group>
      </Modal>
    </Box>
  );
}

/** The original inline search + map experience. */
function LocationPickerInline({
  value,
  onChange,
  label,
  placeholder = "Search an address or click the map…",
  error,
  mapHeight = 260,
  withinPortal = true,
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
  const geocoder = useGeocoder();
  const places = usePlacesSearch();
  const placesLib = useMapsLibrary("places");
  // `null` means "not editing" (show the saved address); any string — including
  // "" after the user clears the field — is live edit state. A plain `query ||
  // value.address` fallback would snap the saved address back the moment the
  // user cleared the input, making it impossible to retype the location.
  const [query, setQuery] = useState<string | null>(null);
  const [results, setResults] = useState<PlacePrediction[]>([]);
  const [searching, setSearching] = useState(false);
  const [resolving, setResolving] = useState(false);
  const reverseStaleRef = useRef<{ stale: boolean } | null>(null);
  // One Autocomplete session groups every keystroke of a search with the final
  // Details fetch into a single billable unit. Reset after each pick.
  const sessionTokenRef = useRef<
    google.maps.places.AutocompleteSessionToken | undefined
  >(undefined);
  if (placesLib && !sessionTokenRef.current) {
    sessionTokenRef.current = new placesLib.AutocompleteSessionToken();
  }

  const hasPin = value.lat != null && value.lng != null;

  // Debounced forward search — fires only after the user stops typing
  // (SEARCH_DEBOUNCE_MS of silence), so we make one request per pause rather
  // than one per keystroke. While it runs, the input shows a spinner; the
  // dropdown itself only appears once there are predictions to show.
  useEffect(() => {
    const q = (query ?? "").trim();
    if (q.length < MIN_QUERY_LEN) {
      setResults([]);
      setSearching(false);
      return;
    }
    setSearching(true);
    if (!places) return; // re-runs once the places library loads
    // Autocomplete has no abort support, so a token marks superseded requests
    // and their responses are dropped instead of overwriting newer results.
    const token = { stale: false };
    const handle = setTimeout(async () => {
      const found = await searchPlaces(
        places.autocomplete,
        sessionTokenRef.current,
        q,
      );
      if (token.stale) return;
      setResults(found);
      setSearching(false);
    }, SEARCH_DEBOUNCE_MS);
    return () => {
      clearTimeout(handle);
      token.stale = true;
    };
  }, [query, places]);

  // Drop any in-flight reverse lookup when the picker unmounts.
  useEffect(
    () => () => {
      if (reverseStaleRef.current) reverseStaleRef.current.stale = true;
    },
    [],
  );

  const selectResult = useCallback(
    async (prediction: PlacePrediction) => {
      // Clear the query/results immediately so the pending debounce can't fire
      // a search for the picked address and pop the dropdown back open.
      setQuery(null);
      setResults([]);
      // Predictions carry no coordinates — resolve them now via Place Details.
      if (!places) return;
      setResolving(true);
      const resolved = await resolvePrediction(
        places.details,
        sessionTokenRef.current,
        prediction,
      );
      // A Details fetch closes the Autocomplete billing session; start a fresh
      // token so the next search is its own session.
      sessionTokenRef.current = placesLib
        ? new placesLib.AutocompleteSessionToken()
        : undefined;
      setResolving(false);
      if (!resolved) return;
      onChange({
        address: resolved.displayName,
        lat: resolved.lat,
        lng: resolved.lng,
      });
    },
    [onChange, places, placesLib],
  );

  const handlePin = useCallback(
    async (lat: number, lng: number) => {
      // Show the pin immediately; fill the address once reverse geocoding lands.
      // Leave edit mode so the input reflects the reverse-geocoded address
      // instead of whatever half-typed query the user abandoned for the map.
      setQuery(null);
      onChange({ address: value.address, lat, lng });
      if (!geocoder) return;
      // Mark any in-flight reverse lookup stale — only the latest pin counts.
      if (reverseStaleRef.current) reverseStaleRef.current.stale = true;
      const token = { stale: false };
      reverseStaleRef.current = token;
      setResolving(true);
      const address = await reverseGeocode(geocoder, lat, lng);
      if (token.stale) return; // a newer pin superseded this one
      setResolving(false);
      onChange({
        address: address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
        lat,
        lng,
      });
    },
    [onChange, value.address, geocoder],
  );

  const handleMapClick = useCallback(
    (e: MapMouseEvent) => {
      const latLng = e.detail.latLng;
      if (latLng) void handlePin(latLng.lat, latLng.lng);
    },
    [handlePin],
  );

  const inputValue = query ?? value.address;
  const center = hasPin
    ? { lat: value.lat as number, lng: value.lng as number }
    : DEFAULT_CENTER;

  // Mantine Autocomplete requires unique option values; predictions are keyed
  // by their display text, so de-duplicate the rare identical descriptions.
  const optionsByName = useMemo(() => {
    const byName = new Map<string, PlacePrediction>();
    for (const r of results) {
      if (!byName.has(r.displayName)) byName.set(r.displayName, r);
    }
    return byName;
  }, [results]);

  return (
    <Box>
      <Autocomplete
        label={label || undefined}
        placeholder={placeholder}
        value={inputValue}
        error={error}
        radius={10}
        styles={fieldStyles}
        leftSection={<Search size={16} />}
        rightSection={searching || resolving ? <Loader size={14} /> : null}
        data={[...optionsByName.keys()]}
        // Predictions are already ranked by the Places API for the typed
        // query; Mantine's default substring filter would hide most of them.
        filter={({ options }) => options}
        maxDropdownHeight={240}
        comboboxProps={{ withinPortal, shadow: "md", radius: "md" }}
        onChange={setQuery}
        onOptionSubmit={(name) => {
          const prediction = optionsByName.get(name);
          if (prediction) void selectResult(prediction);
        }}
        renderOption={({ option }) => (
          <Text fz={13} lineClamp={2}>
            {option.value}
          </Text>
        )}
      />

      <Box
        mt={10}
        style={{
          height: mapHeight,
          borderRadius: 12,
          overflow: "hidden",
          border: "1px solid #E6ECF2",
        }}
      >
        <GoogleMap
          defaultCenter={center}
          defaultZoom={hasPin ? PINNED_ZOOM : DEFAULT_ZOOM}
          style={{ height: "100%", width: "100%" }}
          gestureHandling="greedy"
          clickableIcons={false}
          streetViewControl={false}
          mapTypeControl={false}
          onClick={handleMapClick}
        >
          <MapRecenter lat={value.lat} lng={value.lng} />
          {hasPin && (
            <Marker
              position={{ lat: value.lat as number, lng: value.lng as number }}
            />
          )}
        </GoogleMap>
      </Box>

      <Text fz={11.5} c="#6B7C8E" mt={6} style={{ display: "flex", gap: 5 }}>
        <MapPin size={13} style={{ flexShrink: 0, marginTop: 1 }} />
        {hasPin
          ? value.address || "Pinned location"
          : "Search above or click the map to drop a pin."}
      </Text>
    </Box>
  );
}
