// Pure API -> presentation mappers for the /trade-in/v2 "МЕРА Оценка" HUD port.
//
// These functions take the real trade-in API objects (AggregatedEstimate,
// StreetDealsResponse, HouseAnalyticsResponse) and produce the EXACT fixture
// shapes the v2 components already consume (see ./types + ./fixtures). The Wire
// phase swaps each component's hardcoded fixture for a `data` prop fed by one of
// these mappers; the fixtures stay as the prop DEFAULT so unwired usage still
// renders. ALL money/format/geometry lives here — never copy fixture literals.
//
// Reconciliations / known FE-approximations (look for "TODO BE-N"):
//   BE-1  #2043 shipped 2026-07-02: AggregatedEstimate now carries real
//         cv/source_counts/created_at. mapSources' marketAds.kpi.cv (#2041)
//         prefers estimate.cv and only falls back to the FE coefficient of
//         variation (analog ₽/м²) for pre-#2043 cached estimates where cv is
//         absent. mapReport now prefers real created_at too (falls back to the
//         expires−24h shift only for pre-BE-1 rows without it). mapResultPanel/
//         mapSummary/mapCache still use the other pre-BE-1 approximations (FE
//         source groupBy) — out of scope for #2040/#2041, left as-is.
//   BE-2  target_address is a single string; street/city are split heuristically
//         (parseAddress). Backend should return structured address components.
//   BE-3  location index (replacing the broken location-coef, see
//         backend/app/services/location_index.py) is wired here
//         (mapObject/mapLocation consume GET /trade-in/location-index).
//
// Enum <-> RU reconciliation (design dropdowns have options with no enum value):
//   house type: 'Блочный'    ⇄ enum 'other'  (enum has no dedicated block type)
//   repair    : 'Без отделки' → enum 'needs_repair' (no-finish ≈ needs repair)
// See HOUSE_TYPE_RU / HOUSE_TYPE_FROM_RU / REPAIR_RU / REPAIR_FROM_RU below.

import type {
  AggregatedEstimate,
  AnalogLot,
  ConfidenceLevel,
  EstimateHistoryItem,
  HouseAnalyticsKpi,
  HouseAnalyticsResponse,
  HouseType,
  LocationIndexResponse,
  PlacementHistoryItem,
  RepairState,
  SalesVsListingsResponse,
  SellTimeSensitivityResponse,
  StreetDealsResponse,
} from "@/types/trade-in";
import type {
  AdRow,
  Analytics,
  AnalyticsKpi,
  AxisTickX,
  AxisTickY,
  CacheKpi,
  CacheRow,
  DealRow,
  DkpRow,
  History,
  HouseSaleRow,
  MarketAds,
  MarketDeals,
  ObjectInfo,
  PriceHistory,
  RangeBar,
  RangeMarker,
  Ranges,
  Report,
  ResultCard,
  ResultMeta,
  ScatterDetail,
  ScatterMini,
  ScatterPoint,
  SellTimeTier,
  SourceCard,
  Summary,
  SummaryRow,
} from "./types";
import { tokens } from "./tokens";
import { sourceLabel as registrySourceLabel } from "@/lib/source-registry";

// Total number of source slots — meta "N / M". Single source of truth is
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).

// ── Composite presentation shapes consumed by the wired components ──────────
// One `data` prop per component; each field keeps its exact ./types shape.

export interface ResultPanelData {
  cards: ResultCard[];
  meta: ResultMeta;
  ranges: Ranges;
  scatterMini: ScatterMini;
  sources: SourceCard[];
}

export interface HeroBarData {
  report: Report;
  object: ObjectInfo;
}

export interface ObjectSummaryData {
  object: ObjectInfo;
  summary: Summary;
}

// ── Enum <-> RU maps ────────────────────────────────────────────────────────

// enum -> display label (mapObject). 'other' surfaces as "Блочный" so it matches
// a real dropdown option and round-trips via HOUSE_TYPE_FROM_RU.
export const HOUSE_TYPE_RU: Record<HouseType, string> = {
  panel: "Панельный",
  brick: "Кирпичный",
  monolith: "Монолитный",
  monolith_brick: "Монолитно-кирпичный",
  other: "Блочный",
};

// enum -> display label (mapObject).
export const REPAIR_RU: Record<RepairState, string> = {
  needs_repair: "Требует ремонта",
  standard: "Удовлетворительный",
  good: "Хороший",
  excellent: "Отличный",
};

// enum -> RU lower-case (summary quality block; meta uses .toUpperCase()).
export const CONFIDENCE_RU: Record<ConfidenceLevel, string> = {
  low: "низкая",
  medium: "средняя",
  high: "высокая",
};

// RU dropdown label -> enum (Wire-phase form submit). Reconciles the design's
// extra options that have no enum value (see header comment).
export const HOUSE_TYPE_FROM_RU: Record<string, HouseType> = {
  Панельный: "panel",
  Кирпичный: "brick",
  Монолитный: "monolith",
  "Монолитно-кирпичный": "monolith_brick",
  Блочный: "other",
  Другой: "other",
};

export const REPAIR_FROM_RU: Record<string, RepairState> = {
  "Без отделки": "needs_repair",
  "Требует ремонта": "needs_repair",
  Удовлетворительный: "standard",
  Хороший: "good",
  Отличный: "excellent",
};

// ── Format helpers (ru) ─────────────────────────────────────────────────────

// Non-breaking space (U+00A0) — glues a number to its unit / pads range dashes so
// "6,56 млн ₽" / "1,2 – 3,4" never wrap mid-token.
const NBSP = " ";

/** rub -> млн with ru grouping, 2dp. 6557500 -> "6,56". null/NaN -> "—". */
export function fmtMln(rub: number | null | undefined): string {
  if (rub == null || !Number.isFinite(rub)) return "—";
  return (rub / 1e6).toLocaleString("ru-RU", {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });
}

/** ₽/м² -> "225 801 ₽/м²" (rounded, ru grouping). null/NaN -> "—". */
export function fmtPpm(ppm: number | null | undefined): string {
  if (ppm == null || !Number.isFinite(ppm)) return "—";
  return `${Math.round(ppm).toLocaleString("ru-RU")} ₽/м²`;
}

/** area -> "54,3 м²" (ru comma, ≤1dp, NBSP before unit). null/NaN -> "—". */
function fmtArea(area: number | null | undefined): string {
  if (area == null || !Number.isFinite(area)) return "—";
  return `${area.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}${NBSP}м²`;
}

/** ISO datetime -> "DD.MM.YYYY" (ru). Invalid/empty -> "—". */
export function fmtDate(iso: string | null | undefined): string {
  if (!iso) return "—";
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return "—";
  return d.toLocaleDateString("ru-RU", {
    day: "2-digit",
    month: "2-digit",
    year: "numeric",
  });
}

