"use client";

// /trade-in/v2 — "МЕРА Оценка" HUD dashboard, wired to the real trade-in API.
// This page OWNS the data: it runs the estimate mutation + restore-by-id query,
// spawns the dashboard sub-hooks (street deals, house analytics) and feeds the
// pixel-perfect HUD components via the pure mappers (mapReport / mapObject /
// mapResultPanel / mapSummary). The artboard frame / brackets / 474-1fr-250 grid
// are unchanged — only the result/summary areas swap between honest empty / grey
// skeleton / inline-error / real-data states. Sub-hooks resolve independently:
// a pending or errored street-deals / analytics call degrades its section to a
// fallback inside the mapper instead of blanking the page.

import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";

import { tokens } from "@/components/trade-in/v2/tokens";
import TopNav from "@/components/trade-in/v2/TopNav";
import HeroBar from "@/components/trade-in/v2/HeroBar";
import ParamsPanel from "@/components/trade-in/v2/ParamsPanel";
import ResultPanel from "@/components/trade-in/v2/ResultPanel";
import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
import { LeadForm } from "@/components/trade-in/v2/LeadForm";
import { Footer } from "@/components/trade-in/v2/Footer";
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
import type {
  Analytics,
  ObjectInfo,
  Report,
} from "@/components/trade-in/v2/types";
import type {
  CacheData,
  HistoryData,
  SourcesData,
} from "@/components/trade-in/v2/mappers";
import {
  mapAnalytics,
  mapCache,
  mapHistory,
  mapLocation,
  mapMarkers,
  mapObject,
  mapReport,
  mapResultPanel,
  mapSources,
  mapSummary,
} from "@/components/trade-in/v2/mappers";

import type {
  AggregatedEstimate,
  TradeInEstimateInput,
} from "@/types/trade-in";
import { HTTPError } from "@/lib/api";
import {
  useEstimate,
  useEstimateHistory,
  useEstimateHouseAnalytics,
  useEstimateMutation,
  useEstimatePlacementHistory,
  useEstimateSellTimeSensitivity,
  useLocationIndex,
  useSalesVsListings,
  useStreetDeals,
} from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota";
import { useMe } from "@/lib/useMe";
import { useLogout } from "@/lib/useLogout";

// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
// non-interactive overlay drawn over the artboard gradient. The frame has
// chamfered octagonal corners (clip-path polygon) + 4px radius; the brackets
// sit just outside it at the artboard corners.
const FRAME_INSET = 14;
const BRACKET_INSET = 6;
const BRACKET_ARM = 22;
const BRACKET_W = 1.5;

const FRAME_CLIP =
  "polygon(18px 0,calc(100% - 18px) 0,100% 18px,100% calc(100% - 18px),calc(100% - 18px) 100%,18px 100%,0 calc(100% - 18px),0 18px)";

const brackets: { key: string; style: CSSProperties }[] = [
  {
    key: "tl",
    style: {
      top: BRACKET_INSET,
      left: BRACKET_INSET,
      borderTop: `${BRACKET_W}px solid ${tokens.bracket2}`,
      borderLeft: `${BRACKET_W}px solid ${tokens.bracket2}`,
    },
  },
  {
    key: "tr",
    style: {
      top: BRACKET_INSET,
      right: BRACKET_INSET,
      borderTop: `${BRACKET_W}px solid ${tokens.bracket2}`,
      borderRight: `${BRACKET_W}px solid ${tokens.bracket2}`,
    },
  },
  {
    key: "bl",
    style: {
      bottom: BRACKET_INSET,
      left: BRACKET_INSET,
      borderBottom: `${BRACKET_W}px solid ${tokens.bracket2}`,
      borderLeft: `${BRACKET_W}px solid ${tokens.bracket2}`,
    },
  },
  {
    key: "br",
    style: {
      bottom: BRACKET_INSET,
      right: BRACKET_INSET,
      borderBottom: `${BRACKET_W}px solid ${tokens.bracket2}`,
      borderRight: `${BRACKET_W}px solid ${tokens.bracket2}`,
    },
  },
];

// Honest neutral fallbacks for the meta blocks (HeroBar / Footer) before there
// is an estimate. Dashes — never the design fixtures (which would read as a fake
// real report).
const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—" };
const EMPTY_OBJECT: ObjectInfo = {
  address: "—",
  city: "",
  area: "—",
  rooms: "—",
  floor: "—",
  totalFloors: "—",
  year: "—",
  houseType: "—",
  repair: "—",
  balcony: false,
  locationIndexLabel: "—",
  locationIndexOk: false,
  lat: null,
  lon: null,
};

// Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) +
// retry-button hover.
const HELPER_STYLES = `
@keyframes v2-skel-pulse{0%{opacity:.45}50%{opacity:.78}100%{opacity:.45}}
.v2-skel{animation:v2-skel-pulse 1.5s ease-in-out infinite}
.v2-retry-btn{transition:all .15s;cursor:pointer}
.v2-retry-btn:hover{border-color:${tokens.accent};color:${tokens.accent};background:rgba(46,139,255,.07)}
`;

const retryBtnStyle: CSSProperties = {
  padding: "8px 16px",
  background: "transparent",
  border: `1px solid ${tokens.line}`,
  borderRadius: 6,
  fontFamily: tokens.font.sans,
  fontSize: 11,
  fontWeight: 600,
  letterSpacing: 1.5,
  color: tokens.muted,
};

// ── Result/summary area state panels (kept inside the page — these are layout
//    states, not reusable design components, so they do not belong in v2/). ───

function SkeletonBlock({ height, flex }: { height?: number; flex?: number }) {
  return (
    <div
      className="v2-skel"
      style={{
        height,
        flex,
        minHeight: 0,
        background: tokens.surface.w50,
        border: `1px solid ${tokens.line2}`,
        borderRadius: 8,
      }}
    />
  );
}

function ResultSkeleton() {
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: 14,
        minWidth: 0,
        height: "100%",
      }}
    >
      <SkeletonBlock height={18} />
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "1fr 1fr 1fr",
          gap: 14,
          flex: "0 0 auto",
        }}
      >
        <SkeletonBlock height={150} />
        <SkeletonBlock height={150} />
        <SkeletonBlock height={150} />
      </div>
      <SkeletonBlock height={150} />
      <SkeletonBlock flex={1} />
    </div>
  );
}

function SummarySkeleton() {
  return (
    <div
      className="v2-skel"
      style={{
        height: "100%",
        background: tokens.surface.w50,
        border: `1px solid ${tokens.line2}`,
        borderRadius: 8,
      }}
    />
  );
}

