import "leaflet/dist/leaflet.css";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Combobox, InputBase, Loader, Text, useCombobox } from "@mantine/core";
import { MapPin, Search } from "lucide-react";
import L from "leaflet";
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet";

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 Nominatim search result, normalised to what the UI needs. */
interface GeocodeResult {
  displayName: string;
  lat: number;
  lng: number;
}

// Leaflet's default marker icon URLs break under bundlers; point them at the
// CDN-hosted assets once so every map instance renders a visible pin.
const markerIcon = L.icon({
  iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
  iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
  shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
  iconSize: [25, 41],
  iconAnchor: [12, 41],
  popupAnchor: [1, -34],
  shadowSize: [41, 41],
});

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

const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search";
const NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse";
// 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 —
// and it keeps us within Nominatim's 1 req/s fair-use limit.
const SEARCH_DEBOUNCE_MS = 450;
const MIN_QUERY_LEN = 2;
// Bias geocoding toward the EDR corridor countries so local addresses surface
// first (Nominatim still returns global matches if nothing local fits).
const SEARCH_COUNTRYCODES = "et,dj";

/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */
async function nominatimSearch(
  query: string,
  signal: AbortSignal,
  countryCodes?: string,
): Promise<GeocodeResult[]> {
  const params = new URLSearchParams({
    q: query,
    format: "jsonv2",
    addressdetails: "0",
    limit: "8",
  });
  if (countryCodes) params.set("countrycodes", countryCodes);
  const res = await fetch(`${NOMINATIM_URL}?${params}`, {
    signal,
    headers: { Accept: "application/json", "Accept-Language": "en" },
  });
  if (!res.ok) return [];
  const data = (await res.json()) as Array<{
    display_name: string;
    lat: string;
    lon: string;
  }>;
  return data.map((d) => ({
    displayName: d.display_name,
    lat: Number(d.lat),
    lng: Number(d.lon),
  }));
}

/**
 * Forward-geocode a free-text query. We try the EDR corridor (ET/DJ) first so
 * local addresses rank highest, then fall back to a global search when nothing
 * local matches — so the field never looks "broken" for an out-of-region query.
 */
async function searchPlaces(query: string, signal: AbortSignal): Promise<GeocodeResult[]> {
  const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES);
  if (local.length > 0) return local;
  return nominatimSearch(query, signal);
}

/** Reverse-geocode a dropped pin to its nearest address. */
async function reverseGeocode(lat: number, lng: number): Promise<string> {
  const params = new URLSearchParams({
    lat: String(lat),
    lon: String(lng),
    format: "json",
  });
  try {
    const res = await fetch(`${NOMINATIM_REVERSE_URL}?${params}`, {
      headers: { Accept: "application/json" },
    });
    if (!res.ok) return "";
    const data = (await res.json()) as { display_name?: string };
    return data.display_name ?? "";
  } catch {
    return "";
  }
}

/**
 * Leaflet computes its tile layout from the container size at mount. When the
 * map is revealed inside a just-toggled section it can mount before layout
 * settles and render grey tiles — invalidating the size on the next frame
 * forces a correct redraw.
 */
function InvalidateSizeOnMount() {
  const map = useMap();
  useEffect(() => {
    const id = setTimeout(() => map.invalidateSize(), 0);
    return () => clearTimeout(id);
  }, [map]);
  return 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 (lat != null && lng != null) {
      map.setView([lat, lng], PINNED_ZOOM, { animate: true });
    }
  }, [lat, lng, map]);
  return null;
}

/** Captures map clicks and forwards the dropped coordinate. */
function ClickToPin({ onPick }: { onPick: (lat: number, lng: number) => void }) {
  useMapEvents({
    click: (e) => onPick(e.latlng.lat, e.latlng.lng),
  });
  return null;
}

export interface LocationPickerProps {
  value: LocationValue;
  onChange: (value: LocationValue) => void;
  label: string;
  placeholder?: string;
  error?: string;
}

/**
 * Address + map location picker backed by free OpenStreetMap services:
 *   - type to search (Nominatim forward geocoding),
 *   - or click anywhere on the map to drop a pin (Nominatim reverse geocoding).
 * Reports the resolved address and coordinates up via `onChange`.
 */
