import { type GeoLibreLayer, parseHexColorList, useAppStore } from "@geolibre/core";
import {
  RASTER_MAX_CLASSES,
  RASTER_MAX_STORED_CLASSES,
  RASTER_MIN_CLASSES,
  RASTER_MIN_CUSTOM_COLORS,
  type RasterBandStats,
  type RasterClassificationMethod,
  type RasterSymbology,
  colormapColors,
  computeRasterBreaks,
  getPaletteLegend,
  getRasterBandStats,
  type PaletteLegendEntry,
  savedRasterSymbology,
  warmColormapColors,
} from "@geolibre/plugins";
import {
  Button,
  type ColorRampOption,
  ColorRampSelect,
  Input,
  Label,
  Select,
  Separator,
  Textarea,
} from "@geolibre/ui";
import {
  COLORMAP_OPTIONS,
  CUSTOM_NORMALIZED_DIFFERENCE,
  guessBandForRole,
  indexById,
  NORMALIZED_DIFFERENCE_INDICES,
} from "maplibre-gl-raster";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useColormapRamps } from "../../hooks/useColormapRamps";
import { setLegendCustomEntry } from "../../lib/auto-legend";
import { savedRasterAttributeTable } from "../../lib/raster-attribute-table";

type RasterStateRecord = {
  mode: "single" | "rgb" | "index";
  bands: number[];
  /** Normalized-difference preset id (index mode only), e.g. "ndvi". */
  index?: string;
  colormap: string;
  reversed: boolean;
  rescale: [number, number][] | null;
  nodata: number | "auto" | "off";
  stretch: "linear" | "log" | "sqrt";
  gamma: number;
};

const CLASSIFICATION_METHODS: {
  value: RasterClassificationMethod;
  labelKey:
    | "rasterSymbology.methodEqualInterval"
    | "rasterSymbology.methodQuantile"
    | "rasterSymbology.methodManual";
}[] = [
  { value: "equal-interval", labelKey: "rasterSymbology.methodEqualInterval" },
  { value: "quantile", labelKey: "rasterSymbology.methodQuantile" },
  { value: "manual", labelKey: "rasterSymbology.methodManual" },
];

const DEFAULT_RAMP = "viridis";
const DEFAULT_CLASS_COUNT = 5;
/** Sentinel `<Select>` value that switches the ramp to a user-defined list. */
const CUSTOM_RAMP_VALUE = "__custom__";
/** A custom ramp needs at least this many colors to interpolate. */
const MIN_CUSTOM_COLORS = RASTER_MIN_CUSTOM_COLORS;

/** True when a pre-migration project stored `reversed` on rasterSymbology. */
function legacyReversed(layer: GeoLibreLayer): boolean {
  const sym = layer.metadata.rasterSymbology;
  return (
    typeof sym === "object" &&
    sym !== null &&
    !Array.isArray(sym) &&
    (sym as Record<string, unknown>).reversed === true
  );
}

function readRasterState(layer: GeoLibreLayer): RasterStateRecord {
  const raw =
    layer.metadata.rasterState &&
    typeof layer.metadata.rasterState === "object" &&
    !Array.isArray(layer.metadata.rasterState)
      ? (layer.metadata.rasterState as Record<string, unknown>)
      : {};
  const bands = Array.isArray(raw.bands)
    ? (raw.bands.filter((b) => typeof b === "number") as number[])
    : [1];
  const rescale =
    Array.isArray(raw.rescale) &&
    raw.rescale.every((range) => Array.isArray(range) && range.length === 2)
      ? (raw.rescale as [number, number][])
      : null;
  return {
    mode: raw.mode === "rgb" ? "rgb" : raw.mode === "index" ? "index" : "single",
    bands: bands.length > 0 ? bands : [1],
    index: typeof raw.index === "string" ? raw.index : undefined,
    colormap: typeof raw.colormap === "string" ? raw.colormap : DEFAULT_RAMP,
    // Reverse lives on rasterState now; migrate projects saved before that move
    // (the flag used to live on rasterSymbology) so they keep their reversal.
    reversed: raw.reversed === true || legacyReversed(layer),
    rescale,
    nodata:
      raw.nodata === "off" || typeof raw.nodata === "number"
        ? (raw.nodata as number | "off")
        : "auto",
    stretch: raw.stretch === "log" || raw.stretch === "sqrt" ? raw.stretch : "linear",
    gamma: typeof raw.gamma === "number" && raw.gamma > 0 ? raw.gamma : 1,
  };
}

function readBandCount(layer: GeoLibreLayer): number | null {
  const value = layer.metadata.bandCount;
  return typeof value === "number" && value > 0 ? value : null;
}

function readBandNames(layer: GeoLibreLayer): Map<number, string> {
  const raw = layer.metadata.bandNames;
  const map = new Map<number, string>();
  if (Array.isArray(raw)) {
    for (const pair of raw) {
      if (Array.isArray(pair) && typeof pair[0] === "number" && typeof pair[1] === "string") {
        map.set(pair[0], pair[1]);
      }
    }
  }
  return map;
}

function rangeFromBreaks(breaks: number[]): [number, number][] {
  return [[breaks[0], breaks[breaks.length - 1]]];
}

/**
 * Single-band pseudocolor (with optional discrete classification) and RGB
 * band-combination controls for a maplibre-gl-raster COG layer. Edits the
 * layer's `metadata.rasterState` (pushed to the control by the store sync) and
 * GeoLibre-owned `metadata.rasterSymbology` (consumed by the classification
 * render injection).
 *
 * @param props.layer - The selected raster store layer.
 */
