import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { trackEvent } from '../lib/analytics';
import {
  SCHOOL_FILTER_NAME,
  createSchoolFilterKey,
  getDefaultSchoolFeatureName,
  getSchoolBackendFeatureName,
  getSchoolFilterKeyId,
  normalizeSchoolFilters,
} from '../lib/school-filter';
import {
  SPECIFIC_CRIMES_FILTER_NAME,
  createSpecificCrimeFilterKey,
  getDefaultSpecificCrimeFeatureName,
  getSpecificCrimeFeatureName,
  getSpecificCrimeFilterKeyId,
  normalizeSpecificCrimeFilters,
} from '../lib/crime-filter';
import {
  CRIME_SEVERITY_FILTER_NAMES,
  createCrimeSeverityFilterKey,
  getCrimeSeverityFeatureName,
  getCrimeSeverityFilterKeyId,
  getDefaultCrimeSeverityFeatureName,
  normalizeCrimeSeverityFilters,
  type CrimeSeverityFilterName,
} from '../lib/crime-severity-filter';
import {
  ELECTION_VOTE_SHARE_FILTER_NAME,
  createElectionVoteShareFilterKey,
  getDefaultElectionVoteShareFeatureName,
  getElectionVoteShareFeatureName,
  getElectionVoteShareFilterKeyId,
  normalizeElectionVoteShareFilters,
} from '../lib/election-filter';
import {
  ETHNICITIES_FILTER_NAME,
  createEthnicityFilterKey,
  getDefaultEthnicityFeatureName,
  getEthnicityFeatureName,
  getEthnicityFilterKeyId,
  normalizeEthnicityFilters,
} from '../lib/ethnicity-filter';
import {
  QUALIFICATIONS_FILTER_NAME,
  createQualificationFilterKey,
  getDefaultQualificationFeatureName,
  getQualificationFeatureName,
  getQualificationFilterKeyId,
  normalizeQualificationFilters,
} from '../lib/qualification-filter';
import {
  TENURE_FILTER_NAME,
  createTenureFilterKey,
  getDefaultTenureFeatureName,
  getTenureFeatureName,
  getTenureFilterKeyId,
  normalizeTenureFilters,
} from '../lib/tenure-filter';
import {
  POI_FILTER_NAMES,
  createPoiFilterKey,
  getDefaultPoiFilterFeatureName,
  getPoiDistanceFeatureName,
  getPoiDistanceFilterKeyId,
  normalizePoiDistanceFilters,
  usesFixedPoiDistanceScale,
  type PoiFilterName,
} from '../lib/poi-distance-filter';

interface UseFiltersOptions {
  initialFilters: FeatureFilters;
  features: FeatureMeta[];
  /** Max simultaneous filters for the current user; `null`/`undefined` = unlimited. */
  filterLimit?: number | null;
  /** Filter slots already consumed outside this hook (e.g. travel-time entries),
   *  which share the same cap. Subtracted from `filterLimit` when gating adds. */
  reservedFilterSlots?: number;
  /** When true, block adding any *new* filter outright (existing filters can still
   *  be tweaked/replaced). Used so a non-paying user on a shared link can view and
   *  adjust the shared set but not build on top of it. Triggers `onFilterLimitReached`. */
  lockAdds?: boolean;
  /** Called when an add is blocked because `filterLimit` is reached or `lockAdds` is set. */
  onFilterLimitReached?: () => void;
}

function normalizeFilters(filters: FeatureFilters): FeatureFilters {
  return normalizePoiDistanceFilters(
    normalizeTenureFilters(
      normalizeQualificationFilters(
        normalizeEthnicityFilters(
          normalizeElectionVoteShareFilters(
            normalizeCrimeSeverityFilters(
              normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
            )
          )
        )
      )
    )
  );
}

function getBackendFeatureName(name: string): string {
  return (
    getSchoolBackendFeatureName(name) ??
    getSpecificCrimeFeatureName(name) ??
    getCrimeSeverityFeatureName(name) ??
    getElectionVoteShareFeatureName(name) ??
    getEthnicityFeatureName(name) ??
    getQualificationFeatureName(name) ??
    getTenureFeatureName(name) ??
    getPoiDistanceFeatureName(name) ??
    name
  );
}