const RU_MONTHS = [
  "янв",
  "фев",
  "мар",
  "апр",
  "май",
  "июн",
  "июл",
  "авг",
  "сен",
  "окт",
  "ноя",
  "дек",
];
/** ISO -> "янв 2026" (Росреестр deal dates are month-granular; the day is fake). null/invalid -> "—". */
export function fmtMonthYear(iso: string | null | undefined): string {
  if (!iso) return "—";
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return "—";
  return `${RU_MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}

/** Same as fmtDate but shifted by `hours` (TODO BE-1 created_at = expires_at−24h). */
function fmtDateShift(iso: string | null | undefined, hours: number): string {
  if (!iso) return "—";
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return "—";
  d.setHours(d.getHours() + hours);
  return d.toLocaleDateString("ru-RU", {
    day: "2-digit",
    month: "2-digit",
    year: "numeric",
  });
}

/** Signed percent with proper ru minus. -3 -> "−3%", 5 -> "+5%", 0 -> "0%". */
export function fmtPct(v: number | null | undefined, digits = 0): string {
  if (v == null || !Number.isFinite(v)) return "—";
  const rounded = digits > 0 ? Number(v.toFixed(digits)) : Math.round(v);
  const sign = rounded < 0 ? "−" : rounded > 0 ? "+" : "";
  return `${sign}${Math.abs(rounded)}%`;
}

/** Russian plural picker. forms = [one, few, many]. */
export function pluralRu(n: number, forms: [string, string, string]): string {
  const a = Math.abs(n) % 100;
  const b = a % 10;
  if (a > 10 && a < 20) return forms[2];
  if (b > 1 && b < 5) return forms[1];
  if (b === 1) return forms[0];
  return forms[2];
}

// ── Numeric helpers ─────────────────────────────────────────────────────────

function clamp(v: number, lo: number, hi: number): number {
  return Math.max(lo, Math.min(hi, v));
}

function round1(v: number): number {
  return Math.round(v * 10) / 10;
}

function median(values: number[]): number | null {
  const clean = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
  if (clean.length === 0) return null;
  const mid = Math.floor(clean.length / 2);
  return clean.length % 2 ? clean[mid] : (clean[mid - 1] + clean[mid]) / 2;
}

/** Coefficient of variation (%) of positive values; null if <2 samples. */
function coeffVar(values: number[]): number | null {
  const clean = values.filter((v) => Number.isFinite(v) && v > 0);
  if (clean.length < 2) return null;
  const mean = clean.reduce((a, b) => a + b, 0) / clean.length;
  if (mean === 0) return null;
  const variance =
    clean.reduce((a, b) => a + (b - mean) ** 2, 0) / clean.length;
  return (Math.sqrt(variance) / mean) * 100;
}

// Robust ₽/м² outlier guard (Fix #8 / audit): a single mis-scraped or
// mislabeled lot (e.g. 872 093 ₽/м² next to a 177k pool median) silently
// blows up «РАЗБРОС ЦЕН» (CV) with no visible cause. Any price_per_m2 more
// than OUTLIER_MULT× the pool's OWN median is flagged as a "возможный
// выброс" (row badge, see mapSources) AND excluded from the spread calc below
// — never silently: the exclusion count is surfaced next to the number it
// affects (see mapSources' adsFootnote).
const OUTLIER_MULT = 2.75; // midpoint of the audited 2.5–3× band

/**
 * Splits `ppm2` (parallel to the source row array) into outlier-free values
 * for spread/median math and a same-length flag array for row badges.
 */
function guardPriceOutliers(ppm2: (number | null | undefined)[]): {
  clean: number[];
  isOutlier: boolean[];
  excludedCount: number;
} {
  const finite = ppm2.filter(
    (v): v is number => v != null && Number.isFinite(v) && v > 0,
  );
  const pool = median(finite);
  const isOutlier = ppm2.map((v) =>
    v != null && Number.isFinite(v) && v > 0 && pool != null && pool > 0
      ? v > pool * OUTLIER_MULT
      : false,
  );
  const clean = ppm2.filter(
    (v, i): v is number =>
      v != null && Number.isFinite(v) && v > 0 && !isOutlier[i],
  );
  return { clean, isOutlier, excludedCount: isOutlier.filter(Boolean).length };
}

// «Разброс цен» (§M8 / #1993): the внутренний term "CV" (coefficient of
// variation) is not surfaced to users anymore, and the витрина rounds it to a
// whole percent ("42%", not "42,2%") — the extra decimal implied a false
// precision for what is a rough spread indicator.
function cvStr(values: number[]): string {
  const cv = coeffVar(values);
  return cv != null
    ? `${cv.toLocaleString("ru-RU", {
        maximumFractionDigits: 0,
      })}%`
    : "—";
}

/** Format a 0..1 coefficient-of-variation ratio the same way as cvStr's %. */
function cvRatioStr(cv: number): string {
  return `${(cv * 100).toLocaleString("ru-RU", {
    minimumFractionDigits: 1,
    maximumFractionDigits: 1,
  })}%`;
}

/**
 * Analog ₽/м² spread, used by #2041's marketAds.kpi.cv. Prefers the real
 * backend estimate.cv (#2043/BE-1, 0..1 ratio) and only falls back to the FE
 * coefficient of variation for pre-#2043 cached estimates where cv is absent
 * (older persisted rows don't carry it — see the file-header TODO BE-1 note).
 */
function cvLabel(e: AggregatedEstimate | null): string {
  if (e == null) return "—";
  if (e.cv != null && Number.isFinite(e.cv)) return cvRatioStr(e.cv);
  return cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean);
}

/**
 * Bin `values` into 8 buckets across [min,max]; return per-bucket height as
 * count/maxCount*100 (the mini-histogram bar heights). Empty input -> [].
 */
export function bins8(values: number[]): number[] {
  const clean = values.filter((v) => Number.isFinite(v) && v > 0);
  if (clean.length === 0) return [];
  const min = Math.min(...clean);
  const max = Math.max(...clean);
  if (max === min) {
    // Degenerate: all equal -> single central bar.
    const bars = new Array<number>(8).fill(0);
    bars[3] = 100;
    return bars;
  }
  const span = max - min;
  const counts = new Array<number>(8).fill(0);
  for (const v of clean) {
    const idx = clamp(Math.floor(((v - min) / span) * 8), 0, 7);
    counts[idx] += 1;
  }
  const maxCount = Math.max(...counts);
  if (maxCount === 0) return [];
  return counts.map((c) => Math.round((c / maxCount) * 100));
}

export interface ScatterDomain {
  xMin: number;
  xMax: number;
  yMin: number;
  yMax: number;
}

export interface ScatterBox {
  left: number;
  right: number;
  top: number; // smaller pixel y (high value)
  bottom: number; // larger pixel y (low value)
}

/**
 * Project data points {x,y} into the SVG pixel box. Y is inverted (high value ->
 * top). Out-of-domain points are clamped into the box. Coords rounded to 1dp.
 */
export function scatterProject(
  pts: ReadonlyArray<{ x: number; y: number }>,
  domain: ScatterDomain,
  box: ScatterBox,
  r: number,
): ScatterPoint[] {
  const xSpan = domain.xMax - domain.xMin || 1;
  const ySpan = domain.yMax - domain.yMin || 1;
  return pts.map((p) => {
    const fx = clamp((p.x - domain.xMin) / xSpan, 0, 1);
    const fy = clamp((p.y - domain.yMin) / ySpan, 0, 1);
    return {
      x: round1(box.left + fx * (box.right - box.left)),
      y: round1(box.bottom - fy * (box.bottom - box.top)),
      r,
    };
  });
}

// ── Range marker (CSS % offsets) ────────────────────────────────────────────

function pctStr(n: number): string {
  return `${round1(n)}%`;
}

/**
 * Filled-segment offsets + median dot position for a price range bar.
 * Domain is [lo,hi] padded by `padFrac` on each side so the dot/segment have
 * breathing room. Degenerate ranges fall back to a centred dot.
 */
function rangeMarker(
  lo: number | null | undefined,
  med: number | null | undefined,
  hi: number | null | undefined,
  padFrac = 0.08,
): RangeMarker {
  if (
    lo == null ||
    med == null ||
    hi == null ||
    !Number.isFinite(lo) ||
    !Number.isFinite(med) ||
    !Number.isFinite(hi) ||
    hi <= lo
  ) {
    return { fillLeft: "6%", fillRight: "6%", dotLeft: "50%" };
  }
  const span = hi - lo;
  const pad = span * padFrac;
  const dMin = lo - pad;
  const dMax = hi + pad;
  const w = dMax - dMin;
  return {
    fillLeft: pctStr(((lo - dMin) / w) * 100),
    fillRight: pctStr(((dMax - hi) / w) * 100),
    dotLeft: pctStr(clamp(((med - dMin) / w) * 100, 0, 100)),
  };
}

/** "{lo} — {hi} млн ₽" range line for a result card. Missing -> "—". */
function rangeLine(
  lo: number | null | undefined,
  hi: number | null | undefined,
): string {
  if (
    lo == null ||
    hi == null ||
    !Number.isFinite(lo) ||
    !Number.isFinite(hi)
  ) {
    return "—";
  }
  return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}${NBSP}млн${NBSP}₽`;
}

// ── Address parsing (TODO BE-2) ─────────────────────────────────────────────

// NB: JS \b word boundaries do NOT work around Cyrillic (only ASCII \w), so the
// street keyword is delimited by start/space/dot/end explicitly, not \b.
const STREET_RE =
  /(^|\s)(ул|улица|пр|пр-?кт|проспект|пер|переулок|б-?р|бул|бульвар|ш|шоссе|наб|набережная|пл|площадь|проезд|тракт|мкр|микрорайон)(\s|\.|$)/i;
const CITY_PREFIX_RE = /^(г|город|пгт|с|село|д|деревня|рп)\.?\s+/i;

// House number token ("14", "14/3", "14А", "14 к2", "14 стр1"); leading дом/зд
// prefix stripped by houseClean before the test.
const HOUSE_RE = /^\d+[а-яА-Я]?(\/\d+)?(\s?к\.?\s?\d+)?(\s?стр\.?\s?\d+)?$/i;
// Administrative anchor — the city token typically sits right before one of these.
const ADMIN_ANCHOR_RE =
  /городск(ой|ая)|муниципальн|\bобласть\b|\bобл\b|\bкрай\b|\bреспублик/i;
// Explicit city token "г Екатеринбург" / "город …" / "пгт …" (capture name).
const CITY_TOKEN_RE = /^(?:г|город|пгт|гп)\.?\s+([А-Яа-яЁё].*)/;

/**
 * Best-effort split of a single address string into {street+house, city}.
 * Handles BOTH common geocoder orders (TODO BE-2 — structured address):
 *   OSM/Nominatim: "14/3, улица Яскина, …, Екатеринбург, городской округ …, область, …"
 *   DaData:        "г Екатеринбург, ул Яскина, д 14/3"
 */
function parseAddress(full: string | null): { address: string; city: string } {
  if (!full || !full.trim()) return { address: "—", city: "" };
  const parts = full
    .split(",")
    .map((s) => s.trim())
    .filter((p) => p.length > 0 && !/^\d{6}$/.test(p) && !/^Россия$/i.test(p));
  if (parts.length === 0) return { address: full.trim(), city: "" };

  // City: prefer an explicit г./город token; else the token right before the
  // first administrative anchor (городской округ / область / край).
  let city = "";
  const tokenMatch = parts.map((p) => p.match(CITY_TOKEN_RE)).find(Boolean);
  if (tokenMatch) {
    city = tokenMatch[1].trim();
  } else {
    const anchorIdx = parts.findIndex((p) => ADMIN_ANCHOR_RE.test(p));
    if (anchorIdx > 0)
      city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim();
  }

  // Address: street token + adjacent house number (house precedes the street in
  // OSM order, follows it in DaData order). Fall back to the first part.
  const houseClean = (p: string) =>
    p.replace(/^(?:д|дом|зд|здание)\.?\s+/i, "").trim();
  const streetIdx = parts.findIndex((p) => STREET_RE.test(p));
  let address: string;
  if (streetIdx >= 0) {
    const street = parts[streetIdx];
    const house = [parts[streetIdx - 1], parts[streetIdx + 1]]
      .filter((p): p is string => p != null)
      .map(houseClean)
      .find((p) => HOUSE_RE.test(p));
    address = house ? `${street}, ${house}` : street;
  } else {
    // No street keyword (prefix-less address, e.g. "Белинского, 86"). Don't
    // silently drop the house number: pair a house token with its preceding
    // street name; otherwise keep the full comma-joined string rather than
    // only parts[0] (which would lose the house).
    const houseIdx = parts.findIndex((p) => HOUSE_RE.test(houseClean(p)));
    address =
      houseIdx > 0
        ? `${parts[houseIdx - 1]}, ${houseClean(parts[houseIdx])}`
        : parts.join(", ");
  }
  return { address, city };
}

function roomsLabel(rooms: number | null): string {
  if (rooms == null) return "—";
  if (rooms <= 0) return "Студия";
  if (rooms >= 5) return "5+";
  return String(rooms);
}

function numLabel(v: number | null): string {
  return v == null || !Number.isFinite(v) ? "—" : String(v);
}

// ── Source slots (FE groupBy — TODO BE-1) ───────────────────────────────────
// Each design slot maps to one or more API source keys. count = number of
// analogs + actual_deals whose .source matches; active = key present in
// sources_used (or any lots found). 'АВИТО ОЦЕНКА' also covers the avito_imv
// anchor key (no lots of its own).
const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [
  { name: "ЦИАН", keys: ["cian"] },
  { name: "Я.НЕДВИЖИМОСТЬ", keys: ["yandex"] },
  { name: "РОСРЕЕСТР", keys: ["rosreestr"] },
  { name: "АВИТО", keys: ["avito", "avito_imv"] },
  { name: "ДОМКЛИК", keys: ["domklik", "domclick"] },
];
// #2081 round2: N1.RU + RESTATE dropped — мёртвые (всегда «нет данных», не
// учитываем). N1 с #2204 выключен полностью (backend + расписания). Домклик оставлен
// (живой источник, реально даёт лоты). TOTAL_SOURCES = единый счётчик «N / M»,
// чтобы плитки, счётчик и текст не расходились.
const TOTAL_SOURCES = SOURCE_SLOTS.length;

function buildSources(e: AggregatedEstimate): SourceCard[] {
  const counts = new Map<string, number>();
  for (const lot of [...e.analogs, ...e.actual_deals]) {
    const s = (lot.source ?? "").toLowerCase();
    if (!s) continue;
    counts.set(s, (counts.get(s) ?? 0) + 1);
  }
  const used = new Set(e.sources_used.map((s) => s.toLowerCase()));
  return SOURCE_SLOTS.map((slot) => {
    const count = slot.keys.reduce((sum, k) => sum + (counts.get(k) ?? 0), 0);
    const active = slot.keys.some((k) => used.has(k)) || count > 0;
    if (!active) {
      return {
        name: slot.name,
        count: "—",
        label: "нет данных",
        active: false,
      };
    }
    return {
      name: slot.name,
      count: count > 0 ? String(count) : "—",
      label:
        count > 0 ? pluralRu(count, ["лот", "лота", "лотов"]) : "нет данных",
      active: true,
    };
  });
}

