"use client";

// 01 ПАРАМЕТРЫ КВАРТИРЫ — left card of the /trade-in/v2 "МЕРА Оценка" port.
// Faithful markup port (МЕРА Оценка.dc.html lines 146-240). Wire phase: this is
// now a CONTROLLED form. Each field is local useState producing a
// TradeInEstimateInput that is handed to onSubmit; the visual markup / layout /
// styles are UNCHANGED — only the data plumbing differs (display <div>s became
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
// РАДИУС is now wired (radius_m on submit + the real map circle below scales
// with it); the CRM dropdown still has no backend → kept visually but disabled.
// Hover/active + @keyframes live in a pp-prefixed local <style>.
//
// Fix (audit): the 01 map was a decorative SVG (grid + fake streets + radius
// rings) with no real coordinates behind it. It is now a real Leaflet + OSM
// basemap, ported from the same CDN-loader pattern as ./SourcesMap.tsx /
// ../MapPicker.tsx (copied rather than shared — same self-contained-port
// convention as SourcesMap.tsx, no npm Leaflet dep).
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type Dispatch,
  type KeyboardEvent as ReactKeyboardEvent,
  type SetStateAction,
} from "react";
import { tokens } from "./tokens";
import { dropdownOptions } from "./fixtures";
import {
  HOUSE_TYPE_FROM_RU,
  HOUSE_TYPE_RU,
  REPAIR_FROM_RU,
  REPAIR_RU,
  type MapMarker,
} from "./mappers";
import { useGeocodeSuggest } from "@/lib/trade-in-api";
import type {
  GeocodeSuggestion,
  HouseType,
  RepairState,
  TradeInEstimateInput,
} from "@/types/trade-in";

type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;

// Russian plural picker (one / few / many) for SR announcements.
function pluralRu(n: number, one: string, few: string, many: string): string {
  const m10 = n % 10;
  const m100 = n % 100;
  if (m10 === 1 && m100 !== 11) return one;
  if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
  return many;
}

// Visually-hidden style for the polite suggestion-count live region.
const srOnly: CSSProperties = {
  position: "absolute",
  width: 1,
  height: 1,
  padding: 0,
  margin: -1,
  overflow: "hidden",
  clip: "rect(0 0 0 0)",
  whiteSpace: "nowrap",
  border: 0,
};

interface DdProps {
  open: boolean;
  onToggle: () => void;
  value: string;
  options: string[];
  onSelect: (v: string) => void;
  mono: boolean;
  triggerFontSize: number;
  optionFontSize: number;
  variant?: "solid" | "dashed";
  openUp?: boolean;
  triggerHeight?: number;
  triggerBg?: string;
  triggerColor?: string;
  caretColor?: string;
  disabled?: boolean;
  ariaLabel?: string;
}

// Shared combobox keydown logic for the HUD dropdowns (the <Dd> widget + the
// inline РАДИУС copy). aria-activedescendant pattern: focus STAYS on the trigger
// button; arrows move a virtual `activeIdx` highlight, Enter/Space commit it,
// Escape/Tab close. Navigation keys preventDefault to stop page scroll.
function comboKeyDown(
  e: ReactKeyboardEvent<HTMLButtonElement>,
  open: boolean,
  options: string[],
  activeIdx: number,
  setActiveIdx: Dispatch<SetStateAction<number>>,
  onToggle: () => void,
  onSelect: (v: string) => void,
) {
  if (!open) {
    if (
      e.key === "ArrowDown" ||
      e.key === "ArrowUp" ||
      e.key === "Enter" ||
      e.key === " "
    ) {
      e.preventDefault();
      onToggle();
    }
    return;
  }
  switch (e.key) {
    case "ArrowDown":
      e.preventDefault();
      setActiveIdx((i) => Math.min(options.length - 1, i + 1));
      break;
    case "ArrowUp":
      e.preventDefault();
      setActiveIdx((i) => Math.max(0, i - 1));
      break;
    case "Home":
      e.preventDefault();
      setActiveIdx(0);
      break;
    case "End":
      e.preventDefault();
      setActiveIdx(options.length - 1);
      break;
    case "Enter":
    case " ":
      e.preventDefault();
      if (activeIdx >= 0 && activeIdx < options.length) {
        onSelect(options[activeIdx]);
      }
      break;
    case "Escape":
      e.preventDefault();
      onToggle();
      break;
    case "Tab":
      // do NOT preventDefault — let focus move naturally; just close the list
      onToggle();
      break;
    default:
      break;
  }
}

// ── 01 map — Leaflet + OSM (real coordinates) ───────────────────────────────
// Same CDN loader pattern/version/SRI as ./SourcesMap.tsx and ../MapPicker.tsx
// (duplicated on purpose — each v2 file is a self-contained port, no shared
// runtime module, no npm Leaflet dep).
const LEAFLET_VER = "1.9.4";
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
const MAP_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const MAP_ATTRIBUTION = "© OpenStreetMap";
const DEFAULT_MAP_ZOOM = 16;
const MIN_MAP_ZOOM = 11;
const MAX_MAP_ZOOM = 19;

/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror SourcesMap.tsx) */
function loadLeaflet(): Promise<any> {
  return new Promise((resolve, reject) => {
    const w = window as any;
    if (w.L) {
      resolve(w.L);
      return;
    }
    if (!document.querySelector(`link[data-leaflet]`)) {
      const link = document.createElement("link");
      link.rel = "stylesheet";
      link.href = LEAFLET_CSS;
      link.integrity = LEAFLET_CSS_SRI;
      link.crossOrigin = "anonymous";
      link.setAttribute("data-leaflet", "1");
      document.head.appendChild(link);
    }
    const existing = document.querySelector<HTMLScriptElement>(`script[data-leaflet]`);
    if (existing) {
      existing.addEventListener("load", () => resolve(w.L));
      existing.addEventListener("error", () => reject(new Error("leaflet load failed")));
      return;
    }
    const script = document.createElement("script");
    script.src = LEAFLET_JS;
    script.integrity = LEAFLET_JS_SRI;
    script.crossOrigin = "anonymous";
    script.setAttribute("data-leaflet", "1");
    script.onload = () => resolve(w.L);
    script.onerror = () => reject(new Error("leaflet load failed"));
    document.body.appendChild(script);
  });
}

/** Экранирование перед вставкой в raw-HTML Leaflet divIcon (адрес — ввод
 * пользователя). Тот же паттерн, что и esc() в SourcesMap.tsx. */