function dropUnknownFilters(filters: FeatureFilters, features: FeatureMeta[]): FeatureFilters {
  if (features.length === 0) return filters;

  const knownFeatures = new Set(features.map((feature) => feature.name));
  let changed = false;
  const next: FeatureFilters = {};

  for (const [name, value] of Object.entries(filters)) {
    if (knownFeatures.has(getBackendFeatureName(name))) {
      next[name] = value;
    } else {
      changed = true;
    }
  }

  return changed ? next : filters;
}

/** Count the *effective* filters a raw set resolves to: normalized (variant folding)
 *  and limited to features the backend knows. Mirrors what the hook actually applies
 *  and what the server counts, so a caller can tell whether a loaded set exceeds the
 *  cap without re-deriving the fold/unknown-drop logic (and without counting stale or
 *  unknown keys that would never reach the server). `features` empty → counts as-is. */
export function countEffectiveFilters(filters: FeatureFilters, features: FeatureMeta[]): number {
  return Object.keys(dropUnknownFilters(normalizeFilters(filters), features)).length;
}

/** Slice a filter set down to `budget` keys (keeping the first N), or return it
 *  unchanged (same reference) when within budget or uncapped. */
function clampToBudget(filters: FeatureFilters, budget: number | null): FeatureFilters {
  if (budget == null || Object.keys(filters).length <= budget) return filters;
  return Object.fromEntries(Object.entries(filters).slice(0, budget));
}

/** Shallow value-equality for two filter sets (same keys, same range/enum values).
 *  Lets effects skip a `setFilters` when nothing actually changed, avoiding a refetch. */
function filtersEqual(a: FeatureFilters, b: FeatureFilters): boolean {
  const aKeys = Object.keys(a);
  if (aKeys.length !== Object.keys(b).length) return false;
  for (const key of aKeys) {
    const av = a[key];
    const bv = b[key];
    if (!bv || av.length !== bv.length) return false;
    for (let i = 0; i < av.length; i++) {
      if (av[i] !== bv[i]) return false;
    }
  }
  return true;
}

function getNextNumericKeyId(
  filters: FeatureFilters,
  getId: (name: string) => string | null
): number {
  let max = -1;
  for (const name of Object.keys(filters)) {
    const id = getId(name);
    if (id == null) continue;
    const numeric = Number(id);
    if (Number.isInteger(numeric) && numeric >= 0) {
      max = Math.max(max, numeric);
    }
  }
  return max + 1;
}