function PlaceholderPanel({
  title,
  body,
  children,
}: {
  title: string;
  body: string;
  children?: ReactNode;
}) {
  return (
    <div
      style={{
        height: "100%",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        textAlign: "center",
        gap: 12,
        background: tokens.surface.w50,
        backdropFilter: "blur(6px)",
        border: `1px solid ${tokens.line2}`,
        borderRadius: 8,
        padding: "40px 28px",
        fontFamily: tokens.font.sans,
      }}
    >
      {/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
      <h2
        style={{
          margin: 0,
          fontSize: 13,
          fontWeight: 600,
          letterSpacing: 2,
          color: tokens.ink,
        }}
      >
        {title}
      </h2>
      <div
        style={{
          fontSize: 12,
          color: tokens.muted,
          maxWidth: 360,
          lineHeight: 1.5,
        }}
      >
        {body}
      </div>
      {children}
    </div>
  );
}

function EmptyResultPanel() {
  return (
    <PlaceholderPanel
      title="РЕЗУЛЬТАТ ОЦЕНКИ"
      body="Заполните параметры квартиры слева и нажмите «ОЦЕНИТЬ КВАРТИРУ» — здесь появится рыночная оценка, диапазоны цен и источники данных."
    />
  );
}

function InsufficientPanel() {
  return (
    <PlaceholderPanel
      title="НЕДОСТАТОЧНО ДАННЫХ"
      body="По этому адресу не нашлось достаточного числа аналогов и сделок для надёжной оценки. Уточните адрес или параметры квартиры."
    />
  );
}

function RestoreErrorPanel({
  notFound,
  onRetry,
  onNew,
}: {
  notFound: boolean;
  onRetry: () => void;
  onNew: () => void;
}) {
  return (
    <PlaceholderPanel
      title={notFound ? "ОТЧЁТ НЕ НАЙДЕН" : "ОШИБКА ЗАГРУЗКИ"}
      body={
        notFound
          ? "Ссылка устарела, отчёт удалён или у вас нет к нему доступа."
          : "Не удалось загрузить отчёт. Попробуйте ещё раз."
      }
    >
      <div style={{ display: "flex", gap: 10, marginTop: 4 }}>
        {!notFound && (
          <button
            type="button"
            className="v2-retry-btn"
            onClick={onRetry}
            style={retryBtnStyle}
          >
            ПОВТОРИТЬ
          </button>
        )}
        <button
          type="button"
          className="v2-retry-btn"
          onClick={onNew}
          style={retryBtnStyle}
        >
          НОВАЯ ОЦЕНКА
        </button>
      </div>
    </PlaceholderPanel>
  );
}

function SummaryPlaceholder() {
  return (
    <div
      style={{
        height: "100%",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        textAlign: "center",
        gap: 8,
        background: tokens.surface.w50,
        backdropFilter: "blur(6px)",
        border: `1px solid ${tokens.line2}`,
        borderRadius: 8,
        padding: "28px 18px",
        fontFamily: tokens.font.sans,
      }}
    >
      {/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
      <h2
        style={{
          margin: 0,
          fontSize: 12,
          fontWeight: 600,
          letterSpacing: 1.5,
          color: tokens.ink,
        }}
      >
        СВОДКА ОБЪЕКТА
      </h2>
      <div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
        Появится после оценки квартиры.
      </div>
    </div>
  );
}

// Restore-by-id: read ?id= from the URL with an SSR guard. Mirrors v1 (app/
// page.tsx) deliberately — next/navigation useSearchParams would force a
// Suspense boundary and break `next build`.
function readUrlId(): string | null {
  if (typeof window === "undefined") return null;
  return new URLSearchParams(window.location.search).get("id");
}

export default function TradeInV2Page() {
  const router = useRouter();
  const queryClient = useQueryClient();

  const [nav, setNav] = useState(0);
  const [drawerOpen, setDrawerOpen] = useState(false);

  // #2264 C7: after a fresh estimate lands we move focus onto the result region
  // (a labelled, tabIndex={-1} container in ResultPanel) so keyboard/SR users
  // don't get dumped back on <body>. `focusedEstimateRef` guards against
  // re-focusing on unrelated re-renders / sub-hook updates.
  const resultRegionRef = useRef<HTMLDivElement>(null);
  const focusedEstimateRef = useRef<string | null>(null);
  // Locally-held just-computed estimate (so we render instantly after submit
  // without a round-trip through the restore query).
  const [freshResult, setFreshResult] = useState<AggregatedEstimate | null>(
    null,
  );
  // #2424 fix — router.replace(`?id=`) is an async client-navigation (RSC
  // round-trip) that does NOT update window.location.search synchronously, but
  // the onSelectEstimate handler below also calls setNav(0) in the same tick,
  // which DOES force an immediate synchronous re-render. Without an override,
  // that re-render's readUrlId() call reads the stale (pre-navigation) URL —
  // and since this page has no other reactive subscription to the URL, the
  // stale value is locked in forever (mirrors how `freshResult` already
  // overrides the same race for the post-submit path below). Cleared once
  // `restored.data` actually reflects this id (see effect below).
  //
  // Three states, not two: `undefined` means "no override, defer to
  // readUrlId()"; `null` means "override to no-id" (handleNew's reset);
  // a string means "override to this id" (a row was just clicked). Using
  // plain `null` for both "no override" and "force no-id" was tried first and
  // reintroduced this exact race for handleNew(): forcing pendingRestoreId
  // from a bad id to `null` would, under `pendingRestoreId ?? readUrlId()`,
  // fall through to readUrlId() on the very next render — which is still
  // stale until router.replace("/v2") finishes — so the bad id came right
  // back. The two intents need distinguishable values.
  const [pendingRestoreId, setPendingRestoreId] = useState<
    string | null | undefined
  >(undefined);

  const urlId = pendingRestoreId !== undefined ? pendingRestoreId : readUrlId();

  // Post-mount flag: readUrlId() is null on SSR but a UUID on the client's first
  // render, so any structure that depends on the id (e.g. the PDF <a> vs disabled
  // <button>) would diverge between SSR and hydration on a fresh /v2?id= load.
  // Gate id-dependent structure behind `mounted` to keep first render identical.
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);

  // Scale-fit: shrink the fixed 1536×1024 artboard to fit smaller viewports
  // instead of clipping. Never upscale (capped at 1). #2275: this used to floor
  // at 0.88, which on a 390px phone still rendered the artboard ~1350px wide —
  // page-wide horizontal scroll + a hero clipped at the left edge + the summary
  // rail pushed off-screen. Width is now the only constraint (a scaled artboard
  // taller than the viewport just scrolls the page vertically, which is normal
  // and not the bug being fixed here).
  const [artboardScale, setArtboardScale] = useState(1);
  // #2275: below this viewport width the fixed-width artboard can no longer
  // read at a usable size even scaled-to-fit, so HeroBar + ObjectSummary (the
  // "at a glance" content — report meta, PDF CTA, per-section totals) get a real
  // fluid mobile block above the artboard instead of a tiny scaled-down copy.
  // The rest of the dashboard (ParamsPanel / ResultPanel / overlays) stays
  // inside the scaled artboard — smaller on phones, but no longer overflowing.
  const [isMobile, setIsMobile] = useState(false);
  // Below this width the fixed 1536px artboard scales down to an unreadable
  // ~25% and there is no responsive layout yet. Rather than ship that broken
  // scaled-down HUD, render an honest "open on desktop" notice instead (see the
  // early return below). Starts false so SSR + first client render match the
  // desktop tree; the effect flips it post-hydration → no hydration mismatch.
  const [isSmallViewport, setIsSmallViewport] = useState(false);
  useEffect(() => {
    const compute = () => {
      setArtboardScale(Math.min(1, window.innerWidth / 1536));
      setIsMobile(window.innerWidth < 768);
      setIsSmallViewport(window.innerWidth < 1024);
    };
    compute();
    window.addEventListener("resize", compute);
    return () => window.removeEventListener("resize", compute);
  }, []);

  const mutation = useEstimateMutation();
  // Skip the restore fetch once we already hold a fresh result.
  const restored = useEstimate(freshResult === null ? urlId : null);

  // L3 — once the restore-by-id fetch has confirmed the estimate does not
  // exist (404), `currentEstimateId` below drops to null so the sibling
  // dashboard hooks (analytics/location-index/placement-history/sell-time,
  // all `enabled: estimate_id !== null`) don't each fire their own doomed
  // request against the same dead id. Hoisted above the sub-hooks (was
  // computed further down, after they'd already fired on the stale id).
  const restoreActive = urlId !== null && freshResult === null;
  const restoreError = restoreActive && restored.isError;
  const restoreNotFound =
    restoreError &&
    restored.error instanceof HTTPError &&
    restored.error.status === 404;

  const estimate: AggregatedEstimate | null =
    freshResult ?? restored.data ?? null;
  const currentEstimateId = restoreNotFound
    ? null
    : (freshResult?.estimate_id ?? urlId);

  // Clear the pendingRestoreId override once BOTH (a) the restore query has
  // resolved data for that same id AND (b) window.location.search has
  // genuinely caught up to it — only then is readUrlId() alone guaranteed to
  // keep returning the right value, so the override is no longer needed.
  // The (b) check guards a narrow edge case: if the query cache already held
  // a fresh (within staleTime) response for the clicked id — e.g. it was the
  // just-submitted/just-restored estimate earlier in the same SPA session —
  // `restored.data` can resolve synchronously, before router.replace's async
  // navigation has actually updated the URL. Clearing on (a) alone would then
  // drop the override a beat too early and briefly re-expose the very race
  // this state exists to prevent. If (b) isn't true yet, we simply leave the
  // (still-correct) override in place — harmless, since it already matches
  // what the URL will become — rather than risk reverting to a stale read.
  useEffect(() => {
    if (
      pendingRestoreId !== undefined &&
      pendingRestoreId !== null &&
      restored.data?.estimate_id === pendingRestoreId &&
      readUrlId() === pendingRestoreId
    ) {
      setPendingRestoreId(undefined);
    }
  }, [pendingRestoreId, restored.data]);

  // Per-user side data, independent of the current estimate. Both fail-soft
  // (retry:false): history feeds the «Предыдущие оценки» overlay (04), quota
  // feeds the TopNav «Мои отчёты» badge. Refreshed after a successful estimate
  // via invalidateQueries in handleSubmit.
  const history = useEstimateHistory();
  const quota = useQuota();
  // Real logged-in identity for the TopNav user menu (replaces the design
  // fixture). /me returns {username, role, brand, display_name, org, email}
  // (#2046) — known profile fields fall back to username / brand ?? role / ""
  // when absent (never invented). undefined while loading → TopNav «Гость».
  const me = useMe();
  // #2555: session-logout (POST /api/v1/auth/logout + local /me-cache
  // invalidate + redirect на /login) — replaces the legacy basic_auth-cache
  // -bust `logout()` for the v2 TopNav (new /login-form users).
  const logoutMutation = useLogout();

  // Dashboard sub-hooks — each resolves independently; failure degrades its
  // section via the mappers (null input) rather than blanking the page.
  const streetDeals = useStreetDeals(
    estimate?.target_address ?? null,
    estimate?.area_m2 ?? null,
    estimate?.rooms ?? null,
  );
  const analytics = useEstimateHouseAnalytics(currentEstimateId);
  // LocationDrawer + HeroBar «ЛОКАЦИЯ». Same independent-resolve contract: a
  // pending/errored/degraded response resolves to the mapper's own honest,
  // distinct reason (mapObject/mapLocation) — never a fabricated percent.
  const locationIndex = useLocationIndex(currentEstimateId);
  // Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same
  // contract: each resolves independently; a pending/errored one degrades its
  // overlay section to an honest empty via the mapper (null input).
  const placementHistory = useEstimatePlacementHistory(currentEstimateId);
  const salesVsListings = useSalesVsListings(
    estimate?.target_address ?? null,
    estimate?.area_m2 ?? null,
    estimate?.rooms ?? null,
  );
  const sellTime = useEstimateSellTimeSensitivity(currentEstimateId);

  const streetDealsData = streetDeals.data ?? null;
  const analyticsData = analytics.data ?? null;
  const locationIndexData = locationIndex.data ?? null;
  const placementHistoryData = placementHistory.data ?? null;
  const salesVsListingsData = salesVsListings.data ?? null;
  const sellTimeData = sellTime.data ?? null;

  // ── Derived state flags ───────────────────────────────────────────────────
  const restoreLoading = restoreActive && restored.isPending;

  const loading = mutation.isPending || restoreLoading;
  const insufficient =
    estimate != null &&
    (estimate.insufficient_data || estimate.n_analogs === 0);
  const apiError = mutation.error?.message ?? null;
  // M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line,
  // «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate.
  // Folds in `mounted` so SSR and the first client render agree (false → hidden)
  // — no hydration drift, same reason the PDF control is gated behind `mounted`.
  const hasEstimate = mounted && estimate != null && !insufficient;

  // ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
  //    geometry). ──────────────────────────────────────────────────────────
  const report = useMemo(
    () => (estimate ? mapReport(estimate) : EMPTY_REPORT),
    [estimate],
  );
  const objectInfo = useMemo(
    () => (estimate ? mapObject(estimate, locationIndexData) : EMPTY_OBJECT),
    [estimate, locationIndexData],
  );
  const locationData = useMemo(
    () => mapLocation(locationIndexData),
    [locationIndexData],
  );
  const resultPanelData = useMemo(
    () => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
    [estimate, streetDealsData],
  );
  const summaryData = useMemo(
    () =>
      estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null,
    [estimate, analyticsData, streetDealsData],
  );

  // Move focus to the result region once a user-submitted estimate is rendered.
  // Gated on `freshResult` (a submit, not a restore-by-id cold load, which must
  // not steal focus) and on the result actually being on screen (!loading +
  // resultPanelData). Fires once per distinct estimate id.
  useEffect(() => {
    if (
      freshResult &&
      !loading &&
      hasEstimate &&
      resultPanelData &&
      focusedEstimateRef.current !== freshResult.estimate_id
    ) {
      focusedEstimateRef.current = freshResult.estimate_id;
      resultRegionRef.current?.focus();
    }
  }, [freshResult, loading, hasEstimate, resultPanelData]);

  // ── Overlay (section) mapped data. Computed unconditionally: the mappers
  //    accept nulls and degrade per-section, so an overlay opened before/without
  //    an estimate shows an honest empty shell — never the design fixtures as if
  //    they were a real report. ─────────────────────────────────────────────
  const historyData = useMemo<HistoryData>(
    () =>
      mapHistory(placementHistoryData, streetDealsData, salesVsListingsData),
    [placementHistoryData, streetDealsData, salesVsListingsData],
  );
  const analyticsViewData = useMemo<Analytics>(
    () => mapAnalytics(analyticsData, sellTimeData, estimate),
    [analyticsData, sellTimeData, estimate],
  );
  const sourcesData = useMemo<SourcesData>(
    () => mapSources(estimate, streetDealsData),
    [estimate, streetDealsData],
  );
  // «Предыдущие оценки» (04) — KPIs + last-estimates table derived purely from
  // the per-user /history list (never /cache-stats, which is global/admin).
  const cacheData = useMemo<CacheData>(
    () => mapCache(history.data ?? null),
    [history.data],
  );

  // TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the
  // authoritative monthly counter; fall back to the loaded history length, then
  // to the fixture default (undefined) for unwired/pre-load states.
  const reportsCount = quota.data?.used ?? history.data?.length;

  // Real user for the TopNav menu. undefined until /me resolves → TopNav shows
  // its neutral «Гость» fallback (never the old "Андрей Петров / Брусника").
  // #2046: display_name/org/email — реальный профиль, если backend его знает
  // (см. app.core.auth._USERNAME_PROFILE); иначе фолбэк на username/brand?role/"".
  const topNavUser = useMemo(() => {
    const scope = me.data;
    if (!scope) return undefined;
    const nameParts =
      scope.display_name?.trim().split(/\s+/).filter(Boolean) ?? [];
    const initials =
      nameParts.length >= 2
        ? (nameParts[0][0] + nameParts[1][0]).toUpperCase()
        : nameParts.length === 1
          ? nameParts[0].slice(0, 2).toUpperCase()
          : scope.username.slice(0, 2).toUpperCase();
    return {
      name: scope.display_name ?? scope.username,
      org: scope.org ?? scope.brand ?? scope.role,
      email: scope.email ?? "",
      initials,
    };
  }, [me.data]);

  // Analog price pins for the 01 map, projected from the real estimate. No
  // estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
  // (Finding #2: never the static fixture price markers).
  const markers = useMemo(() => mapMarkers(estimate), [estimate]);

  // Prefill for restore-by-id / re-estimate. Keyed remount applies it when the
  // estimate arrives async (useState reads initialValues only on mount).
  const initialValues = useMemo<Partial<TradeInEstimateInput> | undefined>(
    () =>
      estimate
        ? {
            address: estimate.target_address ?? undefined,
            area_m2: estimate.area_m2 ?? undefined,
            rooms: estimate.rooms ?? undefined,
            floor: estimate.floor,
            total_floors: estimate.total_floors,
            year_built: estimate.year_built ?? undefined,
            house_type: estimate.house_type ?? undefined,
            repair_state: estimate.repair_state ?? undefined,
            has_balcony: estimate.has_balcony ?? undefined,
            // Without these the 01 map falls back to its "pick an address"
            // placeholder on every restored estimate (shared link, history,
            // PDF flow) even though the address field is populated — the map
            // keys off coords, not the address string, and only the geocode
            // suggestion path used to supply them.
            lat: estimate.target_lat ?? undefined,
            lon: estimate.target_lon ?? undefined,
          }
        : undefined,
    [estimate],
  );

  function handleSubmit(input: TradeInEstimateInput) {
    mutation.mutate(input, {
      onSuccess: (est) => {
        setFreshResult(est);
        // A fresh submission always supersedes any pending restore-by-id
        // override (#2424 fix) — drop it so it can't shadow a later restore.
        // `undefined` (not `null`): there is no override to force, just none.
        setPendingRestoreId(undefined);
        // A new estimate consumed quota and added a history row — refresh both
        // so the TopNav badge + «Предыдущие оценки» overlay reflect it.
        void queryClient.invalidateQueries({
          queryKey: ["trade-in", "history"],
        });
        void queryClient.invalidateQueries({
          queryKey: ["trade-in", "quota"],
        });
        // basePath auto-prefixes → /trade-in/v2?id=...
        router.replace(`/v2?id=${est.estimate_id}`, { scroll: false });
      },
    });
  }

  function handleRetry() {
    void restored.refetch();
  }

  function handleNew() {
    setFreshResult(null);
    // `null`, not `undefined`: forces urlId to no-id immediately, synchronously
    // clearing restoreActive/restoreError even before router.replace below
    // finishes and readUrlId() would otherwise still read the stale id.
    setPendingRestoreId(null);
    router.replace("/v2", { scroll: false });
  }

  let middleContent: ReactNode;
  if (loading) {
    middleContent = <ResultSkeleton />;
  } else if (restoreError) {
    middleContent = (
      <RestoreErrorPanel
        notFound={restoreNotFound}
        onRetry={handleRetry}
        onNew={handleNew}
      />
    );
  } else if (estimate && !insufficient && resultPanelData) {
    middleContent = (
      <ResultPanel
        data={resultPanelData}
        onNavigate={setNav}
        regionRef={resultRegionRef}
      />
    );
  } else if (estimate && insufficient) {
    middleContent = <InsufficientPanel />;
  } else {
    middleContent = <EmptyResultPanel />;
  }

  let rightContent: ReactNode;
  if (loading) {
    rightContent = <SummarySkeleton />;
  } else if (estimate && !insufficient && summaryData) {
    rightContent = (
      <ObjectSummary
        data={{ object: objectInfo, summary: summaryData }}
        onNavigate={setNav}
      />
    );
  } else {
    rightContent = <SummaryPlaceholder />;
  }

  // Polite SR announcement reflecting the estimate lifecycle. Same precedence as
  // middleContent: loading → error → insufficient → ready → silent.
  let statusMessage = "";
  if (loading) {
    statusMessage = "Идёт расчёт оценки…";
  } else if (restoreError) {
    statusMessage = "Ошибка загрузки отчёта";
  } else if (estimate && insufficient) {
    statusMessage = "Недостаточно данных для оценки";
  } else if (estimate && !insufficient) {
    statusMessage = "Оценка готова";
  }

  // Small-viewport guard: the HUD is a fixed 1536px artboard with no responsive
  // layout — under ~1024px it scales to an unreadable ~25%. Show an honest
  // desktop-only notice instead of the broken scaled-down copy. Placed after all
  // hooks so the early return never changes hook order. Desktop (≥1024) is
  // untouched.
  if (isSmallViewport) {
    return (
      <div
        style={{
          minHeight: "100vh",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          textAlign: "center",
          padding: "40px 24px",
          boxSizing: "border-box",
          gap: 14,
          fontFamily: tokens.font.sans,
          color: tokens.ink,
          background: tokens.gradientBg,
        }}
      >
        <h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: 0.3 }}>
          Мера оптимизирована для десктопа
        </h1>
        <p
          style={{
            fontSize: 14,
            lineHeight: 1.6,
            margin: 0,
            maxWidth: 360,
            color: tokens.body,
          }}
        >
          Откройте оценку на компьютере или расширьте окно браузера до 1024&nbsp;px
          и шире — на узком экране интерфейс отчёта нечитаем.
        </p>
      </div>
    );
  }

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        width: "100%",
      }}
    >
      {/* #2275 mobile quick-view — a real fluid, native-size block for the "at a
          glance" content (HeroBar's report meta + PDF CTA, ObjectSummary's
          per-section totals). Inside the scaled artboard below, these would read
          as tiny/illegible text on a phone rather than the honest mobile layout
          the design deserves — so under the mobile breakpoint they render here
          instead (and are skipped inside the artboard, see `!isMobile` below).
          The rest of the dashboard (ParamsPanel / ResultPanel / overlays) has no
          equivalent fluid mobile layout yet (follow-up) and stays inside the
          scaled artboard: smaller on phones, but — with the 0.88 floor removed
          above — never wider than the viewport. */}
      {isMobile && (
        <div
          style={{
            width: "100%",
            maxWidth: 480,
            margin: "0 auto",
            padding: "14px 14px 20px",
            boxSizing: "border-box",
            fontFamily: tokens.font.sans,
            color: tokens.ink,
          }}
        >
          <HeroBar
            compact
            data={{ report, object: objectInfo }}
            estimateId={mounted ? currentEstimateId : null}
            onOpenInfo={() => setDrawerOpen(true)}
            hasEstimate={hasEstimate}
          />
          <div style={{ marginTop: 14 }}>{rightContent}</div>
        </div>
      )}
      <div
        style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}
      >
        <div
          style={{
            position: "relative",
            width: 1536,
            height: 1024,
            overflow: "hidden",
            background: tokens.gradientBg,
            fontFamily: tokens.font.sans,
            color: tokens.ink,
            transform: `scale(${artboardScale})`,
            transformOrigin: "top left",
          }}
        >
          <style>{HELPER_STYLES}</style>

          {/* #2264 C5: the page's single visible "title" is the SVG logo in TopNav,
            so give the document a real, visually-hidden <h1> (page-has-heading-one
            + a top of the heading order for the overlay/panel <h2>s). Text mirrors
            the <title> in layout.tsx ("МЕРА · Оценка v2") so a screen-reader user
            landing on the page hears the same product framing as the tab title. */}
          <h1
            style={{
              position: "absolute",
              width: 1,
              height: 1,
              padding: 0,
              margin: -1,
              overflow: "hidden",
              clip: "rect(0 0 0 0)",
              whiteSpace: "nowrap",
              border: 0,
            }}
          >
            МЕРА · Оценка недвижимости
          </h1>

          {/* Polite, visually-hidden status announcer for SR users. */}
          <div
            aria-live="polite"
            role="status"
            style={{
              position: "absolute",
              width: 1,
              height: 1,
              padding: 0,
              margin: -1,
              overflow: "hidden",
              clip: "rect(0 0 0 0)",
              whiteSpace: "nowrap",
              border: 0,
            }}
          >
            {statusMessage}
          </div>

          {/* OUTER HUD FRAME */}
          <div
            style={{
              position: "absolute",
              inset: FRAME_INSET,
              border: `1px solid ${tokens.line}`,
              borderRadius: 4,
              clipPath: FRAME_CLIP,
              pointerEvents: "none",
            }}
          />
          {/* 4 corner brackets */}
          {brackets.map((b) => (
            <div
              key={b.key}
              style={{
                position: "absolute",
                width: BRACKET_ARM,
                height: BRACKET_ARM,
                pointerEvents: "none",
                ...b.style,
              }}
            />
          ))}

          {/* CONTENT WRAP */}
          <div
            style={{
              position: "absolute",
              inset: 14,
              padding: "16px 28px",
              display: "flex",
              flexDirection: "column",
            }}
          >
            {/* TopNav stays interactive while a section overlay is open so the
              user can switch sections directly from the overlay. Only the
              LocationDrawer (a full modal over the whole HUD) inerts it. The
              dashboard body (<main>/<footer>) is still inerted behind ANY
              overlay/drawer — preserving the focus + click guard from a11y
              audit #2081 for everything except the top tabs. */}
            <nav
              aria-label="Разделы"
              inert={drawerOpen ? true : undefined}
              style={{ display: "contents" }}
            >
              <TopNav
                active={nav}
                onNavigate={setNav}
                reports={reportsCount ?? 0}
                user={topNavUser}
                onLogout={() => logoutMutation.mutate()}
              />
            </nav>
            <main
              inert={nav !== 0 || drawerOpen ? true : undefined}
              style={{ display: "contents" }}
            >
              {/* #2275: on mobile the readable HeroBar lives in the fluid
                quick-view block above instead of a tiny scaled-down copy. */}
              {!isMobile && (
                <HeroBar
                  data={{ report, object: objectInfo }}
                  estimateId={mounted ? currentEstimateId : null}
                  onOpenInfo={() => setDrawerOpen(true)}
                  hasEstimate={hasEstimate}
                />
              )}

              <div
                style={{
                  display: "grid",
                  gridTemplateColumns: "474px 1fr 250px",
                  gap: 18,
                  flex: 1,
                  minHeight: 0,
                }}
              >
                <ParamsPanel
                  key={estimate?.estimate_id ?? "new"}
                  onSubmit={handleSubmit}
                  isPending={mutation.isPending}
                  hasEstimate={hasEstimate}
                  error={apiError}
                  initialValues={initialValues}
                  markers={markers}
                />
                {middleContent}
                {/* #2275: on mobile ObjectSummary is rendered fluid in the
                  quick-view block above instead of here (tiny scaled copy). */}
                {!isMobile && rightContent}
              </div>
            </main>

            <footer
              inert={nav !== 0 || drawerOpen ? true : undefined}
              style={{ display: "contents" }}
            >
              <Footer data={report} hasEstimate={hasEstimate} />
            </footer>
          </div>

          {nav !== 0 && (
            <SectionOverlay
              active={nav}
              onClose={() => setNav(0)}
              onNavigate={setNav}
              history={historyData}
              analytics={analyticsViewData}
              sources={sourcesData}
              cache={cacheData}
              estimate={estimate}
              onSelectEstimate={(id) => {
                // #2420 — same ?id= restore-by-id shape as handleSubmit/handleNew
                // above (line ~649); this file uses replace() consistently for
                // every /v2?id= transition, so we follow that convention here
                // too rather than introducing the file's only push() call.
                //
                // #2424 fix: router.replace() is an async client-navigation —
                // window.location.search does NOT update synchronously — but
                // setNav(0) below DOES force an immediate synchronous
                // re-render in the same tick. Without pendingRestoreId,
                // readUrlId() on that re-render would read the stale
                // (pre-navigation) URL and — since this page has no other
                // reactive subscription to the URL — that stale value would
                // be locked in forever. setPendingRestoreId synchronously
                // hands `urlId` the correct target id, mirroring how
                // `freshResult` already overrides this same race for the
                // post-submit path (handleSubmit above).
                setPendingRestoreId(id);
                router.replace(`/v2?id=${id}`, { scroll: false });
                setNav(0);
              }}
            />
          )}
          <LocationDrawer
            open={drawerOpen}
            onClose={() => setDrawerOpen(false)}
            data={locationData}
          />
        </div>
      </div>

      {/* Lead form (issue #2395 — v2-native mount of #2377's lead capture,
          previously stranded in the dead HeroTransparency/HeroSummary tree).
          Lives outside the scaled 1536×1024 artboard transform (in normal page
          flow) so it stays legible at any viewport/scale instead of shrinking
          with the HUD; only shown once a real, sufficient estimate is on
          screen — never on the empty/insufficient-data states. */}
      {hasEstimate && <LeadForm estimateId={currentEstimateId} />}
    </div>
  );
}