/**
 * Canonical "active sources" count = number of filled source TILES (one per
 * design slot), so meta/quality/overlay all agree with what the user sees. NOT
 * e.sources_used.length (which can double-count alias keys / anchor-only sources).
 */
function activeSourceCount(e: AggregatedEstimate): number {
  return buildSources(e).filter((s) => s.active).length;
}

// ── Deal tier resolution (DKP / Росреестр) ──────────────────────────────────

interface DealTier {
  medianRub: number;
  loRub: number;
  hiRub: number;
  medianPpm: number;
  count: number;
  bars: number[];
}

/**
 * Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with
 * real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's
 * own actual_deals. null when no deal data at all.
 */
function resolveDealTier(
  e: AggregatedEstimate,
  sd?: StreetDealsResponse | null,
): DealTier | null {
  if (sd && sd.count > 0 && Number.isFinite(sd.median_price_rub)) {
    return {
      medianRub: sd.median_price_rub,
      loRub: sd.range_low_rub,
      hiRub: sd.range_high_rub,
      medianPpm: sd.median_price_per_m2,
      count: sd.count,
      // Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the
      // same way the ads card does — otherwise a single mis-scraped lot bins
      // over [min,max] and crushes the real deals into the left buckets.
      bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
    };
  }
  const area = e.area_m2;
  if (e.dkp_corridor && e.dkp_corridor.count > 0 && area && area > 0) {
    const c = e.dkp_corridor;
    return {
      medianRub: c.median_ppm2 * area,
      loRub: c.low_ppm2 * area,
      hiRub: c.high_ppm2 * area,
      medianPpm: c.median_ppm2,
      count: c.count,
      // Fix #1/#8: same high-outlier guard as the ads card (see above).
      bars: bins8(
        guardPriceOutliers(e.actual_deals.map((d) => d.price_per_m2)).clean,
      ),
    };
  }
  if (e.actual_deals.length > 0) {
    const prices = e.actual_deals.map((d) => d.price_rub);
    const ppms = e.actual_deals.map((d) => d.price_per_m2);
    const medRub = median(prices);
    const medPpm = median(ppms);
    if (medRub == null || medPpm == null) return null;
    return {
      medianRub: medRub,
      loRub: Math.min(...prices),
      hiRub: Math.max(...prices),
      medianPpm: medPpm,
      count: e.actual_deals.length,
      // Fix #1/#8: same high-outlier guard as the ads card (see above).
      bars: bins8(guardPriceOutliers(ppms).clean),
    };
  }
  return null;
}

// ── Scatter mini (ЦЕНА × СРОК ПРОДАЖИ) ──────────────────────────────────────
// Pixel box + x-tick positions taken from the design viewBox (168×140). X domain
// is a fixed 0–180 days so the x-ticks stay valid; Y domain is data-driven and
// the y-tick LABELS are recomputed for the chosen pixel positions.
const SCATTER_BOX: ScatterBox = { left: 34, right: 158, top: 13, bottom: 112 };
const SCATTER_X_DOMAIN = { min: 0, max: 180 };
const SCATTER_X_TICKS: AxisTickX[] = [
  { label: "0", x: 34 },
  { label: "90", x: 96 },
  { label: "180", x: 158 },
];
const SCATTER_Y_TICK_PX = [13, 77, 112]; // top, mid, bottom

function mlnTick(rub: number): string {
  return `${Math.round(rub / 1e6)}М`;
}

function buildScatterMini(
  e: AggregatedEstimate,
  sd?: StreetDealsResponse | null,
): ScatterMini {
  // Exposure (x-axis). days_on_market is sparsely populated in prod, so for
  // ACTIVE analogs fall back to "days listed so far" = today − listing_date
  // (a valid lower bound on time-to-sell). Deals keep requiring a real
  // days_on_market — today−deal-date is not exposure. TODO BE-1: populate days_on_market.
  const daysListed = (l: AnalogLot): number | null => {
    if (l.listing_date == null) return null;
    const t = new Date(l.listing_date).getTime();
    if (Number.isNaN(t)) return null;
    const days = Math.round((Date.now() - t) / 86_400_000);
    return days >= 0 && days < 3650 ? days : null;
  };
  const realDom = (l: AnalogLot): number | null =>
    l.days_on_market != null && Number.isFinite(l.days_on_market)
      ? l.days_on_market
      : null;
  const toPts = (lots: AnalogLot[], dom: (l: AnalogLot) => number | null) =>
    lots
      .map((l) => ({ x: dom(l), y: l.price_rub }))
      .filter(
        (p): p is { x: number; y: number } =>
          p.x != null && Number.isFinite(p.y),
      );

  const dealsRaw = toPts(
    sd && sd.deals.length > 0 ? sd.deals : e.actual_deals,
    realDom,
  );
  const analogsRaw = toPts(e.analogs, (l) => realDom(l) ?? daysListed(l));
  const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
  const subjectDays = e.est_days_on_market;

  const allY = [...dealsRaw, ...analogsRaw]
    .map((p) => p.y)
    .filter((y) => Number.isFinite(y));
  if (Number.isFinite(subjectPrice)) allY.push(subjectPrice);

  const fallbackTicks: AxisTickY[] = SCATTER_Y_TICK_PX.map((y) => ({
    label: "—",
    y,
  }));

  if (allY.length === 0) {
    return {
      deals: [],
      analogs: [],
      subject: { x: 96, y: 62, r: 3.6 },
      yTicks: fallbackTicks,
      xTicks: SCATTER_X_TICKS,
    };
  }

  let yMin = Math.min(...allY);
  let yMax = Math.max(...allY);
  if (yMin === yMax) {
    yMin *= 0.95;
    yMax *= 1.05;
  }
  const yPad = (yMax - yMin) * 0.08;
  // C3: clamp the domain floor to 0 (mirror buildPriceHistory) — an un-clamped
  // yMin-yPad can go negative, which surfaces as a "−1M" axis tick on a chart
  // that only ever plots prices.
  const domain: ScatterDomain = {
    xMin: SCATTER_X_DOMAIN.min,
    xMax: SCATTER_X_DOMAIN.max,
    yMin: Math.max(0, yMin - yPad),
    yMax: yMax + yPad,
  };

  const deals = scatterProject(dealsRaw, domain, SCATTER_BOX, 2.4);
  const analogs = scatterProject(analogsRaw, domain, SCATTER_BOX, 3);
  // Subject: real est_days if known, else mid-window (90 days).
  const subject = Number.isFinite(subjectPrice)
    ? scatterProject(
        [{ x: subjectDays ?? 90, y: subjectPrice }],
        domain,
        SCATTER_BOX,
        3.6,
      )[0]
    : { x: 96, y: 62, r: 3.6 };

  const yTicks: AxisTickY[] = SCATTER_Y_TICK_PX.map((py) => {
    const frac =
      (SCATTER_BOX.bottom - py) / (SCATTER_BOX.bottom - SCATTER_BOX.top);
    const price = domain.yMin + frac * (domain.yMax - domain.yMin);
    return { label: mlnTick(price), y: py };
  });

  return { deals, analogs, subject, yTicks, xTicks: SCATTER_X_TICKS };
}

// ── Public mappers ──────────────────────────────────────────────────────────

/**
 * Report header (HeroBar / Footer). id = first 8 of UUID; date = real
 * created_at (#2043/BE-1) when the estimate has one, falling back to the old
 * expires−24h approximation only for pre-BE-1 rows where created_at is absent.
 */
export function mapReport(e: AggregatedEstimate): Report {
  return {
    id: e.estimate_id.slice(0, 8),
    date: e.created_at
      ? fmtDate(e.created_at)
      : fmtDateShift(e.expires_at, -24), // pre-BE-1 fallback: no real created_at
    validUntil: fmtDate(e.expires_at),
  };
}

/**
 * Compact HeroBar badge for the location index — {label, ok} shared by
 * mapObject (ObjectInfo.locationIndexLabel/locationIndexOk) and available for
 * reuse. location_index_pct is a real % deviation of the local median ₽/м²
 * from the citywide median (see backend/app/services/location_index.py) — a
 * comparison metric, NOT a price multiplier, and it does NOT feed the
 * estimate. `ok: true` only for status="ok" (a trustworthy percent); every
 * other case gets a short, honest, DISTINCT reason instead of a blank dash —
 * the drawer (mapLocation below) expands on each:
 *   - li == null            → "нет данных" (not fetched yet / this estimate
 *     has no location index request enabled)
 *   - "out_of_coverage"     → "вне ЕКБ" (the index only covers Yekaterinburg)
 *   - "insufficient_data"   → "мало данных" (too few comparable listings)
 */
function locationIndexBadge(
  li: LocationIndexResponse | null | undefined,
): { label: string; ok: boolean } {
  if (li == null) return { label: "нет данных", ok: false };
  if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true };
  if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
  return { label: "мало данных", ok: false }; // status === "insufficient_data"
}

/**
 * Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block).
 * `locationIndex` is the GET /trade-in/location-index response — optional/null
 * while it is still loading or not requested for this address.
 */
export function mapObject(
  e: AggregatedEstimate,
  locationIndex?: LocationIndexResponse | null,
): ObjectInfo {
  const { address, city } = parseAddress(e.target_address);
  const badge = locationIndexBadge(locationIndex);
  return {
    address,
    city,
    area: numLabel(e.area_m2),
    rooms: roomsLabel(e.rooms),
    floor: numLabel(e.floor),
    totalFloors: numLabel(e.total_floors),
    year: numLabel(e.year_built),
    houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—",
    repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
    balcony: e.has_balcony ?? false,
    locationIndexLabel: badge.label,
    locationIndexOk: badge.ok,
    // Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use —
    // null while the estimate has no geocode yet.
    lat: e.target_lat,
    lon: e.target_lon,
  };
}

// ── Location index (LocationDrawer «ЛОКАЦИЯ») ───────────────────────────────
// RU category label per OSM POI type, mirroring the CATEGORY_WEIGHTS keys in
// backend/app/services/location_index.py (top-N straight-line POI ranking —
// a qualitative "what's nearby" list only, no longer feeding any score/coef).
// An unrecognised category (raw OSM tag outside that dict) falls back to a
// generic label rather than surfacing a raw enum-ish string to the user.
const POI_CATEGORY_RU: Record<string, string> = {
  metro_stop: "Метро",
  school: "Школа",
  kindergarten: "Детский сад",
  hospital: "Больница",
  shop_mall: "ТРЦ",
  shop_supermarket: "Супермаркет",
  bus_stop: "Остановка",
  park: "Парк",
  pharmacy: "Аптека",
  tram_stop: "Трамвайная остановка",
  shop_small: "Магазин у дома",
};
const POI_CATEGORY_FALLBACK = "Объект инфраструктуры";

function poiCategoryLabel(poiType: string): string {
  return POI_CATEGORY_RU[poiType] ?? POI_CATEGORY_FALLBACK;
}

/** One POI row in the LocationDrawer «что рядом» list. */
export interface LocationFactorRow {
  label: string; // POI name if known, else its RU category
  category: string; // RU category label (always present, for the badge)
  distance: string; // "150 м" / "1.2 км"
}