function escapeMapHtml(s: string): string {
  return s.replace(/[&<>"']/g, (c) =>
    c === "&"
      ? "&amp;"
      : c === "<"
        ? "&lt;"
        : c === ">"
          ? "&gt;"
          : c === '"'
            ? "&quot;"
            : "&#39;",
  );
}

/** Пин квартиры-предмета оценки — divIcon 1:1 повторяет прежний SVG-бабл
 * (акцентная рамка, точка, адрес + площадь, треугольник-указатель), только
 * теперь висит на реальных координатах реальной карты. */
function buildSubjectIcon(
  L: any,
  addressLabel: string,
  areaLabel: string | null,
): any {
  const html = `
    <div style="position:relative;width:220px;height:56px;pointer-events:none">
      <div style="position:absolute;left:50%;bottom:9px;transform:translateX(-50%);display:flex;align-items:center;gap:6px;background:${tokens.surface.w85};border:1px solid ${tokens.accent};border-radius:4px;padding:4px 8px;white-space:nowrap;box-shadow:0 3px 10px rgba(46,139,255,.25);font-family:${tokens.font.sans}">
        <span style="width:7px;height:7px;border-radius:50%;background:${tokens.accent};flex:0 0 auto"></span>
        <span style="display:flex;flex-direction:column;gap:1px">
          <span style="font-size:11px;font-weight:600;color:${tokens.ink}">${escapeMapHtml(addressLabel)}</span>
          ${
            areaLabel
              ? `<span style="font-size:9px;color:${tokens.muted}">${escapeMapHtml(areaLabel)}</span>`
              : ""
          }
        </span>
      </div>
      <div style="position:absolute;left:50%;bottom:0;transform:translateX(-50%);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:7px solid ${tokens.accent}"></div>
    </div>`;
  return L.divIcon({ html, className: "", iconSize: [220, 56], iconAnchor: [110, 56] });
}

function Dd({
  open,
  onToggle,
  value,
  options,
  onSelect,
  mono,
  triggerFontSize,
  optionFontSize,
  variant = "solid",
  openUp = false,
  triggerHeight = 33,
  triggerBg = tokens.surface.w62,
  triggerColor = tokens.ink,
  caretColor = tokens.muted2,
  disabled = false,
  ariaLabel,
}: DdProps) {
  const listId = useId();
  const optId = (i: number) => `${listId}-opt-${i}`;
  const [activeIdx, setActiveIdx] = useState(-1);

  // On open, point the virtual highlight at the current selection (fallback to
  // the first option); reset when closed. Pure UI sync — not a data effect.
  useEffect(() => {
    if (open) {
      const idx = options.indexOf(value);
      setActiveIdx(idx >= 0 ? idx : 0);
    } else {
      setActiveIdx(-1);
    }
  }, [open, value, options]);

  const panelStyle: CSSProperties = {
    position: "absolute",
    left: 0,
    right: 0,
    background: tokens.surface.w98,
    border: `1px solid ${tokens.line}`,
    borderRadius: 6,
    zIndex: 30,
    padding: 3,
    ...(openUp
      ? {
          bottom: "calc(100% + 4px)",
          boxShadow: "0 -10px 26px rgba(40,80,130,.18)",
        }
      : {
          top: "calc(100% + 4px)",
          boxShadow: "0 10px 26px rgba(40,80,130,.18)",
        }),
  };

  return (
    <div style={{ position: "relative" }}>
      <button
        type="button"
        className={
          disabled
            ? "pp-dd-trigger-disabled"
            : variant === "dashed"
              ? "pp-dd-trigger-dashed"
              : "pp-dd-trigger"
        }
        onClick={!disabled ? onToggle : undefined}
        onKeyDown={
          disabled
            ? undefined
            : (e) =>
                comboKeyDown(
                  e,
                  open,
                  options,
                  activeIdx,
                  setActiveIdx,
                  onToggle,
                  onSelect,
                )
        }
        role="combobox"
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={listId}
        aria-activedescendant={
          open && activeIdx >= 0 ? optId(activeIdx) : undefined
        }
        aria-label={ariaLabel}
        aria-disabled={disabled || undefined}
        disabled={disabled}
        style={{
          height: triggerHeight,
          background: triggerBg,
          borderRadius: 6,
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          padding: "0 13px",
          fontFamily: mono ? tokens.font.mono : tokens.font.sans,
          fontSize: triggerFontSize,
          color: triggerColor,
          cursor: disabled ? "default" : "pointer",
          width: "100%",
          textAlign: "left",
        }}
      >
        {value}
        {!disabled && (
          <span
            style={{
              color: caretColor,
              fontSize: 9,
              fontFamily: tokens.font.sans,
            }}
          >
            ▼
          </span>
        )}
      </button>
      {open && (
        <div
          style={panelStyle}
          role="listbox"
          id={listId}
          aria-label={ariaLabel}
          tabIndex={-1}
        >
          {options.map((o, i) => (
            <div
              key={o}
              className="pp-dd-opt"
              role="option"
              id={optId(i)}
              aria-selected={o === value}
              onClick={() => onSelect(o)}
              onMouseEnter={() => setActiveIdx(i)}
              style={{
                padding: "7px 10px",
                fontSize: optionFontSize,
                fontFamily: mono ? tokens.font.mono : tokens.font.sans,
                borderRadius: 4,
                cursor: "pointer",
                color: o === value ? tokens.accent : tokens.ink,
                fontWeight: o === value ? 600 : 400,
                background: i === activeIdx ? "rgba(46,139,255,.1)" : undefined,
              }}
            >
              {o}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

const styles = `
@keyframes pp-pulsedot{0%,100%{opacity:.45}50%{opacity:1}}
.pp-dot{animation:pp-pulsedot 2.6s ease-in-out infinite}
.pp-dd-trigger{border:1px solid ${tokens.line};transition:border-color .15s}
.pp-dd-trigger:hover{border-color:${tokens.accent}}
.pp-dd-trigger-dashed{border:1px dashed ${tokens.line};transition:border-color .15s}
.pp-dd-trigger-dashed:hover{border-color:${tokens.accent}}
.pp-dd-trigger-disabled{border:1px dashed ${tokens.line}}
.pp-dd-opt{transition:background .15s}
.pp-dd-opt:hover{background:rgba(46,139,255,.1)}
.pp-input:focus-visible{outline:none;border-color:${tokens.accentDeep};box-shadow:0 0 0 2px ${tokens.accentDeep}}
.pp-eval-btn{background:linear-gradient(90deg,${tokens.accentDeep},${tokens.accent});box-shadow:0 2px 10px rgba(46,139,255,.25);transition:all .18s}
.pp-eval-btn:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)}
.pp-eval-btn:active{transform:translateY(1px)}
.pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
.pp-eval-btn-secondary{background:transparent;transition:all .18s}
.pp-eval-btn-secondary:hover{background:rgba(46,139,255,.08)}
.pp-eval-btn-secondary:active{transform:translateY(1px)}
.pp-eval-btn-secondary:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
`;

// Primary field captions ("АДРЕС", "ПЛОЩАДЬ", "КОМНАТ", …) — the labels the user
// reads first. C1 hierarchy: bumped from muted2 to body2 (#46586c, ~6:1) so they
// read as primary against the lighter units/hints which stay on muted (optHint).
const hintLabel: CSSProperties = {
  fontSize: 9.5,
  letterSpacing: 1.5,
  color: tokens.body2,
  marginBottom: 3,
};

const optHint: CSSProperties = {
  fontSize: 9,
  color: tokens.muted4,
};

const fieldRow: CSSProperties = {
  display: "grid",
  gridTemplateColumns: "1fr 1fr",
  gap: 12,
};

// Numeric text input — visually identical to the former display <div> (textField)
// but as a real <input>: inputs do NOT inherit font-family, so it is set
// explicitly; outline removed to keep the HUD look (focus ring would break it).
const inputField: CSSProperties = {
  height: 33,
  width: "100%",
  boxSizing: "border-box",
  background: tokens.surface.w62,
  border: `1px solid ${tokens.line}`,
  borderRadius: 6,
  padding: "0 13px",
  fontFamily: tokens.font.mono,
  fontSize: 14,
  color: tokens.ink,
  outline: "none",
};

// Address input (sans, room on the right for the decorative search glyph).
const addressField: CSSProperties = {
  height: 33,
  width: "100%",
  boxSizing: "border-box",
  background: tokens.surface.w62,
  border: `1px solid ${tokens.line}`,
  borderRadius: 6,
  padding: "0 38px 0 13px",
  fontFamily: tokens.font.sans,
  fontSize: 13,
  color: tokens.ink,
  outline: "none",
};

// Autocomplete dropdown — mirrors the <Dd> HUD panel (surface.w98 + soft blue
// shadow). Absolutely positioned under the address input; scrolls past ~6 rows.
const suggestPanel: CSSProperties = {
  position: "absolute",
  left: 0,
  right: 0,
  top: "calc(100% + 4px)",
  background: tokens.surface.w98,
  border: `1px solid ${tokens.line}`,
  borderRadius: 6,
  zIndex: 30,
  padding: 3,
  maxHeight: 196,
  overflowY: "auto",
  boxShadow: "0 10px 26px rgba(40,80,130,.18)",
};

const suggestOpt: CSSProperties = {
  padding: "7px 10px",
  borderRadius: 4,
  cursor: "pointer",
};

const suggestOptMain: CSSProperties = {
  fontFamily: tokens.font.sans,
  fontSize: 12.5,
  color: tokens.ink,
};

const suggestOptSub: CSSProperties = {
  fontFamily: tokens.font.sans,
  fontSize: 10,
  color: tokens.muted2,
  marginTop: 1,
};

const suggestNote: CSSProperties = {
  padding: "8px 10px",
  fontFamily: tokens.font.sans,
  fontSize: 11,
  color: tokens.muted2,
};

// Inline validation / server error — only rendered when there is an error, so
// the happy-path layout is pixel-unchanged.
const errorText: CSSProperties = {
  marginTop: 8,
  fontSize: 10.5,
  letterSpacing: 0.5,
  lineHeight: 1.4,
  color: tokens.danger,
};

// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
// shadow), sized to the narrow radius trigger and dropped just beneath it.
const radiusPanel: CSSProperties = {
  position: "absolute",
  left: 0,
  right: 0,
  top: "calc(100% + 4px)",
  background: tokens.surface.w98,
  border: `1px solid ${tokens.line}`,
  borderRadius: 6,
  zIndex: 30,
  padding: 3,
  boxShadow: "0 10px 26px rgba(40,80,130,.18)",
};

interface ParamsPanelProps {
  /** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
   *  the still-unwired v2 page renders without props (tsc-safe). */
  onSubmit?: (input: TradeInEstimateInput) => void;
  /** Disables the button + shows a pending label while the estimate is running. */
  isPending?: boolean;
  /** M4 — once a result is on screen the primary CTA moves to «СКАЧАТЬ PDF-ОТЧЁТ»
   *  (HeroBar), so «ОЦЕНИТЬ КВАРТИРУ» (a re-run) demotes to a secondary outline. */
  hasEstimate?: boolean;
  /** Server-side error to surface inline (validation errors are handled locally). */
  error?: string | null;
  /** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
  initialValues?: Partial<TradeInEstimateInput>;
  /** Analog price pins, projected from the real estimate via mapMarkers() onto
   *  the OLD decorative SVG's fixed 0-100% grid (never a real geo scale — see
   *  mapMarkers() comment in ./mappers.ts). Kept in the prop contract for
   *  backward-compat with the caller (app/v2/page.tsx); intentionally NOT
   *  plotted on the real Leaflet map below, because their %-positions do not
   *  correspond to real lat/lon at the map's actual zoom — projecting them
   *  would be a new, subtler version of the honesty bug this map replaces. */
  markers?: MapMarker[];
}

// rooms number -> dropdown label. The design has no «Студия» option, so studio
// (0) and 1-room both map to "1"; >=5 collapses to "5+". null -> design default.
function initRoomsLabel(rooms: number | null | undefined): string {
  if (rooms == null) return "2";
  if (rooms >= 5) return "5+";
  if (rooms <= 1) return "1";
  return String(rooms);
}

function initHouseTypeLabel(ht: HouseType | undefined): string {
  return ht ? HOUSE_TYPE_RU[ht] : "Не указано";
}

function initRepairLabel(rs: RepairState | undefined): string {
  return rs ? REPAIR_RU[rs] : "Не указано";
}

// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
// two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"];

// "Авто" sends no radius_m → the backend applies its two-tier default (1000 m
// primary / 2000 m fallback, see RADIUS_OPTIONS comment above). The map circle
// previews the PRIMARY tier so it is never wildly off from what the backend
// will actually use.
const AUTO_RADIUS_PREVIEW_M = 1000;

// radius_m (metres) -> dropdown label. null/absent → "Авто" (legacy two-tier
// default), so a re-submit never silently narrows the search.
function initRadiusLabel(radiusM: number | null | undefined): string {
  if (radiusM == null) return "Авто";
  const label = `${radiusM} м`;
  return RADIUS_OPTIONS.includes(label) ? label : "Авто";
}

export default function ParamsPanel({
  onSubmit,
  isPending = false,
  hasEstimate = false,
  error = null,
  initialValues,
  // markers intentionally not destructured — see the ParamsPanelProps.markers
  // doc comment: its %-positions belong to the retired decorative SVG grid and
  // do not correspond to real lat/lon on the Leaflet map below.
}: ParamsPanelProps) {
  // M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ»
  // (HeroBar); this button (a re-run) demotes to a secondary outline.
  const secondaryCta = hasEstimate;
  const [openDd, setOpenDd] = useState<DdKey>(null);
  // РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
  const radiusListId = useId();
  const [radiusActive, setRadiusActive] = useState(-1);
  const [address, setAddress] = useState(initialValues?.address ?? "");
  const [area, setArea] = useState(
    initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
  );
  const [rooms, setRooms] = useState(initRoomsLabel(initialValues?.rooms));
  const [floor, setFloor] = useState(
    initialValues?.floor != null ? String(initialValues.floor) : "",
  );
  const [totalFloors, setTotalFloors] = useState(
    initialValues?.total_floors != null
      ? String(initialValues.total_floors)
      : "",
  );
  const [year, setYear] = useState(
    initialValues?.year_built != null ? String(initialValues.year_built) : "",
  );
  const [houseType, setHouseType] = useState(
    initHouseTypeLabel(initialValues?.house_type),
  );
  const [repair, setRepair] = useState(
    initRepairLabel(initialValues?.repair_state),
  );
  const [radius, setRadius] = useState(
    initRadiusLabel(initialValues?.radius_m),
  );
  const [balcony, setBalcony] = useState<boolean | null>(
    initialValues?.has_balcony ?? null,
  );
  const [fieldErrors, setFieldErrors] = useState<{
    address?: string;
    area?: string;
  }>({});

  // ── Address autocomplete (geocode suggest, ЕКБ viewbox) ──
  // The typed string is debounced into `addressQuery` from inside the change
  // handler (a plain timer, not a useEffect-for-HTTP); `useGeocodeSuggest` is
  // gated at >=3 chars and fires on the debounced value. Picking a suggestion
  // fills the full address and captures lat/lon so the backend can skip geocoding.
  const [addressQuery, setAddressQuery] = useState(
    initialValues?.address ?? "",
  );
  const [suggestOpen, setSuggestOpen] = useState(false);
  // Address combobox a11y (M6): stable listbox id + roving highlight index for
  // aria-activedescendant / ArrowDown-ArrowUp keyboard nav over the suggestions.
  const addressListId = useId();
  const addressOptId = (i: number) => `${addressListId}-opt-${i}`;
  const [addressActive, setAddressActive] = useState(-1);
  const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(
    initialValues?.lat != null && initialValues?.lon != null
      ? { lat: initialValues.lat, lon: initialValues.lon }
      : null,
  );
  // ФИАС-ключ дома из выбранной подсказки. Живёт рядом с coords и сбрасывается
  // по тем же событиям (ручная правка адреса → handleAddressChange). Отправляется
  // только когда адрес выбран из suggest — иначе stale fias перебил бы новый адрес.
  const [fias, setFias] = useState<string | null>(
    initialValues?.target_fias_id ?? null,
  );
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  // БАЛКОН radiogroup focus targets (roving tabindex).
  const balNoRef = useRef<HTMLButtonElement>(null);
  const balYesRef = useRef<HTMLButtonElement>(null);
  const suggest = useGeocodeSuggest(addressQuery);

  const handleAddressChange = (v: string) => {
    setAddress(v);
    setCoords(null); // a manual edit invalidates a previously picked point
    setFias(null); // ...and the previously picked house ФИАС (stale-guard)
    setSuggestOpen(v.trim().length >= 3);
    setAddressActive(-1); // a new query invalidates the previous highlight
    if (fieldErrors.address)
      setFieldErrors((prev) => ({ ...prev, address: undefined }));
    if (debounceRef.current) clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
  };

  const pickSuggestion = (s: GeocodeSuggestion) => {
    setAddress(s.full_address);
    setAddressQuery(s.full_address);
    setCoords({ lat: s.lat, lon: s.lon });
    setFias(s.fias_id ?? null);
    setSuggestOpen(false);
    setAddressActive(-1);
  };

  // Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on
  // the input; arrows move `addressActive` over the live suggestions, Enter on a
  // highlighted row selects it (else submits the form), Escape closes ONLY the
  // popup (stopPropagation so it doesn't also dismiss an outer drawer/overlay).
  // The geocode/suggest fetch + pickSuggestion side-effects are untouched.
  const handleAddressKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
    const list = suggest.data ?? [];
    const popupOpen = suggestOpen && addressQuery.trim().length >= 3;
    const listOpen = popupOpen && list.length > 0;
    switch (e.key) {
      case "ArrowDown":
        if (listOpen) {
          e.preventDefault();
          setAddressActive((i) => Math.min(list.length - 1, i + 1));
        } else if (address.trim().length >= 3) {
          e.preventDefault();
          setSuggestOpen(true); // reopen after Escape without retyping
        }
        break;
      case "ArrowUp":
        if (listOpen) {
          e.preventDefault();
          setAddressActive((i) => Math.max(0, i - 1));
        }
        break;
      case "Home":
        if (listOpen) {
          e.preventDefault();
          setAddressActive(0);
        }
        break;
      case "End":
        if (listOpen) {
          e.preventDefault();
          setAddressActive(list.length - 1);
        }
        break;
      case "Enter":
        if (listOpen && addressActive >= 0 && addressActive < list.length) {
          e.preventDefault();
          pickSuggestion(list[addressActive]);
        } else {
          handleSubmit();
        }
        break;
      case "Escape":
        if (popupOpen) {
          e.preventDefault();
          e.stopPropagation();
          setSuggestOpen(false);
          setAddressActive(-1);
        }
        break;
      default:
        break;
    }
  };

  // TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально,
  // но неактивен (выбор ничего не делает).
  const crm = dropdownOptions.crm[0];

  const toggle = (k: Exclude<DdKey, null>) =>
    setOpenDd((cur) => (cur === k ? null : k));
  const pick = (setter: (v: string) => void) => (v: string) => {
    setter(v);
    setOpenDd(null);
  };

  // Keep the РАДИУС virtual highlight pointed at the open selection (UI sync,
  // not a data effect) — mirrors the <Dd> internal effect.
  useEffect(() => {
    if (openDd === "radius") {
      const idx = RADIUS_OPTIONS.indexOf(radius);
      setRadiusActive(idx >= 0 ? idx : 0);
    } else {
      setRadiusActive(-1);
    }
  }, [openDd, radius]);

  // БАЛКОН radiogroup keyboard: arrows pick + move focus; Space/Enter commit the
  // focused option.
  const handleBalconyKey = (e: ReactKeyboardEvent<HTMLButtonElement>) => {
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      e.preventDefault();
      setBalcony(true);
      balYesRef.current?.focus();
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      e.preventDefault();
      setBalcony(false);
      balNoRef.current?.focus();
    } else if (e.key === " " || e.key === "Enter") {
      e.preventDefault();
      setBalcony(e.currentTarget === balYesRef.current);
    }
  };

  const handleSubmit = () => {
    const trimmedAddress = address.trim();
    const areaNum = Number(area.replace(",", "."));
    // Collect ALL blockers (don't early-return) so every offending field shows
    // its own message at once.
    const errs: { address?: string; area?: string } = {};
    if (trimmedAddress.length < 3) {
      errs.address = "Укажите адрес квартиры — минимум 3 символа";
    }
    if (!area.trim()) {
      errs.area = "Укажите площадь квартиры";
    } else if (!Number.isFinite(areaNum) || areaNum <= 10) {
      errs.area = "Площадь должна быть больше 10 м²";
    }
    if (Object.keys(errs).length > 0) {
      setFieldErrors(errs);
      return;
    }
    setFieldErrors({});
    setSuggestOpen(false);
    onSubmit?.({
      address: trimmedAddress,
      area_m2: areaNum,
      rooms: rooms === "5+" ? 5 : Number(rooms),
      floor: floor.trim() ? Number(floor) : null,
      total_floors: totalFloors.trim() ? Number(totalFloors) : null,
      year_built: year.trim() ? Number(year) : undefined,
      house_type: HOUSE_TYPE_FROM_RU[houseType],
      repair_state: REPAIR_FROM_RU[repair],
      has_balcony: balcony ?? undefined,
      lat: coords?.lat ?? null,
      lon: coords?.lon ?? null,
      // Детерминированный ключ дома — только из выбранной подсказки (иначе null).
      target_fias_id: fias,
      // "Авто" → null → backend keeps its two-tier default (1000 m primary /
      // 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по
      // умолчанию — это сузило бы поиск аналогов и убрало fallback.)
      radius_m: radius === "Авто" ? null : parseInt(radius, 10),
    });
  };

  const balOn: CSSProperties = {
    border: `1px solid ${tokens.accent}`,
    color: tokens.accent,
    fontWeight: 600,
    background: "rgba(46,139,255,.1)",
  };
  const balOff: CSSProperties = {
    border: `1px solid ${tokens.line}`,
    color: tokens.muted,
    fontWeight: 400,
    background: "transparent",
  };
  const balBase: CSSProperties = {
    flex: 1,
    borderRadius: 6,
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    // No Tailwind preflight here → reset UA <button> chrome explicitly so the
    // radio pills keep the HUD font/size (UA buttons don't inherit font-family).
    // `inherit` picks up the artboard's Manrope, matching the prior <div> + the
    // map +/- buttons (strict parity, one approach).
    fontFamily: "inherit",
    fontSize: 11,
    letterSpacing: 1,
    padding: 0,
    cursor: "pointer",
    transition: "all .15s",
  };

  // Real analysis-radius circle (metres), synced with the selected РАДИУС —
  // L.circle takes a radius in metres, so this is a true geographic scale
  // (unlike the old SVG ring, which was a clamped pixel best-effort).
  const parsedRadiusM = parseInt(radius, 10);
  const circleRadiusM = Number.isFinite(parsedRadiusM)
    ? parsedRadiusM
    : AUTO_RADIUS_PREVIEW_M;

  // Subject area caption for the map pin (M10) — the SUBJECT's own m², from the
  // form, so the pin never borrows an analog's area. Empty area → no caption.
  const areaTrimmed = area.trim();
  const subjectAreaLabel = areaTrimmed ? `${areaTrimmed} м²` : null;

  // ── 01 map — real Leaflet + OSM (see loadLeaflet()/buildSubjectIcon() above) ──
  const hasCoords = coords != null;
  const mapContainerRef = useRef<HTMLDivElement>(null);
  const leafletMapRef = useRef<any>(null);
  const markerRef = useRef<any>(null);
  const circleRef = useRef<any>(null);
  const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM);
  const [mapLoadError, setMapLoadError] = useState(false);

  // "Latest value" refs, read inside the async loadLeaflet().then() callback
  // below, which may resolve several renders after the effect fired (slow CDN
  // load). Plain closure vars would go stale; refs assigned every render don't.
  const addressRef = useRef(address);
  addressRef.current = address;
  const subjectAreaLabelRef = useRef(subjectAreaLabel);
  subjectAreaLabelRef.current = subjectAreaLabel;
  const circleRadiusMRef = useRef(circleRadiusM);
  circleRadiusMRef.current = circleRadiusM;
  const coordsRef = useRef(coords);
  coordsRef.current = coords;

  // Create/destroy the map when coordinates appear/disappear (not on every
  // lat/lon value change — see the position-sync effect below for that). This
  // is the only effect that mounts/tears down the Leaflet instance, so it is
  // also the only place map.remove() needs to run, fixing the "Map container
  // is already initialized" crash class.
  useEffect(() => {
    if (!hasCoords) return; // no coords yet -> the placeholder renders instead
    let map: any = null;
    let cancelled = false;

    // Reset a previous CDN failure before retrying. Without this the error
    // branch is a dead end: it replaces the map container in the render tree,
    // so mapContainerRef stays null and no later attempt can ever succeed —
    // one transient unpkg blip would kill the map for the rest of the session
    // even after the user picks a different address.
    setMapLoadError(false);

    loadLeaflet()
      .then((L) => {
        if (cancelled || !mapContainerRef.current || !coordsRef.current) return;
        const center: [number, number] = [
          coordsRef.current.lat,
          coordsRef.current.lon,
        ];
        map = L.map(mapContainerRef.current, {
          scrollWheelZoom: false,
          zoomControl: false,
          minZoom: MIN_MAP_ZOOM,
          maxZoom: MAX_MAP_ZOOM,
        }).setView(center, DEFAULT_MAP_ZOOM);
        L.tileLayer(MAP_TILE_URL, {
          attribution: MAP_ATTRIBUTION,
          maxZoom: MAX_MAP_ZOOM,
        }).addTo(map);
        setTimeout(() => map && map.invalidateSize(), 120);

        circleRef.current = L.circle(center, {
          radius: circleRadiusMRef.current,
          color: tokens.accent,
          weight: 1,
          fillColor: tokens.accent,
          fillOpacity: 0.05,
          interactive: false,
        }).addTo(map);

        markerRef.current = L.marker(center, {
          icon: buildSubjectIcon(
            L,
            addressRef.current.trim() || "Адрес квартиры",
            subjectAreaLabelRef.current,
          ),
          interactive: false,
        }).addTo(map);

        leafletMapRef.current = map;
        setMapZoom(DEFAULT_MAP_ZOOM);
        map.on("zoomend", () => setMapZoom(map.getZoom()));
      })
      .catch(() => setMapLoadError(true));

    return () => {
      cancelled = true;
      if (map) map.remove();
      leafletMapRef.current = null;
      markerRef.current = null;
      circleRef.current = null;
    };
    // hasCoords is the only plain dependency this effect reads directly — the
    // live lat/lon/radius/address values come from refs (assigned every
    // render above), which exhaustive-deps correctly treats as stable.
  }, [hasCoords]);

  // Move the existing map/marker/circle to a newly picked address without
  // tearing the map down (avoids a tile-reload flash on every pick).
  useEffect(() => {
    const map = leafletMapRef.current;
    if (!map || !coords) return;
    const center: [number, number] = [coords.lat, coords.lon];
    map.setView(center, map.getZoom());
    markerRef.current?.setLatLng(center);
    circleRef.current?.setLatLng(center);
  }, [coords]);

  // Sync the radius circle in place when the РАДИУС АНАЛИЗА selection changes.
  useEffect(() => {
    circleRef.current?.setRadius(circleRadiusM);
  }, [circleRadiusM]);

  // Refresh the pin's address/area caption in place as the user edits the form.
  useEffect(() => {
    if (!markerRef.current) return;
    const w = window as any;
    if (!w.L) return;
    markerRef.current.setIcon(
      buildSubjectIcon(w.L, address.trim() || "Адрес квартиры", subjectAreaLabel),
    );
  }, [address, subjectAreaLabel]);

  // Address combobox render state (M6). `popupOpen` = the dropdown is shown at
  // all (incl. loading / empty notes); `listboxOpen` = it holds real selectable
  // options, which is when the input advertises aria-expanded + activedescendant.
  const addressList = suggest.data ?? [];
  const addressPopupOpen = suggestOpen && addressQuery.trim().length >= 3;
  const addressListboxOpen = addressPopupOpen && addressList.length > 0;

  // #2264 C9: a polite announcement of the suggestion COUNT so SR users know the
  // list changed under the combobox (role="listbox" is otherwise silent on
  // update). The loading / empty states are already announced by the visible
  // role="status" note below, so this region carries only the count.
  const addressAnnounce = addressListboxOpen
    ? `${addressList.length} ${pluralRu(addressList.length, "подсказка", "подсказки", "подсказок")}`
    : "";

  return (
    <div
      style={{
        position: "relative",
        background: tokens.surface.w50,
        backdropFilter: "blur(6px)",
        border: `1px solid ${tokens.line2}`,
        borderRadius: 8,
        padding: "16px 18px",
        display: "flex",
        flexDirection: "column",
      }}
    >
      <style>{styles}</style>

      {/* HUD corner brackets */}
      <div
        style={{
          position: "absolute",
          left: -1,
          top: -1,
          width: 13,
          height: 13,
          borderLeft: `1.5px solid ${tokens.bracket}`,
          borderTop: `1.5px solid ${tokens.bracket}`,
        }}
      />
      <div
        style={{
          position: "absolute",
          right: -1,
          bottom: -1,
          width: 13,
          height: 13,
          borderRight: `1.5px solid ${tokens.bracket}`,
          borderBottom: `1.5px solid ${tokens.bracket}`,
        }}
      />

      {/* header */}
      <div
        style={{
          display: "flex",
          alignItems: "baseline",
          justifyContent: "space-between",
          marginBottom: 13,
        }}
      >
        <div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
          <span
            style={{
              fontFamily: tokens.font.mono,
              fontSize: 15,
              color: tokens.accent,
            }}
          >
            01
          </span>
          <span style={{ fontSize: 13, fontWeight: 600, letterSpacing: 2 }}>
            ПАРАМЕТРЫ КВАРТИРЫ
          </span>
        </div>
      </div>

      {/* MAP — real Leaflet + OSM basemap, centred on the geocoded address
          (Fix, audit #2264 C7 follow-up): was a decorative SVG (grid + fake
          streets + a clamped best-effort radius ring), no real coordinates
          behind it at all. © OpenStreetMap attribution comes from Leaflet's
          own attribution control on the tile layer below (OSM tile licence
          requirement) — no separate caption needed. */}
      <div
        style={{
          position: "relative",
          height: 140,
          border: `1px solid ${tokens.line}`,
          borderRadius: 6,
          overflow: "hidden",
          background: tokens.mapBg,
          flex: "0 0 auto",
        }}
      >
        {hasCoords ? (
          mapLoadError ? (
            <div
              style={{
                position: "absolute",
                inset: 0,
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                padding: "0 16px",
                textAlign: "center",
                fontSize: 10.5,
                lineHeight: 1.4,
                color: tokens.hint,
                fontFamily: tokens.font.sans,
              }}
            >
              Не удалось загрузить карту. Проверьте интернет-соединение.
            </div>
          ) : (
            <>
              <div
                ref={mapContainerRef}
                style={{ position: "absolute", inset: 0 }}
                aria-label="Карта расположения квартиры"
              />
              {/* map controls — real Leaflet zoom (Leaflet's own zoomControl
                  is disabled above; these are the HUD-styled buttons). */}
              <div
                style={{
                  position: "absolute",
                  right: 9,
                  bottom: 9,
                  display: "flex",
                  flexDirection: "column",
                  gap: 5,
                }}
              >
                <button
                  type="button"
                  aria-label="Приблизить карту"
                  disabled={mapZoom >= MAX_MAP_ZOOM}
                  style={{
                    width: 24,
                    height: 24,
                    background: tokens.surface.w80,
                    border: `1px solid ${tokens.line}`,
                    borderRadius: 4,
                    display: "flex",
                    alignItems: "center",
                    justifyContent: "center",
                    cursor: mapZoom >= MAX_MAP_ZOOM ? "default" : "pointer",
                    transition: "all .15s",
                    fontSize: 15,
                    color: tokens.muted,
                    opacity: mapZoom >= MAX_MAP_ZOOM ? 0.4 : 1,
                    padding: 0,
                    fontFamily: "inherit",
                  }}
                  onClick={() => leafletMapRef.current?.zoomIn()}
                >
                  +
                </button>
                <button
                  type="button"
                  aria-label="Отдалить карту"
                  disabled={mapZoom <= MIN_MAP_ZOOM}
                  style={{
                    width: 24,
                    height: 24,
                    background: tokens.surface.w80,
                    border: `1px solid ${tokens.line}`,
                    borderRadius: 4,
                    display: "flex",
                    alignItems: "center",
                    justifyContent: "center",
                    cursor: mapZoom <= MIN_MAP_ZOOM ? "default" : "pointer",
                    transition: "all .15s",
                    fontSize: 15,
                    color: tokens.muted,
                    opacity: mapZoom <= MIN_MAP_ZOOM ? 0.4 : 1,
                    padding: 0,
                    fontFamily: "inherit",
                  }}
                  onClick={() => leafletMapRef.current?.zoomOut()}
                >
                  −
                </button>
              </div>
            </>
          )
        ) : (
          /* No coordinates yet (address not typed/picked from suggest) — a
             tidy placeholder, never an empty grey box or a broken map. */
          <div
            style={{
              position: "absolute",
              inset: 0,
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
              justifyContent: "center",
              gap: 6,
              padding: "0 16px",
              textAlign: "center",
            }}
          >
            <svg
              aria-hidden="true"
              width="20"
              height="20"
              viewBox="0 0 24 24"
              fill="none"
            >
              <path
                d="M12 21s-7-7.2-7-12a7 7 0 1 1 14 0c0 4.8-7 12-7 12Z"
                stroke={tokens.muted2}
                strokeWidth={1.5}
              />
              <circle cx="12" cy="9" r="2.5" stroke={tokens.muted2} strokeWidth={1.5} />
            </svg>
            <span
              style={{
                fontSize: 10.5,
                lineHeight: 1.4,
                color: tokens.hint,
                fontFamily: tokens.font.sans,
              }}
            >
              Выберите адрес из подсказок, чтобы увидеть карту
            </span>
          </div>
        )}

        {/* map corner bracket — decorative HUD chrome, shown regardless of
            map/placeholder state (unchanged from the prior design). */}
        <div
          style={{
            position: "absolute",
            left: 8,
            top: 8,
            width: 12,
            height: 12,
            borderLeft: `1.5px solid ${tokens.accent}`,
            borderTop: `1.5px solid ${tokens.accent}`,
          }}
        />
      </div>

      {/* radius row */}
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 14,
          marginTop: 13,
          flex: "0 0 auto",
        }}
      >
        <span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.body2 }}>
          РАДИУС АНАЛИЗА
        </span>
        {/* РАДИУС — selectable analysis radius (wired: parsed to radius_m on
            submit). Trigger markup unchanged; now toggles a Dd-style option panel
            and uses the shared pp-dd-trigger hover cue. */}
        <div style={{ position: "relative" }}>
          <button
            type="button"
            className="pp-dd-trigger"
            onClick={() => toggle("radius")}
            onKeyDown={(e) =>
              comboKeyDown(
                e,
                openDd === "radius",
                RADIUS_OPTIONS,
                radiusActive,
                setRadiusActive,
                () => toggle("radius"),
                pick(setRadius),
              )
            }
            role="combobox"
            aria-haspopup="listbox"
            aria-expanded={openDd === "radius"}
            aria-controls={radiusListId}
            aria-activedescendant={
              openDd === "radius" && radiusActive >= 0
                ? `${radiusListId}-opt-${radiusActive}`
                : undefined
            }
            aria-label="Радиус анализа"
            style={{
              display: "flex",
              alignItems: "center",
              gap: 8,
              background: tokens.surface.w60,
              borderRadius: 5,
              padding: "6px 10px",
              fontSize: 12,
              fontFamily: tokens.font.mono,
              color: "inherit",
              cursor: "pointer",
            }}
          >
            <span>{radius}</span>
            <span style={{ color: tokens.muted2, fontSize: 9 }}>▼</span>
          </button>
          {openDd === "radius" && (
            <div
              style={radiusPanel}
              role="listbox"
              id={radiusListId}
              aria-label="Радиус анализа"
              tabIndex={-1}
            >
              {RADIUS_OPTIONS.map((o, i) => (
                <div
                  key={o}
                  className="pp-dd-opt"
                  role="option"
                  id={`${radiusListId}-opt-${i}`}
                  aria-selected={o === radius}
                  onClick={() => pick(setRadius)(o)}
                  onMouseEnter={() => setRadiusActive(i)}
                  style={{
                    padding: "7px 10px",
                    fontSize: 12,
                    fontFamily: tokens.font.mono,
                    borderRadius: 4,
                    cursor: "pointer",
                    color: o === radius ? tokens.accent : tokens.ink,
                    fontWeight: o === radius ? 600 : 400,
                    background:
                      i === radiusActive ? "rgba(46,139,255,.1)" : undefined,
                  }}
                >
                  {o}
                </div>
              ))}
            </div>
          )}
        </div>
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 16,
            fontSize: 10.5,
            color: tokens.muted,
            marginLeft: "auto",
          }}
        >
          <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
            <span
              style={{
                width: 8,
                height: 8,
                borderRadius: "50%",
                background: tokens.accent,
              }}
            />
            Объявления
          </span>
          <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
            <span
              style={{
                width: 8,
                height: 8,
                borderRadius: "50%",
                background: tokens.dealDot,
              }}
            />
            Сделки
          </span>
        </div>
      </div>

      {/* INPUTS */}
      <div
        style={{
          marginTop: 10,
          display: "flex",
          flexDirection: "column",
          gap: 5,
          flex: 1,
          minHeight: 0,
        }}
      >
        <div>
          <label
            htmlFor="pp-address"
            style={{ ...hintLabel, display: "block" }}
          >
            АДРЕС (YANDEX / NOMINATIM)
          </label>
          <div style={{ position: "relative" }}>
            {/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
                is debounced into `addressQuery`; the dropdown below mirrors the
                HUD <Dd> panel styling. Picking an item fills the full address
                and captures lat/lon for the estimate payload. */}
            <input
              id="pp-address"
              className="pp-input"
              type="text"
              value={address}
              role="combobox"
              aria-autocomplete="list"
              aria-expanded={addressListboxOpen}
              aria-controls={addressListboxOpen ? addressListId : undefined}
              aria-activedescendant={
                addressListboxOpen && addressActive >= 0
                  ? addressOptId(addressActive)
                  : undefined
              }
              aria-required="true"
              aria-invalid={fieldErrors.address ? true : undefined}
              aria-describedby={
                fieldErrors.address ? "pp-address-err" : undefined
              }
              onChange={(e) => handleAddressChange(e.target.value)}
              onFocus={() => {
                if (address.trim().length >= 3) setSuggestOpen(true);
              }}
              onBlur={() => {
                setSuggestOpen(false);
                setAddressActive(-1);
              }}
              onKeyDown={handleAddressKeyDown}
              placeholder="Город, улица, дом"
              autoComplete="off"
              style={
                fieldErrors.address
                  ? { ...addressField, border: `1px solid ${tokens.danger}` }
                  : addressField
              }
            />
            {/* Polite announcement of the suggestion count (#2264 C9). */}
            <div aria-live="polite" role="status" style={srOnly}>
              {addressAnnounce}
            </div>
            <svg
              aria-hidden="true"
              style={{
                position: "absolute",
                right: 12,
                top: "50%",
                transform: "translateY(-50%)",
              }}
              width="15"
              height="15"
              viewBox="0 0 15 15"
              fill="none"
            >
              <circle cx="7.5" cy="7.5" r="4.5" stroke="#2e8bff" />
              <line x1="7.5" y1="0" x2="7.5" y2="3" stroke="#2e8bff" />
              <line x1="7.5" y1="12" x2="7.5" y2="15" stroke="#2e8bff" />
              <line x1="0" y1="7.5" x2="3" y2="7.5" stroke="#2e8bff" />
              <line x1="12" y1="7.5" x2="15" y2="7.5" stroke="#2e8bff" />
            </svg>
            {addressPopupOpen &&
              (addressListboxOpen ? (
                <div
                  style={suggestPanel}
                  role="listbox"
                  id={addressListId}
                  aria-label="Подсказки адреса"
                  tabIndex={-1}
                >
                  {addressList.map((s, i) => (
                    <div
                      key={`${s.full_address}-${i}`}
                      className="pp-dd-opt"
                      role="option"
                      id={addressOptId(i)}
                      aria-selected={i === addressActive}
                      // onMouseDown (not onClick) + preventDefault fires before
                      // the input's onBlur, so the pick lands before the list closes.
                      onMouseDown={(e) => {
                        e.preventDefault();
                        pickSuggestion(s);
                      }}
                      onMouseEnter={() => setAddressActive(i)}
                      style={
                        i === addressActive
                          ? { ...suggestOpt, background: "rgba(46,139,255,.1)" }
                          : suggestOpt
                      }
                    >
                      <div style={suggestOptMain}>{s.label}</div>
                      {s.full_address !== s.label && (
                        <div style={suggestOptSub}>{s.full_address}</div>
                      )}
                    </div>
                  ))}
                </div>
              ) : (
                // Loading / empty: a status note, NOT a role="listbox" (it has no
                // selectable options) so aria-required-children stays satisfied.
                <div style={suggestPanel} role="status">
                  <div style={suggestNote}>
                    {suggest.isFetching ? "Поиск…" : "Ничего не найдено"}
                  </div>
                </div>
              ))}
            {fieldErrors.address && (
              <div
                id="pp-address-err"
                style={{ ...errorText, marginTop: 4 }}
                role="alert"
              >
                {fieldErrors.address}
              </div>
            )}
          </div>
        </div>

        <div style={fieldRow}>
          <div>
            <label htmlFor="pp-area" style={{ ...hintLabel, display: "block" }}>
              ПЛОЩАДЬ, М²
            </label>
            <input
              id="pp-area"
              className="pp-input"
              type="text"
              inputMode="decimal"
              value={area}
              aria-required="true"
              aria-invalid={fieldErrors.area ? true : undefined}
              aria-describedby={fieldErrors.area ? "pp-area-err" : undefined}
              onChange={(e) => {
                setArea(e.target.value.replace(/[^\d.,]/g, ""));
                if (fieldErrors.area)
                  setFieldErrors((prev) => ({ ...prev, area: undefined }));
              }}
              onKeyDown={(e) => {
                if (e.key === "Enter") handleSubmit();
              }}
              placeholder="напр. 54"
              style={
                fieldErrors.area
                  ? { ...inputField, border: `1px solid ${tokens.danger}` }
                  : inputField
              }
            />
            {fieldErrors.area && (
              <div
                id="pp-area-err"
                style={{ ...errorText, marginTop: 4 }}
                role="alert"
              >
                {fieldErrors.area}
              </div>
            )}
          </div>
          <div>
            <div style={hintLabel}>КОМНАТ</div>
            <Dd
              open={openDd === "rooms"}
              onToggle={() => toggle("rooms")}
              value={rooms}
              options={dropdownOptions.rooms}
              onSelect={pick(setRooms)}
              mono
              triggerFontSize={14}
              optionFontSize={13}
              ariaLabel="Комнат"
            />
          </div>
        </div>

        <div style={fieldRow}>
          <div>
            <div style={{ display: "flex", justifyContent: "space-between" }}>
              <label htmlFor="pp-floor" style={hintLabel}>
                ЭТАЖ
              </label>
              <span style={optHint}>если знаете</span>
            </div>
            <input
              id="pp-floor"
              className="pp-input"
              type="text"
              inputMode="numeric"
              value={floor}
              onChange={(e) => setFloor(e.target.value.replace(/\D/g, ""))}
              onKeyDown={(e) => {
                if (e.key === "Enter") handleSubmit();
              }}
              placeholder="—"
              style={inputField}
            />
          </div>
          <div>
            <div style={{ display: "flex", justifyContent: "space-between" }}>
              <label htmlFor="pp-total-floors" style={hintLabel}>
                ВСЕГО ЭТАЖЕЙ
              </label>
              <span style={optHint}>если знаете</span>
            </div>
            <input
              id="pp-total-floors"
              className="pp-input"
              type="text"
              inputMode="numeric"
              value={totalFloors}
              onChange={(e) =>
                setTotalFloors(e.target.value.replace(/\D/g, ""))
              }
              onKeyDown={(e) => {
                if (e.key === "Enter") handleSubmit();
              }}
              placeholder="—"
              style={inputField}
            />
          </div>
        </div>

        <div
          style={{
            fontSize: 10,
            color: tokens.hint,
            lineHeight: 1.4,
            marginTop: -3,
          }}
        >
          1-й и последний этаж снижают цену на 5–10% — заполните, если знаете
        </div>

        <div style={fieldRow}>
          <div>
            <div style={{ display: "flex", justifyContent: "space-between" }}>
              <label htmlFor="pp-year" style={hintLabel}>
                ГОД ПОСТРОЙКИ
              </label>
              <span style={optHint}>опц.</span>
            </div>
            <input
              id="pp-year"
              className="pp-input"
              type="text"
              inputMode="numeric"
              value={year}
              onChange={(e) => setYear(e.target.value.replace(/\D/g, ""))}
              onKeyDown={(e) => {
                if (e.key === "Enter") handleSubmit();
              }}
              placeholder="—"
              style={inputField}
            />
          </div>
          <div>
            <div style={{ display: "flex", justifyContent: "space-between" }}>
              <span style={hintLabel}>ТИП ДОМА</span>
              <span style={optHint}>опц.</span>
            </div>
            <Dd
              open={openDd === "houseType"}
              onToggle={() => toggle("houseType")}
              value={houseType}
              options={dropdownOptions.houseType}
              onSelect={pick(setHouseType)}
              mono={false}
              triggerFontSize={13}
              optionFontSize={12}
              ariaLabel="Тип дома"
            />
          </div>
        </div>

        <div style={fieldRow}>
          <div>
            <div style={{ display: "flex", justifyContent: "space-between" }}>
              <span style={hintLabel}>СОСТОЯНИЕ РЕМОНТА</span>
              <span style={optHint}>опц.</span>
            </div>
            <Dd
              open={openDd === "repair"}
              onToggle={() => toggle("repair")}
              value={repair}
              options={dropdownOptions.repair}
              onSelect={pick(setRepair)}
              mono={false}
              triggerFontSize={13}
              optionFontSize={12}
              ariaLabel="Состояние ремонта"
            />
          </div>
          <div>
            <div style={hintLabel}>БАЛКОН / ЛОДЖИЯ</div>
            <div
              role="radiogroup"
              aria-label="Балкон или лоджия"
              style={{ height: 40, display: "flex", gap: 6 }}
            >
              <button
                type="button"
                role="radio"
                aria-checked={balcony === false}
                tabIndex={balcony === false || balcony === null ? 0 : -1}
                ref={balNoRef}
                onClick={() => setBalcony(false)}
                onKeyDown={handleBalconyKey}
                style={{ ...balBase, ...(balcony === false ? balOn : balOff) }}
              >
                НЕТ
              </button>
              <button
                type="button"
                role="radio"
                aria-checked={balcony === true}
                tabIndex={balcony === true ? 0 : -1}
                ref={balYesRef}
                onClick={() => setBalcony(true)}
                onKeyDown={handleBalconyKey}
                style={{ ...balBase, ...(balcony === true ? balOn : balOff) }}
              >
                ДА
              </button>
            </div>
          </div>
        </div>

        <div>
          <div style={{ display: "flex", justifyContent: "space-between" }}>
            <span style={hintLabel}>CRM — ДЛЯ МЕНЕДЖЕРА</span>
            <span style={optHint}>скоро</span>
          </div>
          {/* CRM — non-functional (no backend, #395). Markup kept; open is
              forced false + no-op handlers so it never expands. TODO(wire). */}
          <Dd
            open={false}
            onToggle={() => undefined}
            value={crm}
            options={dropdownOptions.crm}
            onSelect={() => undefined}
            mono={false}
            triggerFontSize={12}
            optionFontSize={12}
            variant="dashed"
            openUp
            triggerHeight={34}
            triggerBg={tokens.surface.w50}
            triggerColor={tokens.hint}
            caretColor={tokens.muted4}
            disabled
            ariaLabel="CRM — для менеджера"
          />
        </div>
      </div>

      {/* button — filled/primary before a result, outline/secondary after (M4). */}
      <button
        type="button"
        className={secondaryCta ? "pp-eval-btn-secondary" : "pp-eval-btn"}
        onClick={handleSubmit}
        disabled={isPending}
        style={{
          marginTop: 9,
          height: 46,
          width: "100%",
          border: `1px solid ${tokens.accent}`,
          borderRadius: 7,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          position: "relative",
          cursor: isPending ? "wait" : "pointer",
          opacity: isPending ? 0.6 : 1,
          fontFamily: tokens.font.sans,
          color: secondaryCta ? tokens.accentDeep : tokens.onAccent,
          flex: "0 0 auto",
        }}
      >
        <span style={{ fontSize: 14, fontWeight: 600, letterSpacing: 3 }}>
          {isPending ? "ОЦЕНИВАЕМ…" : "ОЦЕНИТЬ КВАРТИРУ"}
        </span>
        <span
          style={{
            position: "absolute",
            right: 16,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            width: 26,
            height: 26,
            border: `1px solid ${
              secondaryCta ? tokens.accent : "rgba(255,255,255,.7)"
            }`,
            borderRadius: "50%",
            color: secondaryCta ? tokens.accentDeep : tokens.onAccent,
          }}
        >
          →
        </span>
      </button>

      {/* inline SERVER error — only when present (field-level validation is
          surfaced at each field above). */}
      {error && (
        <div style={errorText} role="alert">
          {error}
        </div>
      )}

      {/* cache status — Fix #6 (audit): was static, so it showed "fresh" before
          any estimate existed. Gated on hasEstimate (same signal that already
          swaps the CTA to «СКАЧАТЬ PDF-ОТЧЁТ» above) — the 24 Ч TTL itself is a
          real cache constant (ParamsPanel/mappers.ts cacheStatus), only the
          "always visible" part was dishonest. */}
      {hasEstimate && (
        <div
          style={{
            display: "flex",
            alignItems: "center",
            justifyContent: "space-between",
            marginTop: 11,
            flex: "0 0 auto",
            fontSize: 9.5,
            letterSpacing: 1.5,
            color: tokens.muted2,
          }}
        >
          <span>ДАННЫЕ АКТУАЛЬНЫ</span>
          <span
            style={{
              display: "flex",
              alignItems: "center",
              gap: 6,
              color: tokens.success,
            }}
          >
            <span
              className="pp-dot"
              style={{
                width: 7,
                height: 7,
                borderRadius: "50%",
                background: tokens.success,
                boxShadow: `0 0 6px ${tokens.success}`,
              }}
            />
            ДЕЙСТВИТЕЛЕН 24 Ч
          </span>
        </div>
      )}
    </div>
  );
}