export function LocationPicker({
  value,
  onChange,
  label,
  placeholder = "Search an address or click the map…",
  error,
}: LocationPickerProps) {
  const combobox = useCombobox();
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<GeocodeResult[]>([]);
  const [searching, setSearching] = useState(false);
  const [resolving, setResolving] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  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. The dropdown is kept open the whole time so the
  // user sees the "Searching…" state and then the live results for what they
  // typed.
  useEffect(() => {
    const q = query.trim();
    if (q.length < MIN_QUERY_LEN) {
      setResults([]);
      setSearching(false);
      return;
    }
    setSearching(true);
    combobox.openDropdown();
    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;
    const handle = setTimeout(async () => {
      try {
        const found = await searchPlaces(q, controller.signal);
        if (controller.signal.aborted) return;
        setResults(found);
        combobox.openDropdown();
      } catch (err) {
        // Ignore aborts (a newer keystroke superseded this request).
        if ((err as Error)?.name !== "AbortError") setResults([]);
      } finally {
        if (!controller.signal.aborted) setSearching(false);
      }
    }, SEARCH_DEBOUNCE_MS);
    // Cancel both the pending debounce AND any in-flight request when the query
    // changes, so a stale response can't overwrite newer results.
    return () => {
      clearTimeout(handle);
      controller.abort();
    };
  }, [query, combobox]);

  const selectResult = useCallback(
    (r: GeocodeResult) => {
      onChange({ address: r.displayName, lat: r.lat, lng: r.lng });
      setQuery("");
      setResults([]);
      combobox.closeDropdown();
    },
    [onChange, combobox],
  );

  const handlePin = useCallback(
    async (lat: number, lng: number) => {
      // Show the pin immediately; fill the address once reverse geocoding lands.
      onChange({ address: value.address, lat, lng });
      setResolving(true);
      const address = await reverseGeocode(lat, lng);
      setResolving(false);
      onChange({
        address: address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
        lat,
        lng,
      });
    },
    [onChange, value.address],
  );

  const inputValue = query || value.address;
  const center = useMemo<[number, number]>(
    () => (hasPin ? [value.lat as number, value.lng as number] : DEFAULT_CENTER),
    [hasPin, value.lat, value.lng],
  );

  return (
    <Box>
      <Combobox store={combobox} withinPortal shadow="md" radius="md">
        <Combobox.Target>
          <InputBase
            label={label}
            placeholder={placeholder}
            value={inputValue}
            error={error}
            radius={10}
            styles={fieldStyles}
            leftSection={<Search size={16} />}
            rightSection={searching || resolving ? <Loader size={14} /> : null}
            onChange={(e) => {
              setQuery(e.currentTarget.value);
              combobox.openDropdown();
            }}
            onFocus={() => {
              if (query.trim().length >= MIN_QUERY_LEN) combobox.openDropdown();
            }}
          />
        </Combobox.Target>

        <Combobox.Dropdown>
          <Combobox.Options mah={240} style={{ overflowY: "auto" }}>
            {searching ? (
              <Combobox.Empty>Searching “{query.trim()}”…</Combobox.Empty>
            ) : results.length === 0 ? (
              <Combobox.Empty>
                {query.trim().length < MIN_QUERY_LEN
                  ? `Type at least ${MIN_QUERY_LEN} characters`
                  : "No matching places"}
              </Combobox.Empty>
            ) : (
              results.map((r, i) => (
                <Combobox.Option
                  key={`${r.lat}-${r.lng}-${i}`}
                  value={String(i)}
                  onClick={() => selectResult(r)}
                >
                  <Text fz={13} lineClamp={2}>
                    {r.displayName}
                  </Text>
                </Combobox.Option>
              ))
            )}
          </Combobox.Options>
        </Combobox.Dropdown>
      </Combobox>

      <Box
        mt={10}
        style={{
          height: 260,
          borderRadius: 12,
          overflow: "hidden",
          border: "1px solid #E6ECF2",
        }}
      >
        <MapContainer
          center={center}
          zoom={hasPin ? PINNED_ZOOM : DEFAULT_ZOOM}
          style={{ height: "100%", width: "100%" }}
          scrollWheelZoom
        >
          <TileLayer
            attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
            url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
          />
          <InvalidateSizeOnMount />
          <ClickToPin onPick={handlePin} />
          <MapRecenter lat={value.lat} lng={value.lng} />
          {hasPin && (
            <Marker
              position={[value.lat as number, value.lng as number]}
              icon={markerIcon}
            />
          )}
        </MapContainer>
      </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>
  );
}
