import type { GeoJSONSource } from 'maplibre-gl';
import type { ClusterSelection } from './MapClusterPanel';
import type { MapFilter } from './usePhotosData';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { LngLatBounds, Map as MapLibreMap, Marker, NavigationControl } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { Button } from '@/components/ui/button';
import { useColorMode } from '@/components/ui/ThemeProvider';
import { useClient } from 'hooks/useClient';
import { AssetViewer } from './AssetViewer';
import { MapClusterPanel } from './MapClusterPanel';
import { ASSET_PARAM, thumbUrl } from './shared';
import { useMapMarkers, useServerConfig } from './usePhotosData';

// The map, as close to Immich's as the pieces allow.
//
// Four things are worth knowing before changing this:
//
// 1. The BASEMAP is the only thing on /photos the browser fetches from outside Officer. Its URL comes from
//    Immich's own `/server/config` (proxied), but the vector tiles themselves come straight from
//    tiles.immich.cloud — exactly as Immich's web app does it. Proxying a basemap would mean relaying
//    thousands of tile requests that contain none of the owner's data, through a sidecar whose job is the
//    library. The tile server learns which parts of the world are being looked at and nothing else.
// 2. CLUSTERING is maplibre's, not ours. A geotagged library is tens of thousands of pins, and the GeoJSON
//    source does the bucketing on a worker thread. That is also why markers are fetched in one unbucketed
//    request: a marker is six numbers, and clustering needs all of them at once to be correct.
// 3. The photo THUMBNAILS are DOM markers layered over an invisible-by-occlusion circle layer, capped at
//    THUMB_LIMIT and rebuilt on `idle`. Rendering an image per asset into the GL context would mean
//    uploading thousands of textures; the browser only ever holds the ones actually on screen.
// 4. Clicking a cluster OPENS IT rather than zooming — see MapClusterPanel. Zooming is still available from
//    the panel, but it is not the answer to "what is in there": photos taken in one place stay one circle at
//    every zoom, so expansion alone can leave a count you can never break down.
//
// maplibre-gl is PINNED TO 5.x on purpose. In 6.0 the tile worker became a separate `maplibre-gl-worker.mjs`
// resolved at runtime from `import.meta.url`. Officer bundles the SPA and serves an index.html fallback for
// unknown paths, so that request comes back as HTML and the browser refuses it — "non-JavaScript MIME type of
// text/html", and no map at all. 5.x inlines the worker as a blob, which needs no serving infrastructure.
// Upgrading means either copying maplibre's worker + shared chunks into public/ at build time or calling
// `setWorkerUrl()`; do not bump the major without doing one of those.

const SOURCE = 'photos-assets';
const HALO_LAYER = 'photos-cluster-halo';
const CLUSTER_LAYER = 'photos-clusters';
const COUNT_LAYER = 'photos-cluster-count';
const POINT_LAYER = 'photos-points';

/** How many photo thumbnails to draw at once. Past this it is a wall of images, not a map. */
const THUMB_LIMIT = 120;

const ACCENT = '#10b981';

type PointFeature = {
  type: 'Feature';
  geometry: { type: 'Point'; coordinates: [number, number] };
  properties: { id: string };
};
type PointCollection = { type: 'FeatureCollection'; features: PointFeature[] };

type BoolFilterKey = 'isFavorite' | 'isArchived' | 'withPartners' | 'withSharedAlbums';
const TOGGLES: { key: BoolFilterKey; label: string; title: string }[] = [
  { key: 'isFavorite', label: 'Favorites', title: 'Only favorited photos' },
  { key: 'isArchived', label: 'Archived', title: 'Include archived photos' },
  { key: 'withPartners', label: 'Partners', title: "Include partners' photos" },
  { key: 'withSharedAlbums', label: 'Shared', title: 'Include photos from shared albums' },
];

/**
 * A marker is a plain DOM button rather than JSX, because maplibre owns the element's position and React must
 * not re-parent it. Styles are inline for the same reason Tailwind is avoided here: the element is created
 * outside the render tree, so keeping its appearance next to its construction is the readable choice.
 */
const makeThumbElement = (id: string, token: string | null, onOpen: (id: string) => void) => {
  const el = document.createElement('button');
  el.type = 'button';
  el.title = 'Open photo';
  el.style.cssText =
    'width:46px;height:46px;padding:0;border-radius:9999px;border:2px solid rgba(255,255,255,.85);' +
    'box-shadow:0 2px 8px rgba(0,0,0,.45);background-color:rgba(0,0,0,.35);background-size:cover;' +
    'background-position:center;cursor:pointer;display:block';
  el.style.backgroundImage = `url("${thumbUrl(id, token)}")`;
  el.addEventListener('click', (ev) => {
    ev.stopPropagation();
    onOpen(id);
  });
  return el;
};