/**
 * LocationDrawer «ЛОКАЦИЯ» section data. `status` mirrors the backend's own
 * three-way honest-degradation contract ("loading" is an FE-only 4th state
 * for li == null, e.g. query still pending) so the drawer can explain EACH
 * case differently instead of collapsing them into one dash:
 *   - "ok"                index/medians reliable, show the real numbers.
 *   - "out_of_coverage"   address outside Yekaterinburg — the index simply
 *     doesn't cover it (not an error, not "no data").
 *   - "insufficient_data" too few comparable listings even at the widest
 *     radius — sampleSize/radiusLabel still say what was actually found.
 *   - "loading"           not fetched yet.
 * indexLabel/localMedianLabel/cityMedianLabel are "—" whenever the
 * corresponding backend field is null (never a fabricated number).
 */
export interface LocationData {
  status: "ok" | "out_of_coverage" | "insufficient_data" | "loading";
  indexLabel: string; // fmtPct(location_index_pct), "—" if not status="ok"
  localMedianLabel: string; // fmtPpm(local_median_price_per_m2), "—" if null
  cityMedianLabel: string; // fmtPpm(city_median_price_per_m2), "—" if null
  sampleSize: number; // comparable active listings actually found (0 if n/a)
  radiusLabel: string; // fmtDist(radius_m), "—" while loading
  poiAvailable: boolean; // poi_status === "ok" (independent of status above)
  factors: LocationFactorRow[];
}

/**
 * LocationDrawer «ЛОКАЦИЯ» section data from GET /trade-in/location-index.
 * null/undefined (not fetched yet) degrades to status="loading" — never a
 * fabricated index, price or factor list (mirrors the backend's own
 * graceful-fallback contract).
 */
export function mapLocation(
  li: LocationIndexResponse | null | undefined,
): LocationData {
  if (li == null) {
    return {
      status: "loading",
      indexLabel: "—",
      localMedianLabel: "—",
      cityMedianLabel: "—",
      sampleSize: 0,
      radiusLabel: "—",
      poiAvailable: false,
      factors: [],
    };
  }
  return {
    status: li.status,
    indexLabel: li.status === "ok" ? fmtPct(li.location_index_pct) : "—",
    localMedianLabel: fmtPpm(li.local_median_price_per_m2),
    cityMedianLabel: fmtPpm(li.city_median_price_per_m2),
    sampleSize: li.sample_size,
    radiusLabel: fmtDist(li.radius_m),
    poiAvailable: li.poi_status === "ok",
    factors: li.nearby_poi.map((p) => ({
      label: p.name?.trim() || poiCategoryLabel(p.poi_type),
      category: poiCategoryLabel(p.poi_type),
      distance: fmtDist(p.distance_m),
    })),
  };
}

// ── ParamsPanel map markers (analog price pins) ─────────────────────────────
// Finding #2: the 01 map showed STATIC fixture price pins (12,7 / 7,6 / 22,4 млн)
// that never reflected the real estimate. We project each analog with a known
// {lat,lon} relative to the subject (target_lat/lon) into the map's 0–100% box.
// There is no real basemap/zoom behind the SVG, so a fixed geographic half-span
// (~1.2 km) is used purely to spread nearby analogs; out-of-box analogs clamp to
// the 4–96% margin. Finding #12: the closest pins overlapped on the small map, so
// a greedy non-overlap pass keeps at most KEEP_CAP pins that are ≥ MIN_PIN_DIST
// apart (% units). null / empty / un-geocoded estimate → [] (the map shows just
// the subject pin, never the fake fixture prices).

/** One analog price pin positioned by CSS % offsets over the ParamsPanel map. */
export interface MapMarker {
  left: string; // e.g. "62%"
  top: string; // e.g. "31%"
  label: string; // "12,70 млн ₽"
  sub: string; // "54 м²"
}

// Half-span from the subject to a box edge (the 50% multiplier). ≈ 0.011° lat /
// 0.018° lon ≈ ~1.2 km at ЕКБ latitude (cos 56.8° ≈ 0.55). Tuneable: larger pulls
// analogs toward the centre, smaller pushes them to the clamped margins.
const MAP_LAT_HALF_SPAN = 0.011;
const MAP_LON_HALF_SPAN = 0.018;
const MAP_CLAMP_LO = 4;
const MAP_CLAMP_HI = 96;
// Greedy de-clutter (Finding #12): keep at most KEEP_CAP pins, each ≥ MIN_PIN_DIST
// from every already-kept pin (Euclidean in the 0–100% map box) so close analogs
// don't stack into an unreadable blob on the small map.
const MIN_PIN_DIST = 22;
const KEEP_CAP = 3;
// M10: the subject projects to the map centre (lat==tLat, lon==tLon → 50%,50%),
// where ParamsPanel draws the subject pin. Reserve that spot so no analog price
// pin stacks under the subject's address caption (which made the subject look
// labelled with an analog's area/price).
const SUBJECT_POS = { left: 50, top: 50 };

/** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */
export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
  if (e == null || e.target_lat == null || e.target_lon == null) return [];
  const tLat = e.target_lat;
  const tLon = e.target_lon;

  const geo = e.analogs.filter(
    (a): a is AnalogLot & { lat: number; lon: number } =>
      a.lat != null &&
      a.lon != null &&
      Number.isFinite(a.lat) &&
      Number.isFinite(a.lon),
  );
  if (geo.length === 0) return [];

  // Closest first: real distance_m when known, else a degree-based estimate
  // (lon scaled by cos≈0.55) so analogs without distance_m still order sensibly.
  const ranked = geo
    .map((a) => {
      const fallbackM =
        Math.hypot(a.lat - tLat, (a.lon - tLon) * 0.55) * 111_320;
      const dist =
        a.distance_m != null && Number.isFinite(a.distance_m)
          ? a.distance_m
          : fallbackM;
      return { a, dist };
    })
    .sort((x, y) => x.dist - y.dist);

  // Greedy non-overlap pass over the closest-first list: project each candidate
  // to numeric left/top %, keep it only when it sits ≥ MIN_PIN_DIST from every
  // pin already kept. Stop at KEEP_CAP. Positions stay numeric until the final
  // map, where they become the MapMarker "NN%" strings.
  const kept: { left: number; top: number; a: AnalogLot }[] = [];
  for (const { a } of ranked) {
    if (kept.length >= KEEP_CAP) break;
    const left = clamp(
      50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50,
      MAP_CLAMP_LO,
      MAP_CLAMP_HI,
    );
    const top = clamp(
      50 - ((a.lat - tLat) / MAP_LAT_HALF_SPAN) * 50,
      MAP_CLAMP_LO,
      MAP_CLAMP_HI,
    );
    // Drop analogs that land on the subject pin (centre) — otherwise the closest
    // analog's "21,2 млн / 80 м²" reads as the subject's caption (M10).
    const onSubject =
      Math.hypot(SUBJECT_POS.left - left, SUBJECT_POS.top - top) < MIN_PIN_DIST;
    if (onSubject) continue;
    const overlaps = kept.some(
      (k) => Math.hypot(k.left - left, k.top - top) < MIN_PIN_DIST,
    );
    if (overlaps) continue;
    kept.push({ left, top, a });
  }

  return kept.map(({ left, top, a }) => ({
    left: `${round1(left)}%`,
    top: `${round1(top)}%`,
    label: `${fmtMln(a.price_rub)}${NBSP}млн${NBSP}₽`,
    sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)}${NBSP}м²` : "—",
  }));
}

/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
export function mapResultPanel(
  e: AggregatedEstimate,
  streetDeals?: StreetDealsResponse | null,
): ResultPanelData {
  const dealTier = resolveDealTier(e, streetDeals);

  // H1 — the «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» flagship card must NOT render as a hole of
  // bare «—» when the model has no expected-sold prediction. When it is missing
  // we emit an honest empty-state (emptyNote) and drop the value/range/ppm/delta;
  // the view then moves the accent frame onto the «рекомендованная цена» card.
  const hasExpected =
    e.expected_sold_price_rub != null &&
    Number.isFinite(e.expected_sold_price_rub);

  const cards: ResultCard[] = [
    {
      title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
      value: fmtMln(e.median_price_rub),
      unit: "млн ₽",
      range: rangeLine(e.range_low_rub, e.range_high_rub),
      ppm: `${fmtPpm(e.median_price_per_m2)} · по объявлениям`,
      // Fix #1/#8: bin over the SAME outlier-guarded pool the spread/CV uses
      // (meta.cv below) — otherwise the histogram spans [min,max] INCLUDING the
      // 872k выброс this card already reports as "исключён", squashing the real
      // analogs into the left bins while the number says the outlier is dropped.
      bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
      nav: 2,
    },
    {
      title: ["ОЖИДАЕМАЯ ЦЕНА", "СДЕЛКИ"],
      value: hasExpected ? fmtMln(e.expected_sold_price_rub) : "—",
      unit: "млн ₽",
      range: hasExpected
        ? rangeLine(
            e.expected_sold_range_low_rub,
            e.expected_sold_range_high_rub,
          )
        : "",
      ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "",
      delta:
        hasExpected && e.asking_to_sold_ratio != null
          ? fmtPct((e.asking_to_sold_ratio - 1) * 100)
          : undefined,
      // §1.3 / M2: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка
      // сервиса. Полная формулировка «к цене объявления» (единая с §M8) — вместо
      // обрубка «К ОБЪЯВЛ.»; рендерится спокойной пилюлей, не круговым гейджем.
      deltaLabel: "к цене объявления",
      // H1 empty-state message (only when there is no expected-sold prediction).
      emptyNote: hasExpected
        ? undefined
        : "Недостаточно данных для прогноза цены сделки — ориентируйтесь на рекомендованную цену в объявлении",
      note:
        hasExpected &&
        dealTier != null &&
        e.expected_sold_price_rub != null &&
        Number.isFinite(e.expected_sold_price_rub) &&
        e.expected_sold_price_rub < dealTier.medianRub
          ? "Обычно ниже медианы ДКП — другая выборка и площади"
          : undefined,
      nav: 2,
    },
    {
      title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
      value: dealTier ? fmtMln(dealTier.medianRub) : "—",
      unit: "млн ₽",
      range: dealTier
        ? rangeLine(dealTier.loRub, dealTier.hiRub)
        : "нет данных",
      ppm: dealTier
        ? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
            dealTier.count,
            ["сделка", "сделки", "сделок"],
          )}`
        : "—",
      bars: dealTier ? dealTier.bars : [],
      nav: 1,
    },
  ];

  // Data-driven footer provenance (replaces the view's hardcoded "ПО 6 АНАЛОГАМ
  // И 10 СДЕЛКАМ"). No "ЗА N МЕСЯЦЕВ" — we have no honest period to show.
  const builtOnDeals = dealTier?.count ?? 0;
  const builtOn = `ПОСТРОЕНО ПО ${e.n_analogs} ${pluralRu(e.n_analogs, [
    "АНАЛОГУ",
    "АНАЛОГАМ",
    "АНАЛОГАМ",
  ])} И ${builtOnDeals} ${pluralRu(builtOnDeals, [
    "СДЕЛКЕ",
    "СДЕЛКАМ",
    "СДЕЛКАМ",
  ])}`;

  // Fix #1 — the ИСТОЧНИКИ ДАННЫХ tiles below sum lots from e.analogs (top-10
  // persisted sample, backend #2087 M1), not the full e.n_analogs population
  // that builtOn (above) reports. Disclose the gap so the two numbers on this
  // one card never read as a single contradictory total.
  const sourcesNote =
    e.analogs.length < e.n_analogs
      ? `Разбивка — по показанным ${e.analogs.length} из ${e.n_analogs} аналогам`
      : undefined;

  const meta: ResultMeta = {
    sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
    confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
    cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
    builtOn,
    sourcesNote,
  };

  const adsBar: RangeBar = {
    label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"],
    median: `${fmtMln(e.median_price_rub)} млн ₽`,
    lo: `${fmtMln(e.range_low_rub)} млн`,
    hi: `${fmtMln(e.range_high_rub)} млн`,
    marker: rangeMarker(e.range_low_rub, e.median_price_rub, e.range_high_rub),
  };

  const dealsBar: RangeBar = {
    label: ["ДИАПАЗОН ЦЕН ПО", "ФАКТИЧЕСКИМ СДЕЛКАМ"],
    median: dealTier ? `${fmtMln(dealTier.medianRub)} млн ₽` : "—",
    lo: dealTier ? `${fmtMln(dealTier.loRub)} млн` : "—",
    hi: dealTier ? `${fmtMln(dealTier.hiRub)} млн` : "—",
    marker: dealTier
      ? rangeMarker(dealTier.loRub, dealTier.medianRub, dealTier.hiRub)
      : { fillLeft: "6%", fillRight: "6%", dotLeft: "50%" },
  };

  return {
    cards,
    meta,
    ranges: { ads: adsBar, deals: dealsBar },
    scatterMini: buildScatterMini(e, streetDeals),
    sources: buildSources(e),
  };
}