export function RasterSymbologySection({ layer }: { layer: GeoLibreLayer }) {
  const { t } = useTranslation();
  const updateLayer = useAppStore((s) => s.updateLayer);
  const state = readRasterState(layer);
  const bandCount = readBandCount(layer);
  const bandNames = readBandNames(layer);
  const symbology = savedRasterSymbology(layer);
  const band = state.bands[0] ?? 1;

  const [stats, setStats] = useState<RasterBandStats | null>(null);
  const lastStatsRef = useRef<RasterBandStats | null>(null);

  // Status of the "Create legend from palette" action: idle, in-flight, or a
  // message key (empty palette / read error) shown beneath the button.
  const [legendStatus, setLegendStatus] = useState<"idle" | "pending" | "empty" | "error">("idle");
  // Aborts an in-flight palette read so its result can't land on a layer the
  // user has since switched away from.
  const legendAbortRef = useRef<AbortController | null>(null);

  // The embedded palette's class colors, loaded (and cached) for palette
  // rasters so the color-ramp preview shows the real colors instead of the gray
  // fallback the "palette" sentinel resolves to.
  const [paletteEntries, setPaletteEntries] = useState<PaletteLegendEntry[] | null>(null);

  // Clear the action state when the selected layer changes (like `stats`
  // above), and cancel any in-flight palette read so a late resolution never
  // attributes its outcome to the newly-selected layer.
  useEffect(() => {
    setLegendStatus("idle");
    return () => {
      legendAbortRef.current?.abort();
      legendAbortRef.current = null;
    };
  }, [layer.id]);

  // Fetch band statistics lazily once the user is classifying (equal-interval
  // and quantile both need a data range / histogram). Aborts implicitly via
  // the cache + the manager's per-layer AbortController. Stats are cleared
  // first so a band/method switch shows "Computing data range…" rather than
  // the previous band's values.
  // Locally-produced (file-backed) rasters expose only a session blob URL; pass
  // it so stats can be read directly when the control has no source URL.
  const localBytesUrl =
    typeof layer.metadata?.localBytesUrl === "string" ? layer.metadata.localBytesUrl : null;
  // Source URL used by both the palette read and the legend action: the
  // control's URL for a remote COG, or the session blob for a file-backed one.
  const rasterUrl =
    (typeof layer.source?.url === "string" ? layer.source.url : null) ?? localBytesUrl;
  // A single-band raster rendered through its embedded color table.
  const isPaletteRaster = state.mode === "single" && state.colormap === "palette";

  // Load the embedded palette colors for a palette raster (cached per layer),
  // so the ramp preview reflects the actual classes. Cleared first on a layer
  // switch so a stale preview isn't shown.
  useEffect(() => {
    setPaletteEntries(null);
    if (!isPaletteRaster || !rasterUrl) return;
    let cancelled = false;
    void getPaletteLegend(layer.id, rasterUrl)
      .then((entries) => {
        if (!cancelled) setPaletteEntries(entries);
      })
      .catch(() => {
        // A failed palette read just leaves the preview on its gray fallback.
      });
    return () => {
      cancelled = true;
    };
  }, [layer.id, isPaletteRaster, rasterUrl]);

  useEffect(() => {
    setStats(null);
    let cancelled = false;
    if (!symbology?.classified || symbology.method === "manual") return;
    void getRasterBandStats(layer.id, band, localBytesUrl).then((result) => {
      if (!cancelled && result) setStats(result);
    });
    return () => {
      cancelled = true;
    };
  }, [layer.id, band, symbology?.classified, symbology?.method, localBytesUrl]);

  // Classification can be enabled before stats arrive (breaks fall back to the
  // [0, …, 1] default range), and switching bands while classified leaves the
  // previous band's edges in place. When fresh stats land, recompute the breaks
  // if they're the placeholder range or no longer cover the band's data, so the
  // classification follows the band without extra interaction. A range the user
  // narrowed by hand (same band, so stats is unchanged) never reaches here.
  useEffect(() => {
    if (!stats || stats === lastStatsRef.current) return;
    lastStatsRef.current = stats;
    if (!symbology?.classified || symbology.method === "manual") return;
    const isDefaultRange = symbology.breaks[0] === 0 && symbology.breaks.at(-1) === 1;
    const coversData =
      stats.min >= symbology.breaks[0] &&
      stats.max <= symbology.breaks[symbology.breaks.length - 1];
    if (isDefaultRange || !coversData) recomputeSymbology({ ...symbology });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [stats]);

  const bandOptions = useMemo(() => {
    if (!bandCount) return [];
    return Array.from({ length: bandCount }, (_, index) => {
      const value = index + 1;
      const name = bandNames.get(value);
      return { value, label: name ? `${value}: ${name}` : `Band ${value}` };
    });
  }, [bandCount, bandNames]);

  // Colors for the ramp preview gradient. Custom ramps use their own colors;
  // built-in ramps resolve synchronously; other (sprite) colormaps are sampled
  // from the renderer's sprite asynchronously and cached. Declared here (before
  // the RGB early return) so the hook order stays stable.
  const previewRamp = symbology?.ramp ?? state.colormap ?? DEFAULT_RAMP;
  const previewCustom =
    (symbology?.customColors?.length ?? 0) >= MIN_CUSTOM_COLORS
      ? (symbology?.customColors as string[])
      : null;
  const [rampPreview, setRampPreview] = useState<readonly string[]>([]);
  const previewCustomKey = previewCustom?.join(",") ?? "";
  useEffect(() => {
    if (previewCustom) {
      setRampPreview(previewCustom);
      return;
    }
    const known = colormapColors(previewRamp);
    if (known) {
      setRampPreview(known);
      return;
    }
    let cancelled = false;
    void warmColormapColors(previewRamp).then((colors) => {
      if (!cancelled && colors) setRampPreview(colors);
    });
    return () => {
      cancelled = true;
    };
    // previewCustomKey captures the custom-colors identity for the deps.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [previewRamp, previewCustomKey]);

  // Every colormap as a swatched option, shared with the Add NetCDF dialog so
  // both pickers offer the same catalogue. Declared before the RGB early return
  // so the hook order stays stable.
  const catalogueRamps = useColormapRamps();

  function commit(options: {
    statePatch?: Partial<RasterStateRecord>;
    symbology?: RasterSymbology | null;
  }): void {
    const metadata: Record<string, unknown> = { ...layer.metadata };
    if (options.statePatch) {
      metadata.rasterState = { ...state, ...options.statePatch };
    }
    if (options.symbology === null) {
      delete metadata.rasterSymbology;
    } else if (options.symbology) {
      metadata.rasterSymbology = options.symbology;
    }
    updateLayer(layer.id, { metadata });
  }

  function recomputeSymbology(
    next: Pick<RasterSymbology, "ramp" | "method" | "classCount" | "customColors">,
    overrides: { range?: [number, number]; manualBreaks?: number[] } = {},
  ): void {
    // Reusing the prior histogram here is safe: a range override only happens
    // for equal-interval (the Min/Max inputs are disabled for quantile), and
    // equal-interval breaks use only min/max — never the histogram.
    const effectiveStats: RasterBandStats | null = overrides.range
      ? { min: overrides.range[0], max: overrides.range[1], histogram: stats?.histogram ?? [] }
      : stats;
    // A manual symbology whose edges already match the requested class count
    // keeps them verbatim, even past the 12-class authoring cap:
    // computeRasterBreaks clamps to that cap, which would collapse a
    // categorical table symbology (up to RASTER_MAX_STORED_CLASSES classes)
    // to an even 12-class spread on any ramp/color/method edit. A class-count
    // edit (edges no longer match) still recomputes as before.
    const manualEdges = overrides.manualBreaks ?? symbology?.breaks ?? [];
    const keepManualEdges =
      next.method === "manual" &&
      manualEdges.length === next.classCount + 1 &&
      next.classCount <= RASTER_MAX_STORED_CLASSES &&
      manualEdges.every((value) => Number.isFinite(value));
    const breaks = keepManualEdges
      ? [...manualEdges].sort((a, b) => a - b)
      : computeRasterBreaks(
          next.method,
          effectiveStats,
          next.classCount,
          overrides.manualBreaks ?? symbology?.breaks,
        );
    const custom =
      (next.customColors?.length ?? 0) >= MIN_CUSTOM_COLORS ? next.customColors : undefined;
    commit({
      statePatch: { colormap: next.ramp, rescale: rangeFromBreaks(breaks) },
      symbology: {
        classified: true,
        ramp: next.ramp,
        method: next.method,
        // Derived from the breaks actually computed, not the requested count:
        // computeRasterBreaks clamps to the authoring cap, and a stored
        // count/breaks mismatch would make the record inconsistent (e.g. when
        // re-ramping a >12-class table symbology from the RAT panel).
        classCount: breaks.length - 1,
        breaks,
        ...(custom ? { customColors: custom } : {}),
      },
    });
  }

  // Reads the raster's embedded color table and fills the layer's Legend-panel
  // entry with one item per pixel value present in the data — labelled from the
  // Raster Attribute Table when one is saved (land-cover class names like
  // NLCD), else with the bare value for the user to rename in the panel.
  // Resolves the source the same way stats do: the control's URL, or the
  // session blob for a file-backed raster.
  async function createLegendFromPalette(): Promise<void> {
    if (!rasterUrl) {
      setLegendStatus("error");
      return;
    }
    // Supersede any prior read. The layer-switch guard is the abort itself: the
    // [layer.id] effect's cleanup aborts this controller, so a resolution after
    // a switch is skipped via signal.aborted (this closure's `layer` binding
    // can't change, so comparing ids here would be dead code).
    legendAbortRef.current?.abort();
    const controller = new AbortController();
    legendAbortRef.current = controller;
    const stale = () => controller.signal.aborted;
    setLegendStatus("pending");
    try {
      const entries = await getPaletteLegend(layer.id, rasterUrl, controller.signal);
      if (stale()) return;
      if (!entries || entries.length === 0) {
        setLegendStatus("empty");
        return;
      }
      // The awaited palette read races user edits: re-read the live layer so a
      // rename or RAT label edit made meanwhile isn't lost, and only apply RAT
      // labels when the table describes the band currently rendered.
      const currentLayer =
        useAppStore.getState().layers.find((candidate) => candidate.id === layer.id) ?? layer;
      const table = savedRasterAttributeTable(currentLayer);
      const currentBand = readRasterState(currentLayer).bands[0] ?? 1;
      const labelByValue = new Map(
        table && table.band === currentBand
          ? table.rows.map((row) => [row.value, row.label] as [number, string])
          : [],
      );
      const { legend, setLegend } = useAppStore.getState();
      setLegend({
        ...setLegendCustomEntry(legend, layer.id, {
          title: currentLayer.name,
          items: entries.map((entry) => ({
            label: labelByValue.get(entry.value) ?? String(entry.value),
            color: entry.color,
          })),
        }),
        panelVisible: true,
      });
      setLegendStatus("idle");
    } catch {
      if (stale()) return;
      setLegendStatus("error");
    }
  }

  // The image palette only feeds a legend in single-band palette mode; other
  // colormaps are continuous and have no per-value classes to enumerate.
  const canCreateLegend = isPaletteRaster;

  const paletteLegendControl = canCreateLegend ? (
    <div className="space-y-1">
      <Button
        type="button"
        variant="outline"
        size="sm"
        className="w-full"
        disabled={legendStatus === "pending"}
        onClick={() => void createLegendFromPalette()}
      >
        {legendStatus === "pending"
          ? t("rasterSymbology.createLegendPending")
          : t("rasterSymbology.createLegend")}
      </Button>
      {legendStatus === "empty" && (
        <p className="text-[10px] text-muted-foreground">
          {t("rasterSymbology.createLegendEmpty")}
        </p>
      )}
      {legendStatus === "error" && (
        <p className="text-[10px] text-destructive">{t("rasterSymbology.createLegendError")}</p>
      )}
      {legendStatus !== "empty" && legendStatus !== "error" && (
        <p className="text-[10px] text-muted-foreground">{t("rasterSymbology.createLegendHint")}</p>
      )}
    </div>
  ) : null;

  // Builds the state patch for a normalized-difference index preset: assigns
  // each operand a band (guessed from band names, else 1 and 2), applies the
  // preset's default colormap, and resets rescale to the [-1, 1] auto range.
  // Mirrors the upstream control's own preset seeding so the two panels agree.
  function indexPatchFor(presetId: string): Partial<RasterStateRecord> {
    const preset = indexById(presetId) ?? NORMALIZED_DIFFERENCE_INDICES[0];
    const max = bandCount ?? Math.max(2, ...state.bands);
    const clamp = (b: number) => Math.min(Math.max(1, b), Math.max(1, max));
    const a = clamp(guessBandForRole(preset.roleA, bandNames) ?? state.bands[0] ?? 1);
    let b = clamp(guessBandForRole(preset.roleB, bandNames) ?? (a === 2 ? 1 : 2));
    // Both roles can resolve to the same band (ambiguous band names, or a
    // single-band clamp); (A - B) / (A + B) with A === B is a constant 0, so
    // nudge B to a distinct band when the image has one.
    if (b === a) b = clamp(a === 1 ? 2 : 1);
    return {
      mode: "index",
      index: preset.id,
      bands: [a, b],
      colormap: preset.colormap,
      rescale: null,
    };
  }

  // --- Mode ---
  const modeControl = (
    <div className="space-y-2">
      <Label htmlFor="rasterMode">{t("rasterSymbology.renderMode")}</Label>
      <Select
        id="rasterMode"
        value={state.mode}
        disabled={bandCount === null}
        onChange={(event) => {
          const mode = event.target.value as "single" | "rgb" | "index";
          if (mode === "rgb") {
            const bands = state.bands.length >= 3 ? state.bands.slice(0, 3) : [1, 2, 3];
            commit({ statePatch: { mode, bands }, symbology: null });
          } else if (mode === "index") {
            commit({
              statePatch: indexPatchFor(state.index ?? "ndvi"),
              symbology: null,
            });
          } else {
            commit({ statePatch: { mode } });
          }
        }}
      >
        <option value="single">{t("rasterSymbology.modeSingle")}</option>
        {(bandCount === null || bandCount >= 2) && (
          <option value="index">{t("rasterSymbology.modeIndex")}</option>
        )}
        {(bandCount === null || bandCount >= 3) && (
          <option value="rgb">{t("rasterSymbology.modeRgb")}</option>
        )}
      </Select>
      {bandCount === null && <p className="text-[10px] text-muted-foreground">Loading bands…</p>}
    </div>
  );

  if (state.mode === "rgb") {
    return (
      <div className="space-y-3">
        <Separator />
        <p className="text-xs font-semibold">{t("rasterSymbology.heading")}</p>
        {modeControl}
        <RgbControls
          state={state}
          bandOptions={bandOptions}
          onChange={(patch) => commit({ statePatch: patch })}
        />
        <NodataControl state={state} onChange={(nodata) => commit({ statePatch: { nodata } })} />
      </div>
    );
  }

  const ramp = symbology?.ramp ?? state.colormap ?? DEFAULT_RAMP;
  const classified = symbology?.classified ?? false;
  const classCount = symbology?.classCount ?? DEFAULT_CLASS_COUNT;
  const method = symbology?.method ?? "equal-interval";
  // Reverse lives on rasterState: the control renders it for built-in
  // colormaps, and the injected texture bakes it for classified / custom.
  const reversed = state.reversed;
  const customColors = symbology?.customColors;
  const isCustom = (customColors?.length ?? 0) >= MIN_CUSTOM_COLORS;
  const rampSelectValue = isCustom ? CUSTOM_RAMP_VALUE : ramp;

  // A custom ramp is the only thing the upstream control can't express for a
  // continuous layer, so it carries a classified:false symbology record the
  // render injection reads; otherwise no record is needed (the control renders
  // the named colormap, reversal included). Breaks are required by the record
  // but unused while continuous, so seed them from whatever range is known.
  function continuousSymbology(opts: {
    ramp: string;
    customColors?: string[];
  }): RasterSymbology | null {
    const custom =
      (opts.customColors?.length ?? 0) >= MIN_CUSTOM_COLORS ? opts.customColors : undefined;
    if (!custom) return null;
    const breaks = computeRasterBreaks(method, stats, classCount);
    return {
      classified: false,
      ramp: opts.ramp,
      method,
      // Derived from the computed breaks (which clamp to the authoring cap)
      // so the stored record stays self-consistent.
      classCount: breaks.length - 1,
      breaks,
      customColors: custom,
    };
  }

  // Reverse is a single rasterState flag for every mode: the control reverses
  // built-in colormaps natively, and the injected texture reads it to bake the
  // flip for classified / custom ramps.
  function setReversed(next: boolean): void {
    commit({ statePatch: { reversed: next } });
  }

  // Switch to / edit / clear a user-defined ramp. `next` is the parsed color
  // list (>= 2 colors) or undefined to drop back to the named ramp.
  function setCustomColors(next: string[] | undefined): void {
    if (classified && symbology) {
      recomputeSymbology({ ramp, method, classCount, customColors: next });
    } else {
      commit({ symbology: continuousSymbology({ ramp, customColors: next }) });
    }
  }

  // Select a built-in named ramp (clears any custom colors). Classified
  // recomputes through the named ramp; continuous pushes the colormap to the
  // control (which renders it, reversal included) and drops any custom record.
  function selectNamedRamp(value: string): void {
    if (classified && symbology) {
      recomputeSymbology({
        ramp: value,
        method,
        classCount,
        customColors: undefined,
      });
    } else {
      commit({
        statePatch: { colormap: value },
        symbology: continuousSymbology({ ramp: value, customColors: undefined }),
      });
    }
  }

  // The picker's options, each carrying its own colors so the dropdown shows a
  // gradient swatch beside every ramp name. Plain (not memoized) because it is
  // built after the RGB early return, where a hook would break hook order.
  const rampOptions: ColorRampOption[] = [];
  // A raster may arrive with a colormap name not in the list (e.g. the
  // control's "palette" default); surface it so the picker reflects what is
  // actually rendered instead of silently showing the first.
  if (!isCustom && !COLORMAP_OPTIONS.some((o) => o.name === ramp)) {
    // The "palette" sentinel isn't a real ramp: label it clearly and preview it
    // with the embedded palette's own class colors (once loaded) rather than the
    // misleading gray fallback the name would otherwise resolve to.
    const isImagePalette = ramp === "palette";
    const paletteColors = paletteEntries?.map((entry) => entry.color) ?? [];
    rampOptions.push({
      value: ramp,
      label: isImagePalette ? t("rasterSymbology.imagePalette") : ramp,
      // The catalogue only warms names it lists, so for an out-of-catalog ramp
      // rampPreview (seeded by the previewRamp effect above) is the real source.
      colors: isImagePalette && paletteColors.length > 0 ? paletteColors : rampPreview,
    });
  }
  rampOptions.push(...catalogueRamps);
  rampOptions.push({
    value: CUSTOM_RAMP_VALUE,
    label: t("rasterSymbology.customRamp"),
    // Preview the actual user-defined colors when a custom ramp is active.
    colors: isCustom ? (customColors as string[]) : [],
  });

  return (
    <div className="space-y-3">
      <Separator />
      <p className="text-xs font-semibold">{t("rasterSymbology.heading")}</p>
      {modeControl}

      {state.mode === "index" ? (
        <IndexControls
          state={state}
          bandOptions={bandOptions}
          onPreset={(id) => commit({ statePatch: indexPatchFor(id), symbology: null })}
          onBands={(bands) => commit({ statePatch: { bands } })}
        />
      ) : (
        <div className="space-y-2">
          <Label htmlFor="rasterBand">{t("rasterSymbology.band")}</Label>
          <Select
            id="rasterBand"
            value={String(band)}
            disabled={bandOptions.length === 0}
            onChange={(event) => commit({ statePatch: { bands: [Number(event.target.value)] } })}
          >
            {bandOptions.length === 0 ? (
              <option value={String(band)}>{`Band ${band}`}</option>
            ) : (
              bandOptions.map((option) => (
                <option key={option.value} value={option.value}>
                  {option.label}
                </option>
              ))
            )}
          </Select>
        </div>
      )}

      <div className="space-y-2">
        <Label htmlFor="rasterRamp">{t("rasterSymbology.colorRampLabel")}</Label>
        <ColorRampSelect
          id="rasterRamp"
          aria-label={t("rasterSymbology.colorRampLabel")}
          value={rampSelectValue}
          reversed={reversed}
          ramps={rampOptions}
          onValueChange={(value) => {
            if (value === CUSTOM_RAMP_VALUE) {
              // Seed the editable list synchronously from the current ramp's
              // resolved colors (or the already-resolved preview), falling back
              // to viridis so custom mode always activates with a valid (>= 2
              // color) ramp -- no async warm, so switching away can't be
              // clobbered by a late callback.
              const seed = colormapColors(ramp) ?? rampPreview;
              const colors =
                seed.length >= MIN_CUSTOM_COLORS ? seed : (colormapColors("viridis") ?? []);
              setCustomColors([...colors]);
            } else {
              selectNamedRamp(value);
            }
          }}
        />
        {isCustom && (
          <CustomColorsField
            colors={customColors as string[]}
            onCommit={(colors) => setCustomColors(colors)}
          />
        )}
      </div>

      <label className="flex items-center gap-2 text-xs">
        <input
          type="checkbox"
          checked={reversed}
          onChange={(event) => setReversed(event.target.checked)}
        />
        {t("rasterSymbology.reverseRamp")}
      </label>

      {paletteLegendControl}

      <label className="flex items-center gap-2 text-xs">
        <input
          type="checkbox"
          checked={classified}
          onChange={(event) => {
            if (event.target.checked) {
              recomputeSymbology({ ramp, method, classCount, customColors });
            } else {
              // Drop classification but keep a custom ramp (reverse lives on
              // rasterState and is untouched here).
              commit({
                symbology: continuousSymbology({ ramp, customColors }),
              });
            }
          }}
        />
        {t("rasterSymbology.classifyToggle")}
      </label>

      {classified && symbology && (
        <ClassificationControls
          symbology={symbology}
          stats={stats}
          onMethod={(nextMethod) => recomputeSymbology({ ...symbology, method: nextMethod })}
          onClassCount={(count) => recomputeSymbology({ ...symbology, classCount: count })}
          onManualBreaks={(breaks) => {
            // Keep edges ascending: savedRasterSymbology rejects unsorted
            // breaks, which would silently collapse the classification UI.
            const sorted = [...breaks].sort((a, b) => a - b);
            commit({
              statePatch: { rescale: rangeFromBreaks(sorted) },
              symbology: { ...symbology, breaks: sorted },
            });
          }}
          onRange={(range) => recomputeSymbology({ ...symbology }, { range })}
        />
      )}

      {!classified && (
        <RescaleControls
          rescale={state.rescale}
          onChange={(rescale) => commit({ statePatch: { rescale } })}
        />
      )}

      {!classified && (
        <div className="grid grid-cols-2 gap-3">
          <div className="space-y-2">
            <Label htmlFor="rasterStretch">{t("rasterSymbology.stretch")}</Label>
            <Select
              id="rasterStretch"
              value={state.stretch}
              onChange={(event) =>
                commit({
                  statePatch: {
                    stretch: event.target.value as "linear" | "log" | "sqrt",
                  },
                })
              }
            >
              <option value="linear">{t("rasterSymbology.stretchLinear")}</option>
              <option value="log">{t("rasterSymbology.stretchLog")}</option>
              <option value="sqrt">{t("rasterSymbology.stretchSqrt")}</option>
            </Select>
          </div>
          <NumberField
            label={t("rasterSymbology.gamma")}
            value={state.gamma}
            step={0.1}
            min={0.1}
            onCommit={(value) => commit({ statePatch: { gamma: value > 0 ? value : 1 } })}
          />
        </div>
      )}

      <NodataControl state={state} onChange={(nodata) => commit({ statePatch: { nodata } })} />
    </div>
  );
}

function RgbControls({
  state,
  bandOptions,
  onChange,
}: {
  state: RasterStateRecord;
  bandOptions: { value: number; label: string }[];
  onChange: (patch: Partial<RasterStateRecord>) => void;
}) {
  const bands = state.bands.length >= 3 ? state.bands : [1, 2, 3];
  const channels: { key: "R" | "G" | "B"; index: number }[] = [
    { key: "R", index: 0 },
    { key: "G", index: 1 },
    { key: "B", index: 2 },
  ];
  return (
    <div className="space-y-2">
      {channels.map(({ key, index }) => (
        <div key={key} className="grid grid-cols-[1.5rem_1fr] items-center gap-2">
          <Label className="text-xs">{key}</Label>
          <Select
            value={String(bands[index] ?? index + 1)}
            disabled={bandOptions.length === 0}
            onChange={(event) => {
              const next = [...bands];
              next[index] = Number(event.target.value);
              onChange({ bands: next });
            }}
          >
            {bandOptions.length === 0 ? (
              <option value={String(bands[index] ?? index + 1)}>
                {`Band ${bands[index] ?? index + 1}`}
              </option>
            ) : (
              bandOptions.map((option) => (
                <option key={option.value} value={option.value}>
                  {option.label}
                </option>
              ))
            )}
          </Select>
        </div>
      ))}
    </div>
  );
}

/** Index-mode operand editor: a normalized-difference preset selector plus a
 * band picker for each operand (A, B) of `(A - B) / (A + B)`, labelled with
 * the preset's roles. Mirrors the upstream control's index UI so editing an
 * index layer works from either panel; the color ramp / rescale below apply to
 * the computed [-1, 1] result. */
function IndexControls({
  state,
  bandOptions,
  onPreset,
  onBands,
}: {
  state: RasterStateRecord;
  bandOptions: { value: number; label: string }[];
  onPreset: (presetId: string) => void;
  onBands: (bands: number[]) => void;
}) {
  const { t } = useTranslation();
  const preset = indexById(state.index) ?? NORMALIZED_DIFFERENCE_INDICES[0];
  const presets = [...NORMALIZED_DIFFERENCE_INDICES, CUSTOM_NORMALIZED_DIFFERENCE];
  const bandA = state.bands[0] ?? 1;
  const bandB = state.bands[1] ?? 2;
  const operands: { role: string; slot: 0 | 1; value: number }[] = [
    { role: preset.roleA, slot: 0, value: bandA },
    { role: preset.roleB, slot: 1, value: bandB },
  ];
  const setOperand = (slot: 0 | 1, value: number) => {
    const next = [bandA, bandB];
    next[slot] = value;
    onBands(next);
  };
  return (
    <>
      <div className="space-y-2">
        <Label htmlFor="rasterIndexPreset">{t("rasterSymbology.index")}</Label>
        <Select
          id="rasterIndexPreset"
          value={preset.id}
          onChange={(event) => onPreset(event.target.value)}
        >
          {presets.map((option) => (
            <option key={option.id} value={option.id}>
              {option.label}
            </option>
          ))}
        </Select>
      </div>
      <div className="space-y-2">
        <Label>{`Bands (${preset.roleA}, ${preset.roleB})`}</Label>
        <div className="grid grid-cols-2 gap-2">
          {operands.map(({ slot, value }) => (
            <Select
              key={slot}
              aria-label={`index-band-${slot === 0 ? "a" : "b"}`}
              value={String(value)}
              disabled={bandOptions.length === 0}
              onChange={(event) => setOperand(slot, Number(event.target.value))}
            >
              {bandOptions.length === 0 ? (
                <option value={String(value)}>{`Band ${value}`}</option>
              ) : (
                bandOptions.map((option) => (
                  <option key={option.value} value={option.value}>
                    {option.label}
                  </option>
                ))
              )}
            </Select>
          ))}
        </div>
        {bandA === bandB && (
          <p className="text-[10px] text-amber-600 dark:text-amber-500">
            Operands A and B are the same band; the index is 0 everywhere. Pick two different bands.
          </p>
        )}
      </div>
    </>
  );
}

function ClassificationControls({
  symbology,
  stats,
  onMethod,
  onClassCount,
  onManualBreaks,
  onRange,
}: {
  symbology: RasterSymbology;
  stats: RasterBandStats | null;
  onMethod: (method: RasterClassificationMethod) => void;
  onClassCount: (count: number) => void;
  onManualBreaks: (breaks: number[]) => void;
  onRange: (range: [number, number]) => void;
}) {
  const { t } = useTranslation();
  const min = symbology.breaks[0];
  const max = symbology.breaks[symbology.breaks.length - 1];
  return (
    <div className="space-y-3">
      <div className="grid grid-cols-2 gap-3">
        <div className="space-y-2">
          <Label htmlFor="rasterMethod">{t("rasterSymbology.method")}</Label>
          <Select
            id="rasterMethod"
            value={symbology.method}
            onChange={(event) => onMethod(event.target.value as RasterClassificationMethod)}
          >
            {CLASSIFICATION_METHODS.map((option) => (
              <option key={option.value} value={option.value}>
                {t(option.labelKey)}
              </option>
            ))}
          </Select>
        </div>
        <div className="space-y-2">
          <Label htmlFor="rasterClasses">{t("rasterSymbology.classes")}</Label>
          <Select
            id="rasterClasses"
            value={String(symbology.classCount)}
            onChange={(event) => onClassCount(Number(event.target.value))}
          >
            {Array.from(
              { length: RASTER_MAX_CLASSES - RASTER_MIN_CLASSES + 1 },
              (_, index) => RASTER_MIN_CLASSES + index,
            ).map((count) => (
              <option key={count} value={count}>
                {count}
              </option>
            ))}
            {symbology.classCount > RASTER_MAX_CLASSES ? (
              // A categorical symbology applied from the Raster Attribute
              // Table can carry more classes than the authoring cap; show the
              // real count instead of letting the native select fall back to
              // the first option. Picking a listed count re-classifies.
              <option value={symbology.classCount}>{symbology.classCount}</option>
            ) : null}
          </Select>
        </div>
      </div>

      {symbology.method !== "manual" && (
        <div className="grid grid-cols-2 gap-3">
          <NumberField
            label={t("rasterSymbology.min")}
            value={min}
            step={Number.isFinite(max - min) ? (max - min) / 100 || 0.1 : 0.1}
            disabled={symbology.method === "quantile"}
            onCommit={(value) => onRange([value, max])}
          />
          <NumberField
            label={t("rasterSymbology.max")}
            value={max}
            step={Number.isFinite(max - min) ? (max - min) / 100 || 0.1 : 0.1}
            disabled={symbology.method === "quantile"}
            onCommit={(value) => onRange([min, value])}
          />
        </div>
      )}

      {symbology.method === "manual" && (
        <div className="space-y-2">
          <Label className="text-xs">{t("rasterSymbology.classEdges")}</Label>
          {symbology.breaks.map((edge, index) => (
            <NumberField
              key={index}
              label={`Edge ${index + 1}`}
              value={edge}
              step={0.1}
              onCommit={(value) => {
                const next = [...symbology.breaks];
                next[index] = value;
                onManualBreaks(next);
              }}
            />
          ))}
        </div>
      )}

      {symbology.method !== "manual" && !stats && (
        <p className="text-[10px] text-muted-foreground">Computing data range…</p>
      )}
    </div>
  );
}

function RescaleControls({
  rescale,
  onChange,
}: {
  rescale: [number, number][] | null;
  onChange: (rescale: [number, number][] | null) => void;
}) {
  const { t } = useTranslation();
  const range = rescale?.[0];
  // The rescale data model is all-or-nothing ([min, max] or null = auto), so
  // clearing either bound drops back to auto-stretch on both — a single bound
  // can't be pinned independently.
  return (
    <div className="grid grid-cols-2 gap-3">
      <NumberField
        label={t("rasterSymbology.min")}
        value={range?.[0] ?? ""}
        placeholder={t("rasterSymbology.autoPlaceholder")}
        step={0.1}
        onCommit={(value, empty) => {
          if (empty) return onChange(null);
          onChange([[value, range?.[1] ?? value]]);
        }}
      />
      <NumberField
        label={t("rasterSymbology.max")}
        value={range?.[1] ?? ""}
        placeholder={t("rasterSymbology.autoPlaceholder")}
        step={0.1}
        onCommit={(value, empty) => {
          if (empty) return onChange(null);
          onChange([[range?.[0] ?? value, value]]);
        }}
      />
    </div>
  );
}

function NodataControl({
  state,
  onChange,
}: {
  state: RasterStateRecord;
  onChange: (nodata: number | "auto" | "off") => void;
}) {
  const { t } = useTranslation();
  const mode =
    state.nodata === "off" ? "off" : typeof state.nodata === "number" ? "custom" : "auto";
  return (
    <div className="grid grid-cols-2 gap-3">
      <div className="space-y-2">
        <Label htmlFor="rasterNodata">{t("rasterSymbology.noData")}</Label>
        <Select
          id="rasterNodata"
          value={mode}
          onChange={(event) => {
            const value = event.target.value;
            if (value === "off") onChange("off");
            else if (value === "custom") onChange(0);
            else onChange("auto");
          }}
        >
          <option value="auto">{t("rasterSymbology.nodataAuto")}</option>
          <option value="off">{t("rasterSymbology.nodataOff")}</option>
          <option value="custom">{t("rasterSymbology.nodataCustom")}</option>
        </Select>
      </div>
      {mode === "custom" && (
        <NumberField
          label={t("rasterSymbology.value")}
          value={typeof state.nodata === "number" ? state.nodata : 0}
          step={1}
          onCommit={(value) => onChange(value)}
        />
      )}
    </div>
  );
}

/**
 * Free-text editor for a user-defined color ramp: a list of hex codes parsed
 * into the ramp's anchor colors. Edits are committed on blur, and only when
 * they yield a usable ramp (>= 2 valid colors) so the layer never lands in an
 * invalid state; an unusable draft is reverted to the last good list.
 *
 * @param props.colors - The current custom colors.
 * @param props.onCommit - Receives the parsed colors when the draft is valid.
 */
function CustomColorsField({
  colors,
  onCommit,
}: {
  colors: string[];
  onCommit: (colors: string[]) => void;
}) {
  const { t } = useTranslation();
  // `colors` is a fresh array each parent render (savedRasterSymbology rebuilds
  // it), so sync the draft off its content, not its reference -- otherwise an
  // unrelated re-render (e.g. band stats loading) would wipe what the user is
  // typing.
  const committed = colors.join(", ");
  const [draft, setDraft] = useState(committed);
  useEffect(() => {
    setDraft(committed);
  }, [committed]);
  const parsed = useMemo(() => parseHexColorList(draft), [draft]);
  const valid = parsed.length >= MIN_CUSTOM_COLORS;
  return (
    <div className="space-y-1">
      <Label htmlFor="rasterCustomColors" className="text-xs">
        {t("rasterSymbology.customColors")}
      </Label>
      <Textarea
        id="rasterCustomColors"
        rows={2}
        value={draft}
        placeholder="#440154, #21908c, #fde725"
        onChange={(event) => setDraft(event.target.value)}
        onBlur={() => {
          // Only commit a usable, actually-changed ramp; otherwise restore.
          if (valid && parsed.join(",") !== colors.join(",")) onCommit(parsed);
          else setDraft(committed);
        }}
      />
      <p className="text-[10px] text-muted-foreground">
        {valid
          ? t("rasterSymbology.customColorsValid", { count: parsed.length })
          : t("rasterSymbology.customColorsHint")}
      </p>
    </div>
  );
}

function NumberField({
  label,
  value,
  step,
  min,
  disabled,
  placeholder,
  onCommit,
}: {
  label: string;
  value: number | "";
  step: number;
  min?: number;
  disabled?: boolean;
  placeholder?: string;
  onCommit: (value: number, empty: boolean) => void;
}) {
  // Local draft so the user can clear / retype freely; committed on blur (one
  // store write / setRasterState per edit, instead of one per keystroke).
  const [draft, setDraft] = useState<string>(value === "" ? "" : String(value));
  useEffect(() => {
    setDraft(value === "" ? "" : String(value));
  }, [value]);
  const commitDraft = () => {
    if (draft.trim() === "") {
      onCommit(0, true);
      return;
    }
    const parsed = Number(draft);
    if (Number.isFinite(parsed)) onCommit(parsed, false);
  };
  return (
    <div className="space-y-2">
      <Label className="text-xs">{label}</Label>
      <Input
        type="number"
        inputMode="decimal"
        step={step}
        min={min}
        disabled={disabled}
        placeholder={placeholder}
        value={draft}
        onChange={(event) => setDraft(event.target.value)}
        onBlur={commitDraft}
        onKeyDown={(event) => {
          if (event.key === "Enter") event.currentTarget.blur();
        }}
      />
    </div>
  );
}