export const MapSection = () => {
  const { token } = useClient();
  const { colorMode } = useColorMode();
  const [, setParams] = useSearchParams();

  const [filter, setFilter] = useState<MapFilter>({ withPartners: true, withSharedAlbums: true });
  const { data: config } = useServerConfig();
  const { data: markers, isLoading, error: markersError } = useMapMarkers(filter);

  const styleUrl = colorMode === 'dark' ? config?.mapDarkStyleUrl : config?.mapLightStyleUrl;

  const containerRef = useRef<HTMLDivElement | null>(null);
  const mapRef = useRef<MapLibreMap | null>(null);
  const thumbsRef = useRef<Marker[]>([]);
  const shownRef = useRef('');
  const loadedRef = useRef(false);
  const fittedRef = useRef(false);

  const [ready, setReady] = useState(false);
  const [mapError, setMapError] = useState<string | null>(null);
  const [visibleIds, setVisibleIds] = useState<string[]>([]);
  const [cluster, setCluster] = useState<ClusterSelection | null>(null);

  const open = useCallback(
    (id: string) => {
      setParams((current) => {
        const next = new URLSearchParams(current);
        next.set(ASSET_PARAM, id);
        return next;
      });
    },
    [setParams],
  );

  const data = useMemo<PointCollection>(
    () => ({
      type: 'FeatureCollection',
      features: (markers ?? []).map((marker) => ({
        type: 'Feature',
        geometry: { type: 'Point', coordinates: [marker.lon, marker.lat] },
        properties: { id: marker.id },
      })),
    }),
    [markers],
  );

  // Build the map. Keyed on the style URL, so flipping Officer's light/dark mode rebuilds it against Immich's
  // matching basemap — cheaper to reason about than swapping the style and re-adding every source by hand.
  useEffect(() => {
    const container = containerRef.current;
    if (!container || !styleUrl) return;

    loadedRef.current = false;
    fittedRef.current = false;
    setMapError(null);

    const map = new MapLibreMap({
      container,
      style: styleUrl,
      center: [0, 25],
      zoom: 1.2,
      attributionControl: { compact: true },
    });
    map.addControl(new NavigationControl({ showCompass: false }), 'top-right');

    // Immich's basemap style asks for icons its own sprite sheet doesn't ship — `places_locality` selects
    // `capital` for capital cities, and none of the four sprite variants contain it (`townspot` is there).
    // That is upstream's bug, visible in Immich's own web map too, and it costs nothing but a console warning
    // per tile batch. Handing maplibre a blank keeps the log readable; the label still draws, just iconless.
    map.on('styleimagemissing', (ev) => {
      if (!map.hasImage(ev.id)) map.addImage(ev.id, { width: 1, height: 1, data: new Uint8Array(4) });
    });

    map.on('load', () => {
      loadedRef.current = true;
      setReady(true);
    });
    // Registering ANY `error` listener turns OFF maplibre's own console logging, so log it back out. Only
    // pre-load failures are banner-worthy — a single tile 404 after that would make a working map look
    // broken — but swallowing the rest leaves a blank map above an empty console, which cannot be debugged.
    map.on('error', (ev) => {
      console.error('[photos/map]', ev.error ?? ev);
      if (!loadedRef.current) setMapError(ev.error?.message ?? 'The basemap could not be loaded.');
    });

    mapRef.current = map;

    return () => {
      thumbsRef.current.forEach((marker) => marker.remove());
      thumbsRef.current = [];
      shownRef.current = '';
      map.remove();
      mapRef.current = null;
      setReady(false);
      setCluster(null);
    };
  }, [styleUrl]);

  // Feed the markers in, adding the layers the first time and only swapping data after that.
  useEffect(() => {
    const map = mapRef.current;
    if (!map || !ready) return;

    const existing = map.getSource(SOURCE) as GeoJSONSource | undefined;
    if (existing) {
      existing.setData(data);
    } else {
      map.addSource(SOURCE, { type: 'geojson', data, cluster: true, clusterRadius: 60, clusterMaxZoom: 15 });

      // Drawn under the clusters so the selected one gets a ring around it. Its filter is set from state; an
      // impossible cluster id is the "nothing selected" case, since maplibre has no way to hide a layer's
      // features other than a filter that matches none of them.
      map.addLayer({
        id: HALO_LAYER,
        type: 'circle',
        source: SOURCE,
        filter: ['==', ['get', 'cluster_id'], -1],
        paint: {
          'circle-color': 'rgba(0,0,0,0)',
          'circle-radius': ['step', ['get', 'point_count'], 23, 10, 27, 100, 33, 1000, 41],
          'circle-stroke-width': 3,
          'circle-stroke-color': ACCENT,
        },
      });

      map.addLayer({
        id: CLUSTER_LAYER,
        type: 'circle',
        source: SOURCE,
        filter: ['has', 'point_count'],
        paint: {
          'circle-color': ACCENT,
          'circle-opacity': 0.85,
          'circle-radius': ['step', ['get', 'point_count'], 16, 10, 20, 100, 26, 1000, 34],
          'circle-stroke-width': 2,
          'circle-stroke-color': 'rgba(255,255,255,.75)',
        },
      });
      map.addLayer({
        id: COUNT_LAYER,
        type: 'symbol',
        source: SOURCE,
        filter: ['has', 'point_count'],
        // Immich's own style ships this fontstack; anything else would 404 and silently drop the numbers.
        layout: {
          'text-field': ['get', 'point_count_abbreviated'],
          'text-font': ['Noto Sans Medium'],
          'text-size': 12,
        },
        paint: { 'text-color': '#05231b' },
      });
      map.addLayer({
        id: POINT_LAYER,
        type: 'circle',
        source: SOURCE,
        filter: ['!', ['has', 'point_count']],
        paint: {
          'circle-color': ACCENT,
          'circle-radius': 6,
          'circle-stroke-width': 2,
          'circle-stroke-color': 'rgba(255,255,255,.8)',
        },
      });

      // Open the cluster rather than zoom into it. Both the leaves and the expansion zoom are read HERE, while
      // the cluster id is still live — it is only meaningful at the zoom it was queried at, so resolving either
      // one later (when the panel's zoom button is pressed, say) would be a race against the camera.
      map.on('click', CLUSTER_LAYER, (ev) => {
        const feature = ev.features?.[0];
        const clusterId = feature?.properties?.cluster_id;
        if (feature?.geometry.type !== 'Point' || typeof clusterId !== 'number') return;

        const source = map.getSource(SOURCE) as GeoJSONSource | undefined;
        if (!source) return;

        const [lon, lat] = feature.geometry.coordinates as [number, number];
        const count = Number(feature.properties?.point_count ?? 0);

        void Promise.all([
          source.getClusterLeaves(clusterId, count, 0),
          source.getClusterExpansionZoom(clusterId),
        ]).then(([leaves, expansionZoom]) => {
          const ids = leaves.map((leaf) => leaf.properties?.id).filter((id): id is string => typeof id === 'string');
          setCluster({ clusterId, ids, center: [lon, lat], expansionZoom });
        });
      });
      map.on('click', POINT_LAYER, (ev) => {
        const id = ev.features?.[0]?.properties?.id;
        if (typeof id === 'string') open(id);
      });
      for (const layer of [CLUSTER_LAYER, POINT_LAYER]) {
        map.on('mouseenter', layer, () => (map.getCanvas().style.cursor = 'pointer'));
        map.on('mouseleave', layer, () => (map.getCanvas().style.cursor = ''));
      }
    }

    // Frame the library once. Re-fitting on every filter change would yank the view out from under someone
    // who just zoomed into a city and then unticked "archived".
    if (!fittedRef.current && data.features.length > 0) {
      const bounds = new LngLatBounds();
      for (const feature of data.features) bounds.extend(feature.geometry.coordinates);
      map.fitBounds(bounds, { padding: 64, maxZoom: 12, animate: false });
      fittedRef.current = true;
    }
  }, [ready, data, open]);

  // Ring the open cluster. The id goes stale as soon as the camera moves — the ring then simply matches nothing
  // and disappears, which is the honest outcome: the panel's list is a snapshot, and that circle is gone.
  useEffect(() => {
    const map = mapRef.current;
    if (!map || !ready || !map.getLayer(HALO_LAYER)) return;
    map.setFilter(HALO_LAYER, ['==', ['get', 'cluster_id'], cluster?.clusterId ?? -1]);
  }, [ready, cluster]);

  // Swap the thumbnail markers whenever the map settles. `idle` rather than `move`, so a drag costs one rebuild
  // at the end instead of one per frame.
  useEffect(() => {
    const map = mapRef.current;
    if (!map || !ready) return;

    const refresh = () => {
      if (!map.getLayer(POINT_LAYER)) return;

      const seen = new Set<string>();
      const picked: { id: string; at: [number, number] }[] = [];
      for (const feature of map.queryRenderedFeatures({ layers: [POINT_LAYER] })) {
        const id = feature.properties?.id;
        if (typeof id !== 'string' || seen.has(id) || feature.geometry.type !== 'Point') continue;
        const [lon, lat] = feature.geometry.coordinates;
        if (typeof lon !== 'number' || typeof lat !== 'number') continue;
        seen.add(id);
        picked.push({ id, at: [lon, lat] });
        if (picked.length >= THUMB_LIMIT) break;
      }

      // Rebuilding identical markers would restart every image fade for no reason.
      const signature = picked.map((item) => item.id).join(',');
      if (signature === shownRef.current) return;
      shownRef.current = signature;

      thumbsRef.current.forEach((marker) => marker.remove());
      thumbsRef.current = picked.map(({ id, at }) =>
        new Marker({ element: makeThumbElement(id, token, open) }).setLngLat(at).addTo(map),
      );
      setVisibleIds(picked.map((item) => item.id));
    };

    map.on('idle', refresh);
    return () => {
      map.off('idle', refresh);
    };
  }, [ready, token, open]);

  const toggle = (key: BoolFilterKey) => {
    // The open cluster is a snapshot of ids from the previous marker set; a filter change can only make it a
    // list of photos that are no longer on the map.
    setCluster(null);
    setFilter((current) => ({ ...current, [key]: current[key] ? undefined : true }));
  };

  const count = markers?.length ?? 0;

  // Name the open cluster from the markers' own reverse-geocoding. Computed here rather than at click time
  // because the click handler is registered once and would close over the first marker set forever.
  const place = useMemo(() => {
    if (!cluster || !markers) return null;

    const byId = new Map(markers.map((marker) => [marker.id, marker]));
    const counts = new Map<string, number>();
    for (const id of cluster.ids) {
      const marker = byId.get(id);
      const name = [marker?.city, marker?.country].filter(Boolean).join(', ');
      if (name) counts.set(name, (counts.get(name) ?? 0) + 1);
    }

    const ranked = [...counts].sort(([, a], [, b]) => b - a);
    const top = ranked[0]?.[0];
    if (!top) return null;
    return ranked.length > 1 ? `${top} +${ranked.length - 1} more` : top;
  }, [cluster, markers]);

  return (
    <div className="flex h-full flex-col">
      <header className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
        <h1 className="text-sm font-semibold">Map</h1>
        {/* A failed marker fetch used to look identical to an empty library: the map simply stayed on its
            opening view. Say so instead — the basemap can be perfectly fine while the pins are missing. */}
        <span className={`text-xs ${markersError ? 'text-destructive' : 'text-muted-foreground'}`}>
          {markersError
            ? `Markers failed: ${markersError instanceof Error ? markersError.message : 'request failed'}`
            : isLoading
              ? 'Loading…'
              : `${count.toLocaleString()} geotagged`}
        </span>
        <div className="flex-1" />
        {TOGGLES.map(({ key, label, title }) => (
          <Button
            key={key}
            size="sm"
            variant={filter[key] ? 'default' : 'ghost'}
            title={title}
            onClick={() => toggle(key)}
          >
            {label}
          </Button>
        ))}
      </header>

      <div className="relative min-h-0 flex-1">
        {/*
          Sized with h-full/w-full, NOT `absolute inset-0`, and that is not a style preference.

          maplibre puts `position: relative; overflow: hidden` on whatever element it is handed, via its own
          stylesheet's `.maplibregl-map`. Tailwind 4 emits its utilities inside `@layer utilities`, and an
          UNLAYERED rule beats a layered one no matter which comes later in the file — so `.maplibregl-map`
          wins over `.absolute`, `inset-0` stops applying, and the div collapses to height 0. It then clips
          its own canvas away, which looks exactly like a map that failed to load: the GL context is healthy,
          tiles download, pixels are drawn, and none of it is ever composited. Nothing logs a thing.

          h-full survives because maplibre's stylesheet sets no height, so there is no conflict to lose.
        */}
        <div ref={containerRef} className="h-full w-full" />

        {!styleUrl && !mapError && (
          <p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
            Loading map…
          </p>
        )}

        {mapError && (
          <div className="absolute inset-0 flex flex-col items-center justify-center gap-1 p-6 text-center">
            <p className="text-sm font-medium">The map could not load</p>
            <p className="max-w-sm text-xs text-muted-foreground">
              The basemap is fetched from Immich&apos;s tile server, so this needs outbound internet access.
              {mapError ? ` (${mapError})` : ''}
            </p>
          </div>
        )}

        {cluster && (
          <MapClusterPanel
            cluster={cluster}
            place={place}
            token={token}
            onOpen={open}
            onZoom={() => mapRef.current?.easeTo({ center: cluster.center, zoom: cluster.expansionZoom })}
            onClose={() => setCluster(null)}
          />
        )}

        {ready && !isLoading && count === 0 && (
          <div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center">
            <p className="rounded-full bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow">
              No geotagged photos match this filter.
            </p>
          </div>
        )}
      </div>

      {/* With a cluster open, prev/next walk that cluster — otherwise the arrows would jump to whatever
          thumbnails happen to be on the map behind the panel. */}
      <AssetViewer ids={cluster ? cluster.ids : visibleIds} token={token} />
    </div>
  );
};