/** 03 СВОДКА ОБЪЕКТА: per-section totals + data-quality block. */
export function mapSummary(
  e: AggregatedEstimate,
  analytics?: HouseAnalyticsResponse | null,
  streetDeals?: StreetDealsResponse | null,
): Summary {
  const kpi = analytics?.kpi;

  const houseSold = kpi?.sold_count ?? null;
  const houseExp = kpi?.median_exposure_days ?? null;
  const row1Value =
    houseSold != null
      ? `${houseSold}${houseExp != null ? ` · ${houseExp} дн` : ""}`
      : "—";

  // H3 — «медиана объявлений» in the СВОДКА rail MUST match the hero/02-РЕЗУЛЬТАТ
  // headline (e.median_price_rub, the subject-area-normalised median). It used to
  // show median(analogs.price_rub) — the RAW median of the analog LISTING totals
  // across mixed areas — which for a small subject reads far higher (e.g. 14,23
  // млн vs the hero's 8,99 млн over the same 37 analogs) and looked like two
  // contradictory medians on one screen. Now both surfaces read the same field.
  const row2Value =
    e.n_analogs > 0
      ? `${e.n_analogs}${
          Number.isFinite(e.median_price_rub)
            ? ` · ${fmtMln(e.median_price_rub)} млн`
            : ""
        }`
      : "—";

  const dealCount = streetDeals?.count ?? e.actual_deals.length;
  const dealMedian =
    streetDeals?.median_price_rub ??
    median(e.actual_deals.map((d) => d.price_rub));
  const row3Value =
    dealCount > 0
      ? `${dealCount}${dealMedian != null ? ` · ${fmtMln(dealMedian)} млн` : ""}`
      : "—";

  const anaExp = kpi?.median_exposure_days ?? null;
  const anaBargain = kpi?.median_bargain_pct ?? null;
  const row4Parts = [
    anaExp != null ? `${anaExp} дн` : null,
    // Finding #10: negate the bargain so a price reduction reads "−3%" (compact
    // whole-percent here), not a misleading "+3%". See bargainSigned() rationale.
    anaBargain != null ? fmtPct(-anaBargain) : null,
  ].filter((p): p is string => p != null);
  const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—";

  const rows: SummaryRow[] = [
    {
      // Finding #9: fed by house-analytics radius data (район, radius_m up to
      // 300 m), NOT strictly the exact house → honest label "Продажи рядом".
      label: "Продажи рядом",
      value: row1Value,
      dot: houseSold && houseSold > 0 ? "accent" : "muted",
      nav: 1,
    },
    {
      label: "Аналоги",
      value: row2Value,
      dot: e.n_analogs > 0 ? "accent" : "muted",
      nav: 2,
    },
    // 'Сделки' is the design's secondary (muted) row regardless of count.
    { label: "Сделки", value: row3Value, dot: "muted", nav: 2 },
    {
      label: "Аналитика",
      value: row4Value,
      dot: row4Parts.length > 0 ? "accent" : "muted",
      nav: 3,
    },
  ];

  return {
    rows,
    quality: {
      sources: `${activeSourceCount(e)} / ${TOTAL_SOURCES}`,
      confidence: CONFIDENCE_RU[e.confidence],
      cv: cvStr(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
    },
  };
}

// ════════════════════════════════════════════════════════════════════════════
// OVERLAY MAPPERS (04 ПРОДАЖИ В ДОМЕ · 05 РЫНОК · 06 АНАЛИТИКА)
//
// Three pure mappers feeding the SectionOverlay views. Each returns a composite
// presentation shape whose fields keep the exact ./types contract the views
// already render, so the Wire phase only swaps each view's fixture import for a
// `data` prop (fixture stays the DEFAULT). All number/format/geometry recomputed
// here from the real API objects — never copied from fixtures. Graceful nulls
// degrade to "—" / empty so a pending/errored sub-hook never blanks an overlay.
//
// NB on table number formats (faithful to the pixel design, NOT app-wide fmtPpm/
// fmtMln): the ad/deal/DKP tables carry their unit in the COLUMN HEADER, so the
// row values are unit-less grouped integers (numRu) for ₽/м², full grouped rubles
// for prices, and млн only where the design shows млн (KPI medians, house-sale
// from→to). Using fmtPpm/fmtMln verbatim there would double the unit or collapse
// rubles to млн, breaking the pixel-perfect markup. Percent style also splits:
// market deltas are whole-percent (fmtPct), торг/bargain are 1-decimal (pct1) —
// matching the design.
// ════════════════════════════════════════════════════════════════════════════

// ── Overlay format helpers ──────────────────────────────────────────────────

/** Grouped integer, ru, no unit. 161351 -> "161 351". null/NaN -> "—". */
export function numRu(v: number | null | undefined): string {
  if (v == null || !Number.isFinite(v)) return "—";
  return Math.round(v).toLocaleString("ru-RU");
}

/** "{lo} – {hi}" млн range, ru 2dp, NBSP-padded en-dash (dkpKpi / marketDeals). Missing -> "—". */
function mlnRangeSp(
  lo: number | null | undefined,
  hi: number | null | undefined,
): string {
  if (
    lo == null ||
    hi == null ||
    !Number.isFinite(lo) ||
    !Number.isFinite(hi)
  ) {
    return "—";
  }
  return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}`;
}

/** Same style as mlnRangeSp (marketAds P25–P75), ru 2dp, NBSP en-dash. Missing -> "—". */
function mlnRangeTight(
  lo: number | null | undefined,
  hi: number | null | undefined,
): string {
  if (
    lo == null ||
    hi == null ||
    !Number.isFinite(lo) ||
    !Number.isFinite(hi)
  ) {
    return "—";
  }
  return `${fmtMln(lo)}${NBSP}–${NBSP}${fmtMln(hi)}`;
}

/** Signed percent, always 1dp, ru comma separator + ru minus. -36 -> "−36,0%". */
function pct1(v: number | null | undefined): string {
  if (v == null || !Number.isFinite(v)) return "—";
  const r = Number(v.toFixed(1));
  const sign = r < 0 ? "−" : r > 0 ? "+" : "";
  return `${sign}${Math.abs(r).toLocaleString("ru-RU", {
    minimumFractionDigits: 1,
    maximumFractionDigits: 1,
  })}%`;
}

/**
 * Finding #10 — "СРЕДНИЙ ТОРГ" rendering. The house-analytics KPI
 * median_bargain_pct is the median over house_placement_history of
 *   (start_price − last_price) / start_price × 100
 * (see backend trade_in.py), so a POSITIVE value means the asking price was
 * REDUCED from its initial to its final value (the haggle / discount room).
 * Feeding that straight through pct1 renders a misleading "+3.3%" — the "+"
 * reads as a price INCREASE. We instead show the SIGNED change from initial →
 * final price, so a reduction reads "−3.3%", matching the tile sub-label
 * "от начальной до итоговой цены" (final price is 3.3% below initial). null → "—".
 */
function bargainSigned(pct: number | null | undefined): string {
  if (pct == null || !Number.isFinite(pct)) return "—";
  return pct1(-pct);
}

/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
export function fmtDist(m: number | null | undefined): string {
  if (m == null || !Number.isFinite(m)) return "—";
  if (m < 1000) return `${Math.round(m)}${NBSP}м`;
  return `${(m / 1000).toLocaleString("ru-RU", {
    minimumFractionDigits: 1,
    maximumFractionDigits: 1,
  })}${NBSP}км`;
}

// Source key -> brand/RU display label (badges + link source). Delegates to the
// single source-registry roster (#2211) instead of a local hardcoded map — an
// unknown/inactive id (e.g. historical 'n1' rows, #2204) echoes back verbatim
// via the registry's own fallback rather than inventing a label.
export function sourceLabel(s: string | null | undefined): string {
  if (!s) return "—";
  return registrySourceLabel(s.toLowerCase());
}

/** Rosreestr deal tier -> RU caption next to the badge. */
export function tierLabel(t: string | null | undefined): string {
  if (!t) return "";
  if (t.includes("house")) return "по дому";
  if (t.includes("street")) return "по улице";
  return "";
}

/** "2-к. квартира, 50 м², 6/16 эт." from placement-history fields. */
function composeLot(
  rooms: number | null,
  area: number | null,
  floor: number | null,
  totalFloors: number | null,
): string {
  const parts: string[] = [];
  if (rooms == null) parts.push("Квартира");
  else if (rooms <= 0) parts.push("Студия");
  else parts.push(`${rooms}-к. квартира`);
  if (area != null && Number.isFinite(area)) parts.push(fmtArea(area));
  if (floor != null) {
    parts.push(
      totalFloors != null ? `${floor}/${totalFloors} эт.` : `${floor} эт.`,
    );
  }
  return parts.join(", ");
}

/** "32.5 м² · 1-к · этаж 12/15" for an analog ad row. */
export function adMeta(l: AnalogLot): string {
  const parts: string[] = [];
  if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
  parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
  if (l.floor != null) {
    parts.push(
      l.total_floors != null
        ? `этаж ${l.floor}/${l.total_floors}`
        : `этаж ${l.floor}`,
    );
  }
  return parts.join(" · ");
}

/** "38.1 м² · 1-к" for a deal row (no floor in the design). */
export function dealMeta(l: AnalogLot): string {
  const parts: string[] = [];
  if (Number.isFinite(l.area_m2)) parts.push(fmtArea(l.area_m2));
  parts.push(l.rooms <= 0 ? "студия" : `${l.rooms}-к`);
  return parts.join(" · ");
}

/**
 * Finding #7 — de-glue addresses. Some geocoded analog/deal addresses run two
 * tokens together with no separator — either a word glued onto the house number
 * ("…, 69/2Геологическая") OR two words glued together ("БольшаковаГеологическая").
 * Insert a space wherever a digit OR a lowercase letter is immediately followed
 * by the start of a Cyrillic WORD (capital + lowercase). NB: the trailing
 * lowercase in [А-ЯЁ][а-яё] is deliberate — it means we do NOT split legitimate
 * single-letter house litereras (14А, 5Б, д.5К — capital NOT followed by
 * lowercase) and do NOT touch already-separated tokens ("ул.Белинского" — the
 * capital is preceded by ".", not a glue char). Pure display cleanup; never
 * touches the comma-splitting parseAddress.
 */
export function deglueAddr(addr: string): string {
  return (
    addr
      .replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2")
      // §M8 — metro proximity glued straight into the address with no spaces
      // ("Геологическая11–15 мин.Чкаловская16–20"): (a) split a Cyrillic word
      // from a following digit ("Геологическая11" → "Геологическая 11"), and
      // (b) separate a "мин." run from the next station name with " · "
      // ("мин.Чкаловская" → "мин. · Чкаловская"). Display cleanup only.
      .replace(/([а-яё])(\d)/g, "$1 $2")
      .replace(/(мин\.)\s*([А-ЯЁ])/g, "$1 · $2")
  );
}

// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────

/** ДКП deal row + real per-row address/date/listing-source (DkpRow has none). */
export interface DkpRowData extends DkpRow {
  addr?: string;
  date?: string;
  source?: string | null;
}

/** History composite + the per-street ДКП deal-vs-listing rows. */
export interface HistoryData extends History {
  dkpRows: DkpRowData[];
}

/**
 * House placement history + per-street ДКП corridor (street-deals) + paired
 * deal↔listing rows (sales-vs-listings). Each source is independent: a null one
 * leaves its block at "—"/empty without blanking the others.
 */
export function mapHistory(
  placement: PlacementHistoryItem[] | null,
  streetDeals: StreetDealsResponse | null,
  salesVsListings: SalesVsListingsResponse | null,
): HistoryData {
  const houseSales: HouseSaleRow[] = (placement ?? []).map((p) => ({
    lot: composeLot(p.rooms, p.area_m2, p.floor, p.total_floors),
    priceFrom: fmtMln(p.start_price),
    priceTo: p.last_price != null ? `${fmtMln(p.last_price)} млн ₽` : "—",
    date: fmtDate(p.last_price_date),
    exposure: p.exposure_days != null ? `${p.exposure_days} дн.` : "—",
  }));

  const street = streetDeals?.street ?? null;
  const title = street
    ? `ДКП-сделки на улице ${street}`
    : "ДКП-сделки на вашей улице";
  const period =
    salesVsListings?.period_months != null
      ? `${salesVsListings.period_months} мес`
      : "—";

  const noteParts: string[] = [];
  if (salesVsListings?.linkage_rate_pct != null) {
    noteParts.push(
      `${Math.round(salesVsListings.linkage_rate_pct)}% сделок имеют историческую цену в объявлении`,
    );
  }
  if (salesVsListings?.median_discount_pct != null) {
    noteParts.push(
      `медианный торг ${pct1(salesVsListings.median_discount_pct)}`,
    );
  }
  const note =
    (noteParts.length > 0 ? `${noteParts.join(" · ")}. ` : "") +
    "Данные по улице, не по дому.";

  const dkpKpi = {
    count: streetDeals?.count != null ? String(streetDeals.count) : "—",
    median:
      streetDeals != null && Number.isFinite(streetDeals.median_price_per_m2)
        ? numRu(streetDeals.median_price_per_m2)
        : "—",
    range:
      streetDeals != null
        ? mlnRangeSp(streetDeals.range_low_rub, streetDeals.range_high_rub)
        : "—",
  };

  const dkpRows: DkpRowData[] = (salesVsListings?.pairs ?? []).map((pr) => {
    const ask =
      pr.listing_price_rub != null
        ? `${numRu(pr.listing_price_rub)} ₽${
            pr.days_listing_to_deal != null && pr.days_listing_to_deal >= 0
              ? ` за ${pr.days_listing_to_deal} дн до`
              : ""
          }`
        : "—";
    const deltaColor =
      pr.discount_pct == null
        ? tokens.muted
        : pr.discount_pct < 0
          ? tokens.success
          : tokens.danger;
    return {
      area: fmtArea(pr.deal_area_m2),
      price: `${numRu(pr.deal_price_rub)} ₽`,
      ppm: numRu(pr.deal_price_per_m2),
      ask,
      delta: pct1(pr.discount_pct),
      deltaColor,
      addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined,
      date: fmtMonthYear(pr.deal_date),
      source: pr.listing_source,
    };
  });

  return { houseSales, dkpHeader: { title, period, note }, dkpKpi, dkpRows };
}

// ── 06 АНАЛИТИКА ДОМА (AnalyticsView) ───────────────────────────────────────

// Three KPI tiles (порядок: экспозиция · торг · доля снятых).
function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
  // V1: median_bargain_pct > 0 means the asking price was REDUCED (see the
  // bargainSigned doc comment below) — the common, honest "price dropped"
  // case, which bargainSigned negates into the displayed "−X%". That displayed
  // drop is the one the fixture (analytics.kpis[1], "−3.0%") colours success —
  // NOT a raw negative pct (which would mean the price rose and bargainSigned
  // would show a misleading "+X%" in green). The old `< 0` check had this
  // backwards.
  const bargainColor =
    k?.median_bargain_pct != null && k.median_bargain_pct > 0
      ? tokens.success
      : undefined;
  return [
    {
      label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
      value:
        k?.median_exposure_days != null ? String(k.median_exposure_days) : "—",
      unit: "дн.",
      sub: "по архивным объявлениям",
    },
    {
      label: "СРЕДНИЙ ТОРГ",
      // Finding #10: negate so a price reduction reads "−3.3%" not "+3.3%".
      value: bargainSigned(k?.median_bargain_pct),
      color: bargainColor,
      sub: "от начальной до итоговой цены",
    },
    {
      label: "ДОЛЯ СНЯТЫХ",
      value:
        k != null && Number.isFinite(k.sold_rate_pct)
          ? `${Math.round(k.sold_rate_pct)}%`
          : "—",
      sub: k != null ? `${k.sold_count} из ${k.total_lots}` : "—",
    },
  ];
}

// price_premium_label -> tier caption + decorative variant.
const SELLTIME_META: Record<
  string,
  { tier: string; variant: SellTimeTier["variant"] }
> = {
  cheap: { tier: "−5% от рынка", variant: "success" },
  median: { tier: "По медиане", variant: "accent" },
  plus5: { tier: "+5%", variant: "gold" },
  plus10: { tier: "+10%", variant: "danger" },
};

// 4-column shell kept (with "—") when sell-time data is absent.
const SELLTIME_FALLBACK: SellTimeTier[] = [
  {
    tier: "−5% от рынка",
    days: "—",
    range: "—",
    count: "—",
    variant: "success",
  },
  { tier: "По медиане", days: "—", range: "—", count: "—", variant: "accent" },
  { tier: "+5%", days: "—", range: "—", count: "—", variant: "gold" },
  { tier: "+10%", days: "—", range: "—", count: "—", variant: "danger" },
];

function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] {
  if (!s || s.buckets.length === 0) return SELLTIME_FALLBACK;
  return s.buckets.map((b) => {
    const meta =
      SELLTIME_META[b.price_premium_label] ??
      ({
        tier:
          b.price_premium_pct === 0
            ? "По медиане"
            : fmtPct(b.price_premium_pct),
        variant: "accent",
      } as { tier: string; variant: SellTimeTier["variant"] });
    return {
      tier: meta.tier,
      days:
        b.median_exposure_days != null ? `~${b.median_exposure_days} дн.` : "—",
      range:
        b.p25_days != null && b.p75_days != null
          ? `обычно ${b.p25_days}–${b.p75_days} дн.`
          : "—",
      count: `${b.n_lots} ${pluralRu(b.n_lots, ["аналог", "аналога", "аналогов"])}`,
      variant: meta.variant,
      insufficientData: b.insufficient_data === true,
    };
  });
}

// Price-history polyline geometry. viewBox 0 0 900 220; the plot band runs
// y=20 (top) … y=195 (bottom) over 175px, x distributes the years evenly across
// [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band)
// so a narrow series like 120–160k fills the plot instead of flat-lining against
// a fixed 0–200k scale; the axis labels are emitted as yTicks for the 5 gridlines.
// PH_X_LEFT/PH_X_RIGHT/phYearX are exported — AnalyticsView's year-axis labels
// (M5) must share this EXACT geometry with the polyline vertices below, or a
// label drifts off its own dot. Single source of truth, no copy-pasted consts.
export const PH_X_LEFT = 58;
export const PH_X_RIGHT = 885;
const PH_Y_TOP = 20; // top gridline pixel (high value)
const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value)
const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175
// Exported (not just local) so AnalyticsView's background gridlines share this
// EXACT geometry with the yTick labels below (C5) — a hardcoded copy in the view
// drifted out of sync with this array; single source of truth like phYearX/PH_X_*.
export const PH_GRID_Y = [20, 64, 108, 151, 195]; // 5 gridlines, evenly spaced (top → bottom)
// Both polylines are drawn from these source keys; the Y zoom is computed over
// the union of their finite medians so the two series share one axis.
// NB: 'avito_imv'/'yandex_valuation' are scrape-origin discriminators on real
// archived listing rows (house_placement_history), NOT AVM model valuations —
// the card plots median ₽/m² of actual past listings. (LOW audit R2 #8, verified.)
const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"];

export function phYearX(i: number, n: number): number {
  if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2;
  return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1);
}

function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
  const empty: PriceHistory = {
    note: "Нет данных по истории цен",
    years: [],
    avito: "",
    yandex: "",
    yTicks: [],
  };
  if (!a || a.price_history.length === 0) return empty;
  const ph = a.price_history;
  const yearsNum = Array.from(new Set(ph.map((p) => p.year))).sort(
    (x, y) => x - y,
  );
  if (yearsNum.length === 0) return empty;
  const yearIndex = new Map(yearsNum.map((y, i) => [y, i] as const));
  const n = yearsNum.length;

  // Data-driven Y zoom: collect every finite median ₽/м² that BOTH series draw
  // so the polylines fill the plot band instead of squashing against 0–200k.
  const allPpm = ph
    .filter(
      (p) =>
        PH_SERIES_SOURCES.includes(p.source) &&
        Number.isFinite(p.median_price_per_m2),
    )
    .map((p) => p.median_price_per_m2);
  if (allPpm.length === 0) return empty;

  let yMin = Math.min(...allPpm);
  let yMax = Math.max(...allPpm);
  if (yMin === yMax) {
    yMin = yMin * 0.97;
    yMax = yMax * 1.03;
  } else {
    const pad = (yMax - yMin) * 0.08;
    yMin -= pad;
    yMax += pad;
  }
  yMin = Math.max(0, yMin);
  const span = yMax - yMin || 1;

  const priceY = (ppm: number): number =>
    clamp(
      PH_Y_BOTTOM - ((ppm - yMin) / span) * PH_Y_SPAN,
      PH_Y_TOP,
      PH_Y_BOTTOM,
    );

  const series = (src: string): string =>
    ph
      .filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2))
      .sort((x, y) => x.year - y.year)
      .map((p) => {
        const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n));
        const y = Math.round(priceY(p.median_price_per_m2));
        return `${x},${y}`;
      })
      .join(" ");

  const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0);
  const note = `Медиана ₽/м² по годам · только этот дом · Avito + Яндекс (${totalLots} ${pluralRu(
    totalLots,
    ["лот", "лота", "лотов"],
  )})`;

  const yTicks: AxisTickY[] = PH_GRID_Y.map((gy) => {
    const v = yMin + ((PH_Y_BOTTOM - gy) / PH_Y_SPAN) * span;
    return { label: `${Math.round(v / 1000)}k`, y: gy };
  });

  return {
    note,
    years: yearsNum.map(String),
    avito: series("avito_imv"),
    yandex: series("yandex_valuation"),
    yTicks,
  };
}

// Detail "ЦЕНА × СРОК ПРОДАЖИ" scatter (viewBox 0 0 900 300). Pixel box + x-ticks
// come from the design scaffold; y-tick LABELS are recomputed for the data domain
// (same approach as buildScatterMini). Exposure x reuses the listing_date
// "days listed so far" fallback for active analogs (deals require real DOM).
const DETAIL_BOX: ScatterBox = { left: 60, right: 880, top: 24, bottom: 252 };
const DETAIL_X_DOMAIN = { min: 0, max: 180 };
const DETAIL_X_TICKS: AxisTickX[] = [
  { label: "0", x: 60 },
  { label: "45", x: 265 },
  { label: "90", x: 470 },
  { label: "135", x: 675 },
  { label: "180", x: 880 },
];
const DETAIL_Y_TICK_PX = [24, 81, 138, 195, 252];

function detailDomReal(l: AnalogLot): number | null {
  return l.days_on_market != null && Number.isFinite(l.days_on_market)
    ? l.days_on_market
    : null;
}
function detailDomListed(l: AnalogLot): number | null {
  if (l.listing_date == null) return null;
  const t = new Date(l.listing_date).getTime();
  if (Number.isNaN(t)) return null;
  const days = Math.round((Date.now() - t) / 86_400_000);
  return days >= 0 && days < 3650 ? days : null;
}
function detailExposurePts(
  lots: AnalogLot[],
  dom: (l: AnalogLot) => number | null,
): { x: number; y: number }[] {
  return lots
    .map((l) => ({ x: dom(l), y: l.price_rub }))
    .filter(
      (p): p is { x: number; y: number } => p.x != null && Number.isFinite(p.y),
    );
}

function buildScatterDetail(
  e: AggregatedEstimate | null,
  radiusM: number | null,
): ScatterDetail {
  const fallbackTicks: AxisTickY[] = DETAIL_Y_TICK_PX.map((y) => ({
    label: "—",
    y,
  }));
  const emptySubject: ScatterPoint = { x: 470, y: 138, r: 6 };
  if (!e) {
    return {
      note: "—",
      deals: [],
      analogs: [],
      subject: emptySubject,
      yTicks: fallbackTicks,
      xTicks: DETAIL_X_TICKS,
    };
  }

  const dealsRaw = detailExposurePts(e.actual_deals, detailDomReal);
  const analogsRaw = detailExposurePts(
    e.analogs,
    (l) => detailDomReal(l) ?? detailDomListed(l),
  );
  const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
  const subjectDays = e.est_days_on_market;

  const nDeals = e.actual_deals.length;
  const nAnalogs = e.analogs.length;
  const note = `${nDeals} ${pluralRu(nDeals, [
    "сделка",
    "сделки",
    "сделок",
  ])} и ${nAnalogs} ${pluralRu(nAnalogs, [
    "аналог",
    "аналога",
    "аналогов",
  ])}${radiusM != null && radiusM > 0 ? ` в радиусе ${radiusM} м` : ""}`;

  const allY = [...dealsRaw, ...analogsRaw]
    .map((p) => p.y)
    .filter((y) => Number.isFinite(y));
  if (Number.isFinite(subjectPrice)) allY.push(subjectPrice);

  if (allY.length === 0) {
    return {
      note,
      deals: [],
      analogs: [],
      subject: emptySubject,
      yTicks: fallbackTicks,
      xTicks: DETAIL_X_TICKS,
    };
  }

  let yMin = Math.min(...allY);
  let yMax = Math.max(...allY);
  if (yMin === yMax) {
    yMin *= 0.95;
    yMax *= 1.05;
  }
  const yPad = (yMax - yMin) * 0.08;
  // C3: clamp the domain floor to 0 (mirror buildPriceHistory / buildScatterMini)
  // — an un-clamped yMin-yPad can go negative, surfacing a "−1M" axis tick.
  const domain: ScatterDomain = {
    xMin: DETAIL_X_DOMAIN.min,
    xMax: DETAIL_X_DOMAIN.max,
    yMin: Math.max(0, yMin - yPad),
    yMax: yMax + yPad,
  };

  const deals = scatterProject(dealsRaw, domain, DETAIL_BOX, 4.5);
  const analogs = scatterProject(analogsRaw, domain, DETAIL_BOX, 5.5);
  // V4: est_days_on_market is honestly unknown for a meaningful share of
  // estimates. Falling back to a silent 90 plotted the subject as if its
  // exposure were a real measurement. Keep the fallback (dropping the point
  // entirely would lose a real, known subjectPrice) but flag it so the view
  // can mark the dot as an estimate instead of presenting it as data.
  const subjectApprox = Number.isFinite(subjectPrice) && subjectDays == null;
  const subject = Number.isFinite(subjectPrice)
    ? scatterProject(
        [{ x: subjectDays ?? 90, y: subjectPrice }],
        domain,
        DETAIL_BOX,
        6,
      )[0]
    : emptySubject;

  const yTicks: AxisTickY[] = DETAIL_Y_TICK_PX.map((py) => {
    const frac =
      (DETAIL_BOX.bottom - py) / (DETAIL_BOX.bottom - DETAIL_BOX.top);
    const price = domain.yMin + frac * (domain.yMax - domain.yMin);
    return { label: mlnTick(price), y: py };
  });

  return {
    note,
    deals,
    analogs,
    subject,
    subjectApprox,
    yTicks,
    xTicks: DETAIL_X_TICKS,
  };
}

/**
 * 06 АНАЛИТИКА ДОМА: header + 3 KPI tiles + sell-time tiers + price-history
 * polyline + detail price×days scatter. analytics / sellTime degrade per-section;
 * the scatter is driven by the estimate's analogs + actual_deals.
 */
export function mapAnalytics(
  analytics: HouseAnalyticsResponse | null,
  sellTime: SellTimeSensitivityResponse | null,
  estimate: AggregatedEstimate | null,
): Analytics {
  const total = analytics?.kpi.total_lots ?? null;
  const ads =
    total != null
      ? `${total} ${pluralRu(total, ["объявление", "объявления", "объявлений"])}`
      : "—";
  const radiusM = analytics?.radius_m ?? null;
  const radius =
    radiusM == null
      ? "—"
      : radiusM > 0
        ? `в радиусе ${radiusM} м`
        : "точно по этому дому";

  const sellTgt = sellTime?.target_median_price_per_m2 ?? null;
  const sellTimeNote = `Медиана экспозиции по архивным лотам${
    sellTgt != null ? ` · опорная цена ${fmtPpm(sellTgt)}` : ""
  }`;

  return {
    header: { ads, radius },
    kpis: buildAnalyticsKpis(analytics?.kpi ?? null),
    sellTime: buildSellTime(sellTime),
    sellTimeNote,
    priceHistory: buildPriceHistory(analytics),
    scatterDetail: buildScatterDetail(estimate, radiusM),
  };
}

// ── 05 РЫНОК · АНАЛОГИ И СДЕЛКИ (SourcesView) ───────────────────────────────

// Real per-row source/distance/url + tier/date carried alongside the existing
// display fields. Optional so the design fixtures (AdRow[]/DealRow[]) stay valid
// as the component DEFAULT; the mapper always populates them.
export interface AdRowData extends AdRow {
  /** Source brand label for the badge (was hardcoded "Avito"). */
  source?: string;
  /** Distance to the subject (was hardcoded "—"). */
  dist?: string;
  /** Original listing URL (was a hardcoded "Росреестр ↗" link). */
  url?: string | null;
  /** Fix #8 — ₽/м² > OUTLIER_MULT× the pool median: excluded from spread/CV,
   *  flagged with a "возможный выброс" badge instead of silently distorting it. */
  outlier?: boolean;
}
export interface DealRowData extends DealRow {
  /** Source brand label for the badge (was hardcoded "Росреестр"). */
  source?: string;
  /** Tier caption next to the badge (was hardcoded "по улице"). */
  tier?: string;
  /** Deal/listing date (was hardcoded "01.01.26"). */
  date?: string;
}

export interface SourcesData {
  adRows: AdRowData[];
  dealRows: DealRowData[];
  marketAds: MarketAds;
  marketDeals: MarketDeals;
  /** Canonical active-source counter for the overlay header, e.g. "4 / 7". */
  sourceCount?: string;
  /**
   * Fix #1/#8 — disclosure caption under the ads table: "Показано N из M"
   * (adRows.length is the top-10 display sample; marketAds.kpi.count/n_analogs
   * is the true total used in calc — mirrors the deals-table footer below) plus
   * an outlier-exclusion note when guardPriceOutliers flagged any row.
   */
  adsFootnote?: string;
}

// Filter chips: distance corridor + rooms + analog area span (design order).
function buildAdFilters(e: AggregatedEstimate | null): string[] {
  if (!e) return [];
  const filters: string[] = [];
  const dists = e.analogs
    .map((a) => a.distance_m)
    .filter((d): d is number => d != null && Number.isFinite(d));
  if (dists.length > 0) {
    const maxKm = Math.max(...dists) / 1000;
    const km =
      maxKm < 1
        ? maxKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 })
        : String(Math.ceil(maxKm));
    filters.push(`Расстояние ≤ ${km}${NBSP}км`);
  }
  // Planning chip — derived from the rooms ACTUALLY present in the rendered
  // analog set (same source as the distance/area chips), NOT from the subject's
  // e.rooms. Analogs are selected by area band + geo proximity, NOT by room
  // count, so the set may span several planning types. Only claim a concrete
  // "N-к" filter when the analogs are genuinely homogeneous; otherwise stay
  // neutral ("все планировки") so the chip never asserts a filter that was not
  // applied to the data.
  const analogRooms = Array.from(
    new Set(
      e.analogs
        .map((a) => a.rooms)
        .filter((r): r is number => r != null && Number.isFinite(r)),
    ),
  );
  if (analogRooms.length === 1) {
    const r = analogRooms[0];
    filters.push(`Планировка ${r <= 0 ? "студия" : `${r}-к`}`);
  } else if (analogRooms.length > 1) {
    filters.push("Все планировки");
  }
  const areas = e.analogs
    .map((a) => a.area_m2)
    .filter((x) => Number.isFinite(x));
  if (areas.length > 0) {
    const lo = Math.min(...areas).toLocaleString("ru-RU", {
      minimumFractionDigits: 1,
      maximumFractionDigits: 1,
    });
    const hi = Math.max(...areas).toLocaleString("ru-RU", {
      minimumFractionDigits: 1,
      maximumFractionDigits: 1,
    });
    filters.push(`Площадь ${lo}${NBSP}–${NBSP}${hi}${NBSP}м²`);
  }
  return filters;
}

/**
 * 05 РЫНОК: analog ad rows + actual-deal rows + the two market KPI bands. Deal
 * aggregates prefer the per-street ДКП corridor (streetDeals), falling back to
 * the estimate's own actual_deals.
 */
export function mapSources(
  estimate: AggregatedEstimate | null,
  streetDeals: StreetDealsResponse | null,
): SourcesData {
  const e = estimate;

  // Fix #8 — flag rows whose ₽/м² is an outlier vs. the pool (never silently
  // fold them into the median/spread math).
  const { isOutlier: adOutliers, excludedCount: adOutlierCount } =
    guardPriceOutliers((e?.analogs ?? []).map((l) => l.price_per_m2));

  const adRows: AdRowData[] = (e?.analogs ?? []).map((l, i) => ({
    addr: deglueAddr(l.address || "—"),
    meta: adMeta(l),
    ppm: numRu(l.price_per_m2),
    price: numRu(l.price_rub),
    source: sourceLabel(l.source),
    dist: fmtDist(l.distance_m),
    url: l.source_url ?? null,
    outlier: adOutliers[i] ?? false,
  }));

  const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
    addr: deglueAddr(l.address || "—"),
    meta: dealMeta(l),
    ppm: numRu(l.price_per_m2),
    price: numRu(l.price_rub),
    source: sourceLabel(l.source),
    tier: tierLabel(l.tier),
    date: fmtMonthYear(l.listing_date),
  }));

  const marketAds: MarketAds = {
    kpi: {
      count: e != null ? String(e.n_analogs) : "—",
      median: e != null ? fmtMln(e.median_price_rub) : "—",
      ppm:
        e != null && Number.isFinite(e.median_price_per_m2)
          ? numRu(e.median_price_per_m2)
          : "—",
      range: e != null ? mlnRangeTight(e.range_low_rub, e.range_high_rub) : "—",
      cv: cvLabel(e),
    },
    filters: buildAdFilters(e),
  };

  // Deal aggregates: street DKP corridor first, else the estimate's actual_deals.
  const dealPrices = e?.actual_deals.map((d) => d.price_rub) ?? [];
  const dealCount = streetDeals?.count ?? dealPrices.length;
  const dealMedianRub = streetDeals?.median_price_rub ?? median(dealPrices);
  const dealLo =
    streetDeals?.range_low_rub ??
    (dealPrices.length > 0 ? Math.min(...dealPrices) : null);
  const dealHi =
    streetDeals?.range_high_rub ??
    (dealPrices.length > 0 ? Math.max(...dealPrices) : null);
  const asking = e?.median_price_rub ?? null;
  const deltaPct =
    dealMedianRub != null && asking != null && asking > 0
      ? (dealMedianRub / asking - 1) * 100
      : null;

  const marketDeals: MarketDeals = {
    kpi: {
      count: dealCount > 0 ? String(dealCount) : "—",
      median: dealMedianRub != null ? fmtMln(dealMedianRub) : "—",
      // §M8 — единая формулировка дельт: всё считается «относительно цен
      // объявлений» (deltaPct = медиана сделок / медиана объявлений − 1).
      delta: deltaPct != null ? `${fmtPct(deltaPct)} к цене объявления` : "—",
      range: mlnRangeSp(dealLo, dealHi),
    },
  };

  // Fix #1 — adRows is the top-10 display sample; marketAds.kpi.count/n_analogs
  // is the true total used in the calc (backend: "Консьюмер должен брать счёт
  // отсюда, а не из len()"). Disclose the gap the same way the deals table
  // already does ("Показано N из M"), plus the outlier exclusion from Fix #8.
  const outlierNote =
    adOutlierCount > 0
      ? ` · ${adOutlierCount} ${pluralRu(adOutlierCount, [
          "возможный выброс исключён",
          "возможных выброса исключено",
          "возможных выбросов исключено",
        ])} из расчёта разброса`
      : "";
  const adsFootnote =
    e != null
      ? `Показано ${adRows.length} из ${e.n_analogs} объявлений${outlierNote}`
      : undefined;

  return {
    adRows,
    dealRows,
    marketAds,
    marketDeals,
    sourceCount: estimate
      ? `${activeSourceCount(estimate)} / ${TOTAL_SOURCES}`
      : "—",
    adsFootnote,
  };
}

// ── 07 ПРЕДЫДУЩИЕ ОЦЕНКИ (CacheView) ─────────────────────────────────────────
// FE-derived ИСКЛЮЧИТЕЛЬНО из per-user списка /history (НЕ из /cache-stats — тот
// глобальный/админский: estimates_total считает ВСЕХ юзеров). Несколько полей,
// которых нет в /history, аппроксимируются на фронте (см. TODO BE-1):
//   • «ИСТОЧНИКОВ» (N / 7) требует sources_used — в /history его нет → "—".
//   • Статус свежести строится от 24-часового TTL кэша (ParamsPanel:
//     «КЭШ ПО АДРЕСУ — 24 Ч»): <18ч свежий · 18–24ч устаревает · >24ч устарел.

export interface CacheData {
  rows: CacheRow[];
  kpis: CacheKpi[];
}

const CACHE_TTL_HOURS = 24;

/**
 * created_at -> компактное ru-время для таблицы (как в дизайне):
 * сегодня -> "HH:MM:SS"; вчера -> "вчера HH:MM"; раньше -> "DD.MM.YYYY".
 */
function fmtCacheTime(iso: string | null | undefined): string {
  if (!iso) return "—";
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return "—";
  const now = new Date();
  const startOfToday = new Date(
    now.getFullYear(),
    now.getMonth(),
    now.getDate(),
  ).getTime();
  const t = d.getTime();
  if (t >= startOfToday) {
    return d.toLocaleTimeString("ru-RU", {
      hour: "2-digit",
      minute: "2-digit",
      second: "2-digit",
    });
  }
  if (t >= startOfToday - 86_400_000) {
    return `вчера ${d.toLocaleTimeString("ru-RU", {
      hour: "2-digit",
      minute: "2-digit",
    })}`;
  }
  return fmtDate(iso);
}

/**
 * #2420 — ПАРАМЕТРЫ column: "2 комн · 54 м²" / "2 комн · 54 м² · 5/9 эт.".
 * rooms + area are independent (each omitted if unknown); floor segment ONLY
 * appended when BOTH floor and total_floors are known (a lone floor without a
 * total is misleading — omitted entirely, no "5/? эт."). "—" when neither
 * rooms nor area is available (mirrors `addr: h.address ? … : "—"` above).
 */
function cacheParams(
  rooms: number | null,
  areaM2: string | null,
  floor: number | null,
  totalFloors: number | null,
): string {
  const area = areaM2 != null ? Number(areaM2) : null;
  const parts: string[] = [];
  if (rooms != null) parts.push(rooms <= 0 ? "студия" : `${rooms} комн`);
  if (area != null && Number.isFinite(area)) parts.push(fmtArea(area));
  if (parts.length === 0) return "—";
  if (floor != null && totalFloors != null) {
    parts.push(`${floor}/${totalFloors} эт.`);
  }
  return parts.join(" · ");
}

/** Свежесть оценки vs 24ч TTL кэша → подпись + token-цвет точки/текста. */
function cacheStatus(iso: string | null | undefined): {
  status: string;
  statusColor: string;
} {
  if (!iso) return { status: "—", statusColor: tokens.muted };
  const d = new Date(iso);
  if (Number.isNaN(d.getTime()))
    return { status: "—", statusColor: tokens.muted };
  const ageH = (Date.now() - d.getTime()) / 3_600_000;
  if (ageH <= CACHE_TTL_HOURS * 0.75) {
    return { status: "свежий", statusColor: tokens.success };
  }
  if (ageH <= CACHE_TTL_HOURS) {
    return { status: "устаревает", statusColor: tokens.warn };
  }
  return { status: "устарел", statusColor: tokens.danger };
}

/**
 * 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на
 * фронте из per-user /history (null → пустая таблица + KPI с "—").
 *   rows  — addr/время/статус/params по каждой строке (src всегда "—", TODO BE-1).
 *   kpis  — ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн).
 *           «ПОВТОРНЫЕ АДРЕСА» убран (§M8 — внутренняя ops-метрика).
 */
export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
  const list = history ?? [];

  const rows: CacheRow[] = list.map((h) => {
    const { status, statusColor } = cacheStatus(h.created_at);
    return {
      id: h.id,
      // §M8 — de-glue metro/address runs the same way as the analog/deal rows
      // (these are the user's own past-estimate addresses, often geocoded with
      // metro proximity jammed on with no separators).
      addr: h.address ? deglueAddr(h.address) : "—",
      time: fmtCacheTime(h.created_at),
      src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно
      status,
      statusColor,
      params: cacheParams(h.rooms, h.area_m2, h.floor, h.total_floors),
    };
  });

  // KPI 1 — всего оценок (длина per-user списка, capped /history limit).
  const total = list.length;

  // KPI 2 — средняя цена по строкам с положительной медианой.
  const prices = list
    .map((h) => h.median_price)
    .filter((p): p is number => p != null && Number.isFinite(p) && p > 0);
  const avg =
    prices.length > 0
      ? prices.reduce((a, b) => a + b, 0) / prices.length
      : null;

  // §M8 — «ПОВТОРНЫЕ АДРЕСА» (доля переоценок) was an internal ops metric with no
  // meaning for the end user, so it is dropped from this витрина. Only the two
  // user-relevant KPIs remain.
  const kpis: CacheKpi[] = [
    {
      label: "ВСЕГО ОЦЕНОК",
      value: String(total),
      // #2479-audit: useEstimateHistory() caps at 50 (trade-in-api.ts:247) — this
      // is a page count, not a true lifetime total, so it must not read as one.
      sub: "последние 50",
    },
    {
      label: "СРЕДНЯЯ ЦЕНА",
      value: avg != null ? fmtMln(avg) : "—",
      unit: avg != null ? "млн ₽" : undefined,
      sub: "по проведённым оценкам",
    },
  ];

  return { rows, kpis };
}
