"use client";

import { useEffect, useRef } from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import { Vehicle } from "@/lib/api";
import { FLEET_COLORS, FleetTone, fleetTone } from "@/components/FleetMap";

const RIYADH: [number, number] = [46.6753, 24.7136];

/** Pixel radius within which vehicles collapse into one cluster. */
const CLUSTER_RADIUS = 44;

/**
 * Above this zoom mapbox would stop clustering. It is set beyond any zoom the map reaches so
 * coincident vehicles keep being counted rather than silently stacking; it doubles as the test
 * for "these can never be separated", since supercluster caps expansion zoom at this value.
 */
const CLUSTER_MAX_ZOOM = 20;

/** Tones that mean a human has to do something. Summed per cluster. */
const NEEDS_ATTENTION: FleetTone[] = ["lowBattery", "maintenance", "offline"];

/**
 * The live fleet map: every vehicle as a status-coloured dot.
 *
 * <p>Hovering shows the QR code, because that is the one identifier an operator reads out loud to
 * someone standing next to the vehicle; clicking opens the full detail sheet. The dot is coloured
 * by operational status rather than battery, since "can this be ridden right now" is the question
 * a fleet map is actually asked.
 */
export function MapboxFleetMap({
  token,
  vehicles,
  selectedId,
  onSelect,
  onPickFrom,
  labels,
  className = "",
}: {
  token: string;
  vehicles: Vehicle[];
  selectedId: string | null;
  onSelect: (id: string) => void;
  /** Members of a cluster that can never be separated by zooming. */
  onPickFrom: (ids: string[]) => void;
  /** Localised words for the cluster tooltip, which is raw HTML rather than React. */
  labels: { vehicles: string; needAttention: string };
  className?: string;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const mapRef = useRef<mapboxgl.Map | null>(null);
  const hoverRef = useRef<mapboxgl.Popup | null>(null);
  const readyRef = useRef(false);
  const fittedRef = useRef(false);
  // Read inside map event handlers, which are registered once and would otherwise close over the
  // first render's props forever.
  const selectRef = useRef(onSelect);
  selectRef.current = onSelect;
  const pickRef = useRef(onPickFrom);
  pickRef.current = onPickFrom;
  const labelsRef = useRef(labels);
  labelsRef.current = labels;

  useEffect(() => {
    if (!ref.current || mapRef.current) return;
    mapboxgl.accessToken = token;
    const map = new mapboxgl.Map({
      container: ref.current,
      style: "mapbox://styles/mapbox/dark-v11",
      center: RIYADH,
      zoom: 11,
      attributionControl: false,
    });
    map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), "top-right");
    map.addControl(new mapboxgl.FullscreenControl(), "top-right");
    map.addControl(
      new mapboxgl.GeolocateControl({ trackUserLocation: false, showAccuracyCircle: false }),
      "top-right",
    );
    map.addControl(new mapboxgl.ScaleControl({ unit: "metric" }), "bottom-left");
    mapRef.current = map;

    const hover = new mapboxgl.Popup({
      closeButton: false,
      closeOnClick: false,
      offset: 14,
      className: "beeb-map-popup",
    });
    hoverRef.current = hover;

    map.on("load", () => {
      readyRef.current = true;
      map.addSource("vehicles", {
        type: "geojson",
        data: emptyFC(),
        cluster: true,
        clusterRadius: CLUSTER_RADIUS,
        // Deliberately higher than the map's usable zoom. Vehicles that genuinely sit apart
        // separate on their own as you zoom in; ones sharing a point never will, and letting them
        // un-cluster at high zoom would just put them back on top of each other where only the
        // topmost is clickable. Keeping them clustered means the count always tells the truth.
        clusterMaxZoom: CLUSTER_MAX_ZOOM,
        clusterProperties: {
          // How many members need someone to do something. A cluster hiding a flat battery must
          // not look as calm as one full of healthy vehicles.
          attention: ["+", ["case", ["get", "attention"], 1, 0]],
        },
      });

      map.addLayer({
        id: "clusters",
        type: "circle",
        source: "vehicles",
        filter: ["has", "point_count"],
        paint: {
          "circle-color": ["case", [">", ["get", "attention"], 0], FLEET_COLORS.lowBattery, "#1F2937"],
          "circle-radius": ["step", ["get", "point_count"], 16, 10, 20, 50, 26],
          "circle-stroke-width": 2,
          "circle-stroke-color": "#ffffff",
          "circle-opacity": 0.92,
        },
      });

      map.addLayer({
        id: "cluster-count",
        type: "symbol",
        source: "vehicles",
        filter: ["has", "point_count"],
        layout: {
          "text-field": ["get", "point_count_abbreviated"],
          "text-size": 12,
          "text-allow-overlap": true,
        },
        paint: { "text-color": "#ffffff" },
      });

      // A halo under the selected dot, so the current selection stays findable after the sheet
      // covers part of the map.
      map.addLayer({
        id: "vehicles-halo",
        type: "circle",
        source: "vehicles",
        filter: ["all", ["!", ["has", "point_count"]], ["==", ["get", "selected"], true]],
        paint: {
          "circle-radius": 16,
          "circle-color": ["get", "color"],
          "circle-opacity": 0.25,
        },
      });

      map.addLayer({
        id: "vehicles-dot",
        type: "circle",
        source: "vehicles",
        filter: ["!", ["has", "point_count"]],
        paint: {
          "circle-radius": ["case", ["get", "selected"], 9, 7],
          "circle-color": ["get", "color"],
          "circle-stroke-width": ["case", ["get", "selected"], 3, 1.5],
          "circle-stroke-color": "#ffffff",
          // A vehicle that has not reported in a while is drawn faded: it is on the map because
          // that is where it was last seen, not because we know it is there now.
          "circle-opacity": ["case", ["get", "stale"], 0.45, 1],
          "circle-stroke-opacity": ["case", ["get", "stale"], 0.45, 1],
        },
      });

      // Battery reads at a glance without opening anything. Only from zoom 14, where dots are far
      // enough apart that the numbers are not a wall of overlapping text.
      map.addLayer({
        id: "vehicles-battery",
        type: "symbol",
        source: "vehicles",
        filter: ["!", ["has", "point_count"]],
        minzoom: 14,
        layout: {
          "text-field": ["concat", ["to-string", ["get", "battery"]], "%"],
          "text-size": 10,
          "text-offset": [0, 1.4],
          "text-allow-overlap": false,
        },
        paint: {
          "text-color": "#ffffff",
          "text-halo-color": "#000000",
          "text-halo-width": 1.2,
        },
      });

      /**
       * Clicking a cluster.
       *
       * <p>Zooming in is the usual answer, but it only works when the members are actually in
       * different places. Vehicles parked at one depot can share a coordinate exactly, and no
       * amount of zoom will ever separate those - supercluster reports an expansion zoom it can
       * never reach. Zooming anyway would leave the operator hammering the same cluster forever,
       * so that case hands the members up to be listed and picked from instead.
       */
      map.on("click", "clusters", (e) => {
        const feature = e.features?.[0];
        const clusterId = feature?.properties?.cluster_id;
        if (clusterId == null) return;
        const src = map.getSource("vehicles") as mapboxgl.GeoJSONSource;
        const point = (feature!.geometry as GeoJSON.Point).coordinates as [number, number];

        src.getClusterExpansionZoom(clusterId as number, (err, zoom) => {
          const separable = !err && typeof zoom === "number" && zoom <= CLUSTER_MAX_ZOOM;
          if (separable) {
            map.easeTo({ center: point, zoom });
            return;
          }
          const count = Number(feature!.properties?.point_count ?? 0) || 100;
          src.getClusterLeaves(clusterId as number, count, 0, (leafErr, leaves) => {
            if (leafErr || !leaves) return;
            const ids = leaves
              .map((l) => (l.properties as Record<string, unknown> | null)?.id)
              .filter((x): x is string => typeof x === "string");
            if (ids.length > 0) pickRef.current(ids);
          });
        });
      });

      map.on("mousemove", "clusters", (e) => {
        const f = e.features?.[0];
        if (!f) return;
        map.getCanvas().style.cursor = "pointer";
        const p = f.properties as Record<string, unknown>;
        const count = Number(p.point_count ?? 0);
        const attention = Number(p.attention ?? 0);
        hover
          .setLngLat((f.geometry as GeoJSON.Point).coordinates as [number, number])
          .setHTML(
            `<div class="beeb-map-tip">
               <strong>${count} ${escapeHtml(labelsRef.current.vehicles)}</strong>
               ${attention > 0 ? `<span>${attention} ${escapeHtml(labelsRef.current.needAttention)}</span>` : ""}
             </div>`,
          )
          .addTo(map);
      });
      map.on("mouseleave", "clusters", () => {
        map.getCanvas().style.cursor = "";
        hover.remove();
      });

      map.on("click", "vehicles-dot", (e) => {
        const id = e.features?.[0]?.properties?.id;
        if (id) selectRef.current(id as string);
      });

      map.on("mousemove", "vehicles-dot", (e) => {
        const f = e.features?.[0];
        if (!f) return;
        map.getCanvas().style.cursor = "pointer";
        const p = f.properties as Record<string, unknown>;
        const geom = f.geometry as GeoJSON.Point;
        hover
          .setLngLat(geom.coordinates as [number, number])
          .setHTML(
            `<div class="beeb-map-tip">
               <strong>${escapeHtml(String(p.qrCode ?? ""))}</strong>
               <span>${escapeHtml(String(p.battery ?? 0))}% · ${escapeHtml(String(p.status ?? ""))}</span>
             </div>`,
          )
          .addTo(map);
      });
      map.on("mouseleave", "vehicles-dot", () => {
        map.getCanvas().style.cursor = "";
        hover.remove();
      });

      sync();
    });

    return () => {
      hover.remove();
      map.remove();
      mapRef.current = null;
      hoverRef.current = null;
      readyRef.current = false;
      fittedRef.current = false;
    };
    // token is stable for a mounted map; re-initialising on change is unnecessary.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [token]);

  const sync = () => {
    const map = mapRef.current;
    if (!map || !readyRef.current) return;
    const located = vehicles.filter((v) => v.lastLat != null && v.lastLng != null);
    const src = map.getSource("vehicles") as mapboxgl.GeoJSONSource | undefined;
    src?.setData({
      type: "FeatureCollection",
      features: located.map((v) => ({
        type: "Feature",
        properties: {
          id: v.id,
          qrCode: v.qrCode,
          battery: v.batteryPercent,
          status: v.status,
          color: FLEET_COLORS[fleetTone(v)],
          selected: v.id === selectedId,
          stale: isStale(v.lastSeenAt),
          // Summed across a cluster, so a group hiding something broken is coloured for it.
          attention: NEEDS_ATTENTION.includes(fleetTone(v)),
        },
        geometry: { type: "Point", coordinates: [v.lastLng as number, v.lastLat as number] },
      })),
    });

    // Fit once, on the first load that actually has vehicles. Re-fitting on every poll would yank
    // the map away from wherever the operator had panned to.
    if (!fittedRef.current && located.length > 0) {
      fittedRef.current = true;
      const bounds = new mapboxgl.LngLatBounds();
      located.forEach((v) => bounds.extend([v.lastLng as number, v.lastLat as number]));
      map.fitBounds(bounds, { padding: 80, maxZoom: 15, duration: 0 });
    }
  };
  useEffect(sync, [vehicles, selectedId]);

  return <div ref={ref} className={className} />;
}

/** A fix older than this is history, not a live position. */
const STALE_MINUTES = 30;

function isStale(lastSeenAt: string | null): boolean {
  if (!lastSeenAt) return true;
  const seen = Date.parse(lastSeenAt);
  if (Number.isNaN(seen)) return true;
  return Date.now() - seen > STALE_MINUTES * 60_000;
}

function emptyFC(): GeoJSON.FeatureCollection {
  return { type: "FeatureCollection", features: [] };
}

/** The popup takes raw HTML, and a QR code is operator-supplied text. */
function escapeHtml(value: string): string {
  return value
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}