export function useFilters({
  initialFilters,
  features,
  filterLimit,
  reservedFilterSlots = 0,
  lockAdds = false,
  onFilterLimitReached,
}: UseFiltersOptions) {
  const initialFiltersRef = useRef<FeatureFilters | null>(null);
  // The original normalized initial set, kept so the prune effect can re-derive the
  // clamped set from source once features load (see below). Captured before clamping.
  const originalNormalizedRef = useRef<FeatureFilters | null>(null);
  if (!initialFiltersRef.current) {
    // Clamp an incoming filter set (e.g. from a bookmarked or shared URL) to the
    // user's remaining budget — the cap minus slots already taken by travel-time
    // filters — so a non-paying user never starts above the cap, which the server
    // would reject (400), blanking the map on first load. Unknown keys are dropped
    // first WHEN the feature list is already loaded (in-app navigation); on a cold load
    // features arrive after this runs, so the prune effect below re-derives from source.
    const normalizedOriginal = normalizeFilters(initialFilters);
    originalNormalizedRef.current = normalizedOriginal;
    const budget = filterLimit != null ? Math.max(0, filterLimit - reservedFilterSlots) : null;
    initialFiltersRef.current = clampToBudget(dropUnknownFilters(normalizedOriginal, features), budget);
  }

  const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
  // Mirrors used by handleAddFilter's limit check so its identity stays stable.
  const filtersRef = useRef(filters);
  filtersRef.current = filters;
  const filterLimitRef = useRef(filterLimit);
  filterLimitRef.current = filterLimit;
  const reservedSlotsRef = useRef(reservedFilterSlots);
  reservedSlotsRef.current = reservedFilterSlots;
  const lockAddsRef = useRef(lockAdds);
  lockAddsRef.current = lockAdds;
  const onFilterLimitReachedRef = useRef(onFilterLimitReached);
  onFilterLimitReachedRef.current = onFilterLimitReached;
  const [activeFeature, setActiveFeature] = useState<string | null>(null);
  const [dragValue, setDragValue] = useState<[number, number] | null>(null);
  const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
  const pendingDragRef = useRef<string | null>(null);
  const dragActiveRef = useRef<string | null>(null);
  const dragValueRef = useRef<[number, number] | null>(null);
  const undoStackRef = useRef<FeatureFilters[]>([]);
  const schoolFilterIdRef = useRef(
    getNextNumericKeyId(initialFiltersRef.current!, getSchoolFilterKeyId)
  );
  const specificCrimeFilterIdRef = useRef(
    getNextNumericKeyId(initialFiltersRef.current!, getSpecificCrimeFilterKeyId)
  );
  const crimeSeverityFilterIdRef = useRef(
    getNextNumericKeyId(initialFiltersRef.current!, getCrimeSeverityFilterKeyId)
  );
  const electionVoteShareFilterIdRef = useRef(
    getNextNumericKeyId(initialFiltersRef.current!, getElectionVoteShareFilterKeyId)
  );
  const ethnicityFilterIdRef = useRef(
    getNextNumericKeyId(initialFiltersRef.current!, getEthnicityFilterKeyId)
  );
  const qualificationFilterIdRef = useRef(
    getNextNumericKeyId(initialFiltersRef.current!, getQualificationFilterKeyId)
  );
  const tenureFilterIdRef = useRef(
    getNextNumericKeyId(initialFiltersRef.current!, getTenureFilterKeyId)
  );
  const poiDistanceFilterIdRef = useRef(
    getNextNumericKeyId(initialFiltersRef.current!, getPoiDistanceFilterKeyId)
  );

  const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);

  const viewFeature = activeFeature || pinnedFeature;
  const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;

  const filterRange = useMemo((): [number, number] | null => {
    if (!viewFeature) return null;
    if (activeFeature && dragValue) return dragValue;
    const filterVal = filters[viewFeature];
    if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
    return null;
  }, [viewFeature, activeFeature, dragValue, filters]);

  const initialReconcileDoneRef = useRef(false);
  useEffect(() => {
    if (features.length === 0) return;

    const knownFeatures = new Set(features.map((feature) => feature.name));
    if (!initialReconcileDoneRef.current) {
      initialReconcileDoneRef.current = true;
      // First time the feature list is known: re-derive the clamped set from the
      // ORIGINAL filters. On a cold load the init-clamp ran before unknown keys could be
      // dropped, so its blind slice may have kept stale keys over genuine ones;
      // recomputing from source recovers any real filters the slice discarded. Skip the
      // setFilters when nothing changed (the common case) so it doesn't trigger a refetch.
      const budget =
        filterLimitRef.current != null
          ? Math.max(0, filterLimitRef.current - reservedSlotsRef.current)
          : null;
      const reconciled = clampToBudget(
        dropUnknownFilters(originalNormalizedRef.current ?? {}, features),
        budget
      );
      setFilters((prev) => (filtersEqual(prev, reconciled) ? prev : reconciled));
    } else {
      setFilters((prev) => dropUnknownFilters(prev, features));
    }
    setPinnedFeature((prev) => {
      if (!prev) return prev;
      return knownFeatures.has(getBackendFeatureName(prev)) ? prev : null;
    });
    setActiveFeature((prev) => {
      if (!prev) return prev;
      return knownFeatures.has(getBackendFeatureName(prev)) ? prev : null;
    });
  }, [features]);

  // If the cap tightens (the user logs out, or a session is invalidated server-side and
  // useAuth drops the user), trim the current filters down to the new budget so the next
  // data request doesn't exceed the server cap and blank the map. Widening is a no-op,
  // and clampToBudget returns the same reference when already within budget (no refetch).
  useEffect(() => {
    if (filterLimit == null) return;
    const budget = Math.max(0, filterLimit - reservedSlotsRef.current);
    setFilters((prev) => clampToBudget(prev, budget));
  }, [filterLimit]);

  const handleAddFilter = useCallback(
    (name: string) => {
      const meta = features.find((f) => f.name === name);
      if (
        name !== SCHOOL_FILTER_NAME &&
        name !== SPECIFIC_CRIMES_FILTER_NAME &&
        !CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName) &&
        name !== ELECTION_VOTE_SHARE_FILTER_NAME &&
        name !== ETHNICITIES_FILTER_NAME &&
        name !== QUALIFICATIONS_FILTER_NAME &&
        name !== TENURE_FILTER_NAME &&
        !POI_FILTER_NAMES.includes(name as PoiFilterName) &&
        !meta
      ) {
        return;
      }

      // Non-paying users are capped at `filterLimit` simultaneous filters, with
      // travel-time entries (reservedFilterSlots) counting toward the same cap.
      // Re-applying an existing regular feature replaces it (no new key), so it's
      // always allowed; the special/generated filters always add a new key.
      // `lockAdds` (a non-paying user on a shared link) blocks every new key
      // regardless of count — they can adjust the shared set but not build on it.
      const limit = filterLimitRef.current;
      if (limit != null || lockAddsRef.current) {
        const current = filtersRef.current;
        const addsNewKey =
          name === SCHOOL_FILTER_NAME ||
          name === SPECIFIC_CRIMES_FILTER_NAME ||
          CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName) ||
          name === ELECTION_VOTE_SHARE_FILTER_NAME ||
          name === ETHNICITIES_FILTER_NAME ||
          name === QUALIFICATIONS_FILTER_NAME ||
          name === TENURE_FILTER_NAME ||
          POI_FILTER_NAMES.includes(name as PoiFilterName) ||
          !(name in current);
        const overLimit =
          limit != null && Object.keys(current).length + reservedSlotsRef.current >= limit;
        if (addsNewKey && (lockAddsRef.current || overLimit)) {
          onFilterLimitReachedRef.current?.();
          return;
        }
      }

      trackEvent('Filter Add', { feature: name });
      setFilters((prev) => {
        undoStackRef.current.push(prev);
        if (undoStackRef.current.length > 50) undoStackRef.current.shift();
        if (name === SCHOOL_FILTER_NAME) {
          const schoolKey = createSchoolFilterKey('primary', 'good', schoolFilterIdRef.current++);
          const defaultSchoolFeatureName = getDefaultSchoolFeatureName(features);
          const defaultSchoolFeature = defaultSchoolFeatureName
            ? features.find((feature) => feature.name === defaultSchoolFeatureName)
            : undefined;
          return {
            ...prev,
            [schoolKey]: [
              defaultSchoolFeature?.histogram?.min ?? defaultSchoolFeature?.min ?? 0,
              defaultSchoolFeature?.histogram?.max ?? defaultSchoolFeature?.max ?? 10,
            ],
          };
        }
        if (name === SPECIFIC_CRIMES_FILTER_NAME) {
          const defaultCrimeFeatureName = getDefaultSpecificCrimeFeatureName(features);
          const defaultCrimeFeature = defaultCrimeFeatureName
            ? features.find((feature) => feature.name === defaultCrimeFeatureName)
            : undefined;
          if (!defaultCrimeFeatureName) return prev;

          return {
            ...prev,
            [createSpecificCrimeFilterKey(
              defaultCrimeFeatureName,
              specificCrimeFilterIdRef.current++
            )]: [
              defaultCrimeFeature?.histogram?.min ?? defaultCrimeFeature?.min ?? 0,
              defaultCrimeFeature?.histogram?.max ?? defaultCrimeFeature?.max ?? 100,
            ],
          };
        }
        if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) {
          const severityFilterName = name as CrimeSeverityFilterName;
          const defaultSeverityFeatureName = getDefaultCrimeSeverityFeatureName(
            features,
            severityFilterName
          );
          const defaultSeverityFeature = defaultSeverityFeatureName
            ? features.find((feature) => feature.name === defaultSeverityFeatureName)
            : undefined;
          if (!defaultSeverityFeatureName) return prev;

          return {
            ...prev,
            [createCrimeSeverityFilterKey(
              severityFilterName,
              defaultSeverityFeatureName,
              crimeSeverityFilterIdRef.current++
            )]: [
              defaultSeverityFeature?.histogram?.min ?? defaultSeverityFeature?.min ?? 0,
              defaultSeverityFeature?.histogram?.max ?? defaultSeverityFeature?.max ?? 100,
            ],
          };
        }
        if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
          const defaultVoteShareFeatureName = getDefaultElectionVoteShareFeatureName(features);
          const defaultVoteShareFeature = defaultVoteShareFeatureName
            ? features.find((feature) => feature.name === defaultVoteShareFeatureName)
            : undefined;
          if (!defaultVoteShareFeatureName) return prev;

          return {
            ...prev,
            [createElectionVoteShareFilterKey(
              defaultVoteShareFeatureName,
              electionVoteShareFilterIdRef.current++
            )]: [
              defaultVoteShareFeature?.histogram?.min ?? defaultVoteShareFeature?.min ?? 0,
              defaultVoteShareFeature?.histogram?.max ?? defaultVoteShareFeature?.max ?? 100,
            ],
          };
        }
        if (name === ETHNICITIES_FILTER_NAME) {
          const defaultEthnicityFeatureName = getDefaultEthnicityFeatureName(features);
          const defaultEthnicityFeature = defaultEthnicityFeatureName
            ? features.find((feature) => feature.name === defaultEthnicityFeatureName)
            : undefined;
          if (!defaultEthnicityFeatureName) return prev;

          return {
            ...prev,
            [createEthnicityFilterKey(defaultEthnicityFeatureName, ethnicityFilterIdRef.current++)]:
              [
                defaultEthnicityFeature?.histogram?.min ?? defaultEthnicityFeature?.min ?? 0,
                defaultEthnicityFeature?.histogram?.max ?? defaultEthnicityFeature?.max ?? 100,
              ],
          };
        }
        if (name === QUALIFICATIONS_FILTER_NAME) {
          const defaultQualificationFeatureName = getDefaultQualificationFeatureName(features);
          const defaultQualificationFeature = defaultQualificationFeatureName
            ? features.find((feature) => feature.name === defaultQualificationFeatureName)
            : undefined;
          if (!defaultQualificationFeatureName) return prev;

          return {
            ...prev,
            [createQualificationFilterKey(
              defaultQualificationFeatureName,
              qualificationFilterIdRef.current++
            )]: [
              defaultQualificationFeature?.histogram?.min ?? defaultQualificationFeature?.min ?? 0,
              defaultQualificationFeature?.histogram?.max ??
                defaultQualificationFeature?.max ??
                100,
            ],
          };
        }
        if (name === TENURE_FILTER_NAME) {
          const defaultTenureFeatureName = getDefaultTenureFeatureName(features);
          const defaultTenureFeature = defaultTenureFeatureName
            ? features.find((feature) => feature.name === defaultTenureFeatureName)
            : undefined;
          if (!defaultTenureFeatureName) return prev;

          return {
            ...prev,
            [createTenureFilterKey(defaultTenureFeatureName, tenureFilterIdRef.current++)]: [
              defaultTenureFeature?.histogram?.min ?? defaultTenureFeature?.min ?? 0,
              defaultTenureFeature?.histogram?.max ?? defaultTenureFeature?.max ?? 100,
            ],
          };
        }
        if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
          const poiFilterName = name as PoiFilterName;
          const defaultPoiFeatureName = getDefaultPoiFilterFeatureName(features, poiFilterName);
          const defaultPoiFeature = defaultPoiFeatureName
            ? features.find((feature) => feature.name === defaultPoiFeatureName)
            : undefined;
          if (!defaultPoiFeatureName) return prev;
          const fixedDistanceScale = usesFixedPoiDistanceScale(defaultPoiFeature);

          return {
            ...prev,
            [createPoiFilterKey(
              poiFilterName,
              defaultPoiFeatureName,
              poiDistanceFilterIdRef.current++
            )]: [
              fixedDistanceScale
                ? (defaultPoiFeature?.min ?? 0)
                : (defaultPoiFeature?.histogram?.min ?? defaultPoiFeature?.min ?? 0),
              fixedDistanceScale
                ? (defaultPoiFeature?.max ?? 5)
                : (defaultPoiFeature?.histogram?.max ?? defaultPoiFeature?.max ?? 5),
            ],
          };
        }
        if (!meta) return prev;
        if (meta.type === 'enum' && meta.values) {
          return { ...prev, [name]: [...meta.values!] };
        } else if (meta.type === 'numeric' && meta.histogram) {
          return { ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] };
        } else if (meta.min != null && meta.max != null) {
          return { ...prev, [name]: [meta.min!, meta.max!] };
        }
        return prev;
      });
    },
    [features]
  );

  const handleUndo = useCallback(() => {
    const prev = undoStackRef.current.pop();
    if (prev) setFilters(prev);
  }, []);

  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
        const target = e.target as HTMLElement;
        if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
          return;
        e.preventDefault();
        handleUndo();
      }
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [handleUndo]);

  const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
    setFilters((prev) => {
      if (Array.isArray(value) && value.length === 0) {
        const next = { ...prev };
        delete next[name];
        return normalizeFilters(next);
      }

      const schoolKeyId = getSchoolFilterKeyId(name);
      if (schoolKeyId != null) {
        let replaced = false;
        const next: FeatureFilters = {};
        for (const [existingName, existingValue] of Object.entries(prev)) {
          if (getSchoolFilterKeyId(existingName) === schoolKeyId) {
            if (!replaced) {
              next[name] = value;
              replaced = true;
            }
            continue;
          }
          next[existingName] = existingValue;
        }
        if (replaced) return normalizeFilters(next);
      }

      const specificCrimeKeyId = getSpecificCrimeFilterKeyId(name);
      if (specificCrimeKeyId != null) {
        let replaced = false;
        const next: FeatureFilters = {};
        for (const [existingName, existingValue] of Object.entries(prev)) {
          if (getSpecificCrimeFilterKeyId(existingName) === specificCrimeKeyId) {
            if (!replaced) {
              next[name] = value;
              replaced = true;
            }
            continue;
          }
          next[existingName] = existingValue;
        }
        if (replaced) return normalizeFilters(next);
      }

      const crimeSeverityKeyId = getCrimeSeverityFilterKeyId(name);
      if (crimeSeverityKeyId != null) {
        let replaced = false;
        const next: FeatureFilters = {};
        for (const [existingName, existingValue] of Object.entries(prev)) {
          if (getCrimeSeverityFilterKeyId(existingName) === crimeSeverityKeyId) {
            if (!replaced) {
              next[name] = value;
              replaced = true;
            }
            continue;
          }
          next[existingName] = existingValue;
        }
        if (replaced) return normalizeFilters(next);
      }

      const ethnicityKeyId = getEthnicityFilterKeyId(name);
      if (ethnicityKeyId != null) {
        let replaced = false;
        const next: FeatureFilters = {};
        for (const [existingName, existingValue] of Object.entries(prev)) {
          if (getEthnicityFilterKeyId(existingName) === ethnicityKeyId) {
            if (!replaced) {
              next[name] = value;
              replaced = true;
            }
            continue;
          }
          next[existingName] = existingValue;
        }
        if (replaced) return normalizeFilters(next);
      }

      const qualificationKeyId = getQualificationFilterKeyId(name);
      if (qualificationKeyId != null) {
        let replaced = false;
        const next: FeatureFilters = {};
        for (const [existingName, existingValue] of Object.entries(prev)) {
          if (getQualificationFilterKeyId(existingName) === qualificationKeyId) {
            if (!replaced) {
              next[name] = value;
              replaced = true;
            }
            continue;
          }
          next[existingName] = existingValue;
        }
        if (replaced) return normalizeFilters(next);
      }

      const tenureKeyId = getTenureFilterKeyId(name);
      if (tenureKeyId != null) {
        let replaced = false;
        const next: FeatureFilters = {};
        for (const [existingName, existingValue] of Object.entries(prev)) {
          if (getTenureFilterKeyId(existingName) === tenureKeyId) {
            if (!replaced) {
              next[name] = value;
              replaced = true;
            }
            continue;
          }
          next[existingName] = existingValue;
        }
        if (replaced) return normalizeFilters(next);
      }

      const electionVoteShareKeyId = getElectionVoteShareFilterKeyId(name);
      if (electionVoteShareKeyId != null) {
        let replaced = false;
        const next: FeatureFilters = {};
        for (const [existingName, existingValue] of Object.entries(prev)) {
          if (getElectionVoteShareFilterKeyId(existingName) === electionVoteShareKeyId) {
            if (!replaced) {
              next[name] = value;
              replaced = true;
            }
            continue;
          }
          next[existingName] = existingValue;
        }
        if (replaced) return normalizeFilters(next);
      }

      const poiDistanceKeyId = getPoiDistanceFilterKeyId(name);
      if (poiDistanceKeyId != null) {
        let replaced = false;
        const next: FeatureFilters = {};
        for (const [existingName, existingValue] of Object.entries(prev)) {
          if (getPoiDistanceFilterKeyId(existingName) === poiDistanceKeyId) {
            if (!replaced) {
              next[name] = value;
              replaced = true;
            }
            continue;
          }
          next[existingName] = existingValue;
        }
        if (replaced) return normalizeFilters(next);
      }

      return normalizeFilters({ ...prev, [name]: value });
    });
  }, []);

  const handleRemoveFilter = useCallback((name: string) => {
    trackEvent('Filter Remove', { feature: name });
    setFilters((prev) => {
      const next = { ...prev };
      delete next[name];
      return next;
    });
    setPinnedFeature((prev) => (prev === name ? null : prev));
  }, []);

  const handleDragStart = useCallback(
    (name: string, initialValue?: [number, number]) => {
      const meta = features.find((f) => f.name === name);
      if (meta?.type === 'enum') return;
      pendingDragRef.current = name;
      setDragValue(initialValue ?? null);
      dragValueRef.current = initialValue ?? null;
      setActiveFeature(name);
    },
    [features]
  );

  const handleDragChange = useCallback((value: [number, number]) => {
    if (pendingDragRef.current) {
      setActiveFeature(pendingDragRef.current);
      dragActiveRef.current = pendingDragRef.current;
      pendingDragRef.current = null;
    }
    setDragValue(value);
    dragValueRef.current = value;
  }, []);

  const handleDragEnd = useCallback(() => {
    if (pendingDragRef.current) {
      // Click without drag — no filter value was changed, just clear preview state.
      pendingDragRef.current = null;
      setActiveFeature(null);
      setDragValue(null);
      dragValueRef.current = null;
      return;
    }
    const af = dragActiveRef.current;
    const dv = dragValueRef.current;
    if (af && dv) {
      setFilters((prev) => normalizeFilters({ ...prev, [af]: dv }));
    }
    setActiveFeature(null);
    setDragValue(null);
    dragActiveRef.current = null;
    dragValueRef.current = null;
  }, []);

  /** End drag without committing to filters — caller handles the commit (e.g. travel time). */
  const handleDragEndNoCommit = useCallback((): [number, number] | null => {
    if (pendingDragRef.current) {
      pendingDragRef.current = null;
      setActiveFeature(null);
      setDragValue(null);
      dragValueRef.current = null;
      return null;
    }
    const dv = dragValueRef.current;
    setActiveFeature(null);
    setDragValue(null);
    dragActiveRef.current = null;
    dragValueRef.current = null;
    return dv;
  }, []);

  const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
    // Enforce the demo cap on bulk sets too (e.g. AI filters, which bypass the
    // per-add path). Without this, an unlicensed user could end up with >5 filters,
    // which the server then rejects (400), breaking the map.
    let next = newFilters;
    // On a shared link (lockAdds), a non-paying user may adjust the shared filters
    // but not introduce new keys — enforce that on bulk sets too, not just in the
    // per-add path, so the AI/bulk path can't be used to build a fresh set on a
    // shared link. Keep only keys already present; signal if any were dropped.
    if (lockAddsRef.current) {
      const current = filtersRef.current;
      const restricted = Object.fromEntries(
        Object.entries(next).filter(([name]) => name in current)
      );
      if (Object.keys(restricted).length !== Object.keys(next).length) {
        onFilterLimitReachedRef.current?.();
      }
      next = restricted;
    }
    const limit = filterLimitRef.current;
    if (limit != null && Object.keys(next).length > limit) {
      next = Object.fromEntries(Object.entries(next).slice(0, limit));
      onFilterLimitReachedRef.current?.();
    }
    setFilters(normalizeFilters(next));
    setActiveFeature(null);
    setDragValue(null);
    setPinnedFeature(null);
    pendingDragRef.current = null;
    dragActiveRef.current = null;
    dragValueRef.current = null;
  }, []);

  const handleTogglePin = useCallback((name: string) => {
    trackEvent('Filter Pin', { feature: name });
    setPinnedFeature((prev) => (prev === name ? null : name));
  }, []);

  const handleSetPin = useCallback((name: string) => {
    setPinnedFeature(name);
  }, []);

  const handleCancelPin = useCallback(() => {
    setPinnedFeature(null);
  }, []);

  return {
    filters,
    activeFeature,
    dragValue,
    pinnedFeature,
    enabledFeatures,
    viewFeature,
    viewSource,
    filterRange,
    handleAddFilter,
    handleFilterChange,
    handleRemoveFilter,
    handleSetFilters,
    handleDragStart,
    handleDragChange,
    handleDragEnd,
    handleDragEndNoCommit,
    handleTogglePin,
    handleSetPin,
    handleCancelPin,
  };
}
