/**
 * ptica-adapt — PURE typed mappers ParcelAnalysis → cockpit view-models.
 *
 * INCREMENT 1 contract: render REAL data where the backend provides it, and an
 * honest PLACEHOLDER ("—" + caption) where a field is absent or not yet wired.
 * Every value the cockpit renders flows through here as a {value, isReal,
 * caption?} view-model so the UI never presents a fake hard number as live.
 *
 * No React, no side effects — just narrowing + formatting. The single source of
 * truth for the real-vs-placeholder decision.
 */

import type {
  ParcelAnalysis,
  ParcelAnalysisCompetitor,
  ParcelFinancialEstimate,
} from "@/types/site-finder";
import type { NspdZoning } from "@/types/nspd";
import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation";
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";

// ── View-model ──────────────────────────────────────────────────────────────

/** A single field as shown in the cockpit. `caption` annotates placeholders. */
export interface PticaField {
  value: string;
  isReal: boolean;
  caption?: string;
}

/** Gauge view-model — value 0..100 (null = no data → render muted track). */
export interface PticaGauge {
  /** 0..100 progress, or null when the metric is unknown. */
  value: number | null;
  /** Center caption under the number (e.g. "Можно строить"). */
  label: string;
  /** Severity bucket drives the stroke color via CSS tokens. */
  tone: "good" | "warn" | "bad" | "neutral";
  isReal: boolean;
  /** Footnote (e.g. "предварительная оценка"). */
  footnote?: string;
}

/** A titled scan card: metric rows + optional footnote. */
export interface PticaScanCard {
  title: string;
  /** Subtitle shown next to the title (e.g. "предв."). */
  badge?: string;
  rows: PticaScanRow[];
  /** Empty-state message when there is nothing to render. */
  emptyState?: string;
}

/** Tone for a status-row dot + state label (prototype .dot / .state). */
export type PticaStatusTone = "ok" | "warn" | "bad";

export interface PticaScanRow {
  key: string;
  field: PticaField;
  /**
   * When set, the row renders as a prototype status-row (dot + label + state)
   * instead of the default kvrow. `field.value` is the state text. Used by the
   * Инженерия card (Экология stays honest kvrow placeholders, no status dots).
   */
  status?: PticaStatusTone;
}

const PLACEHOLDER = "—";

function real(value: string): PticaField {
  return { value, isReal: true };
}

function placeholder(caption: string): PticaField {
  return { value: PLACEHOLDER, isReal: false, caption };
}

// ── Formatting helpers (ru typography) ────────────────────────────────────────

function formatInt(n: number): string {
  return Math.round(n).toLocaleString("ru-RU");
}

/** Fixed-decimal RU number (used for the invest-score on a /10 scale). */
function fmtNum(value: number, digits = 1): string {
  return value.toLocaleString("ru-RU", {
    minimumFractionDigits: digits,
    maximumFractionDigits: digits,
  });
}

/** м² from area_ha (×10000). */
function areaM2FromHa(areaHa: number): string {
  return `${formatInt(areaHa * 10000)} м²`;
}

function formatHa(areaHa: number): string {
  return `${areaHa.toFixed(2)} га`;
}

function formatRubPerM2(n: number): string {
  return `${formatInt(n)} ₽/м²`;
}

/** Сумма в млрд ₽ (rub / 1e9, 2 знака) — для finance-bignums (#1881). */
function formatBln(rub: number): string {
  return (rub / 1e9).toLocaleString("ru-RU", {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });
}

/** Доля → проценты с одним знаком (0.183 → "18,3"). */
function formatPctFromFraction(fraction: number, digits = 1): string {
  return (fraction * 100).toLocaleString("ru-RU", {
    minimumFractionDigits: digits,
    maximumFractionDigits: digits,
  });
}

/**
 * Знаковый процент из доли — для метрик, которые честно могут быть
 * отрицательными (levered/equity IRR при негативном леверидже). Отрицательное
 * число печатается с Unicode-минусом «−» (U+2212), не ASCII «-», как требует
 * ui-microcopy: «−11,6 %». Форматируем модуль и сами ставим «−», чтобы знак не
 * зависел от ICU-сборки рантайма (Node даёт ASCII-hyphen).
 */
function formatSignedPctFromFraction(fraction: number, digits = 1): string {
  const body = formatPctFromFraction(Math.abs(fraction), digits);
  return fraction < 0 ? `−${body}` : body;
}

// #1881 PR-4: backend отдаёт inferred-токены латиницей (housing_class /
// development_type) — локализуем для финмодель-drawer, чтобы финдиректор не
// видел "high_rise" в русском UI. Неизвестный токен → как есть (fallback).
const _HOUSING_CLASS_RU: Record<string, string> = {
  econom: "эконом",
  comfort: "комфорт",
  business: "бизнес",
};
const _DEVELOPMENT_TYPE_RU: Record<string, string> = {
  spot: "точечная",
  mid_rise: "среднеэтажная",
  high_rise: "высотная",
};

// Honest RU caption per backend price_source token. Backend отдаёт латиницей;
// в UI финдиректор должен видеть, откуда взята цена, человеческим языком — не
// сырой токен «objective_geo_radius». Неизвестный/пустой → "" (suffix опускаем,
// сырой токен не печатаем). См. .claude/rules/ui-microcopy.md.
const _PRICE_SOURCE_RU: Record<string, string> = {
  objective_district_median: "медиана сделок квартала",
  objective_geo_radius: "медиана рынка в радиусе 3 км",
  district_reference: "ориентир по району",
  class_norm: "норматив класса",
};

/** Источник цены → честная RU-подпись в «ёлочках»; неизвестный/пустой → "". */
function priceSourceCaption(source: string): string {
  const ru = _PRICE_SOURCE_RU[source];
  return ru ? `«${ru}»` : "";
}

// ── НСПД-регламент (ПЗЗ) helpers ──────────────────────────────────────────────

/** КСИТ (max_far) as a plain RU number — e.g. "3,5". */
function formatFar(far: number): string {
  return fmtNum(far, 1);
}

/**
 * Real parcel area in m² when geometry is known (area_ha × 10000); null
 * otherwise. The single point at which we turn area_ha into m² for ёмкость.
 */
export function parcelAreaM2(a: ParcelAnalysis): number | null {
  const areaHa = a.geometry_suitability?.area_ha;
  return areaHa != null ? areaHa * 10000 : null;
}

/** Human regulation zone — prefer the readable index ("Ж-2") over reg-number. */
function regulationZone(z: NspdZoning | null | undefined): string | null {
  return z?.regulation_zone_index ?? z?.zone_code ?? null;
}

const SRC_NSPD_ZONING = "источник НСПД-зонирование";

// ── Passport ──────────────────────────────────────────────────────────────────

export interface PticaPassport {
  cadNum: string;
  district: PticaField;
  area: PticaField;
  address: PticaField;
  landCategory: PticaField;
  vri: PticaField;
  status: PticaField;
  encumbrances: PticaField;
}

export function adaptPassport(a: ParcelAnalysis): PticaPassport {
  const areaHa = a.geometry_suitability?.area_ha;
  const area =
    areaHa != null
      ? real(`${formatHa(areaHa)} · ${areaM2FromHa(areaHa)}`)
      : placeholder("нет геометрии");

  const districtName = a.district?.district_name;
  const address = a.egrn?.address;
  const landCategory = a.egrn?.land_category;
  const vri = a.egrn?.permitted_use_text;
  const status = a.egrn?.parcel_status;

  return {
    cadNum: a.cad_num,
    district: districtName ? real(districtName) : placeholder("нет данных"),
    area,
    address: address ? real(address) : placeholder("нет данных ЕГРН"),
    landCategory: landCategory
      ? real(landCategory)
      : placeholder("нет данных ЕГРН"),
    vri: vri ? real(vri) : placeholder("нет данных ЕГРН"),
    status: status ? real(status) : placeholder("нет данных ЕГРН"),
    // encumbrances never arrive from the backend yet — always a stub.
    encumbrances: notReal("источник ЕГРН"),
  };
}

// ── Buildability gauge (soft proxy — PLACEHOLDER, isReal:false) ────────────────

/**
 * Derive a soft buildability proxy from the gate verdict + geometry suitability.
 * This is NOT a backend score — it's a preliminary heuristic, flagged with a
 * "предварительная оценка" footnote so it never reads as a live index.
 */
export function adaptBuildabilityGauge(a: ParcelAnalysis): PticaGauge {
  const gate = a.gate_verdict;
  const geom = a.geometry_suitability;

  // Gate contributes the bulk; geometry suitability nudges within the band.
  let base: number | null = null;
  let tone: PticaGauge["tone"] = "neutral";
  let label = "нет данных";

  if (gate) {
    if (gate.can_build_mkd === true) {
      base = 78;
      tone = "good";
      label = "Можно строить";
    } else if (gate.can_build_mkd === false) {
      base = 24;
      tone = "bad";
      label = "Нельзя";
    } else {
      base = 50;
      tone = "warn";
      label = "Нужна проверка";
    }
    // Each blocker/warning trims the proxy a little.
    base -= gate.blockers.length * 8 + gate.warnings.length * 3;
  }

  // Geometry suitability (0..1) shifts the proxy ±10.
  if (base != null && geom?.suitability_score != null) {
    base += (geom.suitability_score - 0.5) * 20;
  }

  const value =
    base == null ? null : Math.max(0, Math.min(100, Math.round(base)));

  return {
    value,
    label,
    tone,
    isReal: false,
    footnote: "предварительная оценка",
  };
}

/**
 * Risk gauge — inverse soft proxy from the same signals. Lower is better.
 * PLACEHOLDER ("предв.") until the real risk model is wired.
 */
export function adaptRiskGauge(a: ParcelAnalysis): PticaGauge {
  const build = adaptBuildabilityGauge(a);
  const value = build.value == null ? null : 100 - build.value;

  let tone: PticaGauge["tone"] = "neutral";
  let label = "нет данных";
  if (value != null) {
    if (value <= 33) {
      tone = "good";
      label = "Низкий";
    } else if (value <= 66) {
      tone = "warn";
      label = "Средний";
    } else {
      tone = "bad";
      label = "Высокий";
    }
  }

  return { value, label, tone, isReal: false, footnote: "предв." };
}

// ── Invest score block (PLACEHOLDER — после прогноза) ─────────────────────────

/** Semantic value tone for a hero stat-line (prototype .v.good / .v.warn). */
export type PticaValueTone = "good" | "warn" | "none";

export interface PticaInvestScore {
  score: PticaField;
  potential: PticaField;
  risk: PticaField;
  buySignal: PticaField;
  /** Hero stat-line value color for «Потенциал». */
  potentialTone: PticaValueTone;
  /** Hero stat-line value color for «Риск». */
  riskTone: PticaValueTone;
}

/**
 * Buy-signal from the §22 forecast exec-summary key numbers. Derived from the
 * (signed) deficit_index — the long `verdict` sentence is a paragraph, not a
 * badge, so we map the structured KPI instead. >0.05 недонасыщенность →
 * «приобретать», <−0.05 затоварка → «пропустить», иначе «наблюдать». Flagged
 * advisory (the forecast itself is advisory). Independent of the overall score,
 * so it surfaces as soon as the report is ready even if scoring is thin.
 */
export function buySignalFromForecast(report: ForecastReport): PticaField {
  const kn = report.exec_summary.key_numbers;
  const di = kn.deficit_index;
  if (di == null) {
    // No forecast number → honest stub, distinct from a real «наблюдать».
    return notReal("прогноз недоступен (Сценарии §22)");
  }
  const word =
    di > 0.05 ? "приобретать" : di < -0.05 ? "пропустить" : "наблюдать";
  return { value: word, isReal: true, caption: "advisory · §22" };
}

/**
 * Invest-score view-model. INCREMENT 1: PLACEHOLDER until the §22 forecast is
 * ready; PR#2: when `report` is present, the score is overall_score×10 (∈ 0..10)
 * and the buy-signal is derived from the exec-summary KPIs. While the forecast is
 * still pending (`report` undefined) the «после прогноза» placeholders stand.
 */
export function adaptInvestScore(
  a: ParcelAnalysis,
  report?: ForecastReport,
): PticaInvestScore {
  const risk = adaptRiskGauge(a);
  const riskField: PticaField =
    risk.value != null
      ? { value: risk.label, isReal: false, caption: "предв." }
      : placeholder("предв.");
  // Hero «Риск» value color (prototype .v.warn): green when low, yellow otherwise.
  const riskTone: PticaValueTone =
    risk.value == null ? "none" : risk.tone === "good" ? "good" : "warn";

  const overall =
    report?.scoring?.overall ?? report?.exec_summary.key_numbers.overall_score;

  // Buy-signal is derived from the deficit_index alone, so it surfaces whenever
  // the report is ready — independent of whether the overall score is present.
  const buySignal = report
    ? buySignalFromForecast(report)
    : placeholder("после прогноза (Scenarios)");

  if (report && overall != null) {
    // overall_score ∈ 0..1 → /10 scale (one decimal), advisory.
    const scoreOn10 = real(fmtNum(overall * 10, 1));
    scoreOn10.caption = "advisory · §22";
    return {
      score: scoreOn10,
      potential: placeholder("после финмодели"),
      risk: riskField,
      buySignal,
      // Потенциал stays muted until the financial model surfaces a real value.
      potentialTone: "none",
      riskTone,
    };
  }

  return {
    score: placeholder("после прогноза (Scenarios)"),
    potential: placeholder("после прогноза (Scenarios)"),
    risk: riskField,
    buySignal,
    potentialTone: "none",
    riskTone,
  };
}

// ── Development Scan cards ─────────────────────────────────────────────────────

// subtype → ru label for the engineering card.
const UTILITY_LABELS: Record<string, string> = {
  substation: "Подстанция",
  pipeline: "Трубопровод",
  power_line: "ЛЭП",
  water_intake: "Водозабор",
  pumping_station: "Насосная станция",
};

function utilityLabel(subtype: string): string {
  return UTILITY_LABELS[subtype] ?? subtype;
}

/**
 * Градостроительство — REAL ПЗЗ-регламент when nspd_zoning carries it
 * (PR #1847), honest placeholder otherwise. Зона prefers the human index
 * ("Ж-2"); КСИТ / высота / плотность light up once the backend field flows.
 */
export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard {
  const z = a.nspd_zoning;
  const zone = regulationZone(z);
  // max_far / max_height_m / max_building_pct are NUMERIC → strings on the wire;
  // coerce to numbers.
  const maxFar = coerceFloat(z?.max_far);
  const maxHeight = coerceFloat(z?.max_height_m);
  const buildingPct = coerceFloat(z?.max_building_pct);
  const areaM2 = parcelAreaM2(a);

  // Плотность (КСИТ) ёмкость = площадь × КСИТ (м²), real only with both inputs.
  const densityField =
    maxFar != null
      ? areaM2 != null
        ? {
            value: `${formatInt(areaM2 * maxFar)} м²`,
            isReal: true,
            caption: `КСИТ ${formatFar(maxFar)} · НСПД`,
          }
        : { value: formatFar(maxFar), isReal: true, caption: "КСИТ · НСПД" }
      : placeholder(SRC_NSPD_ZONING);

  // Пятно застройки = площадь участка × коэф. застройки (max_building_pct).
  // Reuses parcelAreaM2 (area_ha × 10000) — the same area КСИТ uses, and the same
  // computation as the DEVELOPMENT POTENTIAL card (adaptPotentialDrawer).
  const spotField: PticaField =
    areaM2 != null && buildingPct != null
      ? {
          value: `${formatInt(areaM2 * (buildingPct / 100))} м²`,
          isReal: true,
          caption: `${formatInt(buildingPct)} % площади · НСПД`,
        }
      : placeholder(SRC_NSPD_ZONING);

  return {
    title: "Градостроительство",
    rows: [
      {
        // ВРИ (вид разрешённого использования) — real from ЕГРН, matches the
        // prototype's first ГРАДОСТРОИТЕЛЬСТВО row.
        key: "ВРИ",
        field: a.egrn?.permitted_use_text
          ? { value: a.egrn.permitted_use_text, isReal: true, caption: "ЕГРН" }
          : placeholder("нет данных ЕГРН"),
      },
      {
        key: "Зона",
        field: zone
          ? { value: zone, isReal: true, caption: "НСПД-зонирование" }
          : placeholder(SRC_NSPD_ZONING),
      },
      {
        key: "Пред. высота",
        field:
          maxHeight != null
            ? real(`${formatInt(maxHeight)} м`)
            : placeholder(SRC_NSPD_ZONING),
      },
      {
        key: "Плотность (КСИТ)",
        field: densityField,
      },
      {
        key: "Пятно застройки",
        field: spotField,
      },
    ],
  };
}

/** Ограничения · ЗОУИТ — ЗОУИТ count is real; the rest placeholder. */
export function adaptRestrictionsCard(a: ParcelAnalysis): PticaScanCard {
  const zouit = a.nspd_zouit_overlaps;
  return {
    title: "Ограничения · ЗОУИТ",
    rows: [
      {
        key: "ЗОУИТ",
        field:
          zouit != null
            ? real(formatInt(zouit.length))
            : placeholder("нет данных НСПД"),
      },
      { key: "Красные линии", field: placeholder("источник НСПД-граддок") },
      { key: "ОКН", field: placeholder("источник НСПД") },
      { key: "Сервитуты", field: placeholder("источник ЕГРН") },
    ],
  };
}

/** Инженерия — utilities.summary rows (subtype → label + nearest_m). REAL. */
export function adaptEngineeringCard(a: ParcelAnalysis): PticaScanCard {
  const summary = a.utilities?.summary ?? [];
  if (summary.length === 0) {
    return {
      title: "Инженерия",
      rows: [],
      emptyState: "Нет данных по инженерным сетям",
    };
  }
  return {
    title: "Инженерия",
    // Prototype Инженерия renders status-rows (dot + label + state). The state
    // text is the nearest-node distance; tone is «ok» when the узел is within
    // ~1 km, «warn» when further (ограниченная доступность).
    rows: summary.map((u) => ({
      key: utilityLabel(u.subtype),
      field: real(`${formatInt(u.nearest_m)} м`),
      status: (u.nearest_m <= 1000 ? "ok" : "warn") as PticaStatusTone,
    })),
  };
}

/**
 * Рынок — REAL data from the market layer of /analyze. Median (district),
 * absorption (market_pulse.avg_velocity_m2), competitor count
 * (market_pulse.competitors_total), pipeline объектов (pipeline_24mo) and the
 * demand-trend label (market_trend.label). Honest «—» where a field is absent.
 * Prototype row order: Медиана цены / Поглощение / Конкурентов / Новых проектов /
 * Уровень спроса.
 */
export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard {
  const median = a.district?.median_price_per_m2;
  const pulse = a.market_pulse;
  const velocity = pulse?.avg_velocity_m2;
  const competitors = pulse?.competitors_total;
  const newProjects = a.pipeline_24mo?.objects_count;
  const demandLabel = a.market_trend?.label;

  return {
    title: "Рынок",
    rows: [
      {
        key: "Медиана цены",
        field:
          median != null
            ? real(formatRubPerM2(median))
            : placeholder("нет данных района"),
      },
      {
        key: "Поглощение",
        field:
          velocity != null
            ? {
                value: `${fmtNum(velocity, 1)} м²/мес`,
                isReal: true,
                caption: "средний темп поглощения · рынок",
              }
            : placeholder("нет данных рынка"),
      },
      {
        key: "Конкурентов",
        field:
          competitors != null
            ? real(formatInt(competitors))
            : placeholder("нет данных рынка"),
      },
      {
        key: "Новых проектов",
        field:
          newProjects != null
            ? real(formatInt(newProjects))
            : placeholder("нет пайплайна"),
      },
      {
        key: "Уровень спроса",
        field: demandLabel
          ? { value: demandLabel, isReal: true, caption: "тренд цен · рынок" }
          : placeholder("нет тренда рынка"),
      },
    ],
  };
}

/**
 * Экономика — REAL когда backend отдаёт `financial_estimate` (#1881), иначе
 * честный PLACEHOLDER (предв.) до финмодели. Выручка/маржа/ROI/IRR в реальных
 * числах; caption у выручки говорит источник цены (рынок vs норматив класса),
 * IRR помечается «(proxy)» когда `irr_is_proxy`.
 */
export function adaptEconomyCard(
  financial?: ParcelFinancialEstimate | null,
): PticaScanCard {
  if (!financial) {
    return {
      title: "Экономика",
      badge: "предв.",
      rows: [
        { key: "Потенц. выручки", field: placeholder("после финмодели") },
        { key: "Маржа", field: placeholder("после финмодели") },
        { key: "ROI", field: placeholder("после финмодели") },
        { key: "IRR", field: placeholder("после финмодели") },
      ],
    };
  }
  const irrLabel = financial.irr_is_proxy
    ? `${formatPctFromFraction(financial.irr)} % (proxy)`
    : `${formatPctFromFraction(financial.irr)} %`;
  return {
    title: "Экономика",
    badge: "ориент.",
    rows: [
      {
        key: "Потенц. выручки",
        field: {
          value: `${formatBln(financial.revenue_rub)} млрд ₽`,
          isReal: true,
          caption: financial.price_is_calibrated ? "рынок" : "норматив класса",
        },
      },
      {
        key: "Маржа",
        field: real(`${formatPctFromFraction(financial.margin_pct)} %`),
      },
      { key: "ROI", field: real(`${formatPctFromFraction(financial.roi)} %`) },
      { key: "IRR", field: real(irrLabel) },
    ],
  };
}

/**
 * Среда · экология — REAL atmospheric layer from /analyze. Шум (noise.estimated_db
 * + level), Воздух (air_quality.pm2_5 — honest PM2.5 reading, NOT a fabricated AQI
 * index), Ветер (wind dominant direction + max speed), Водоёмы (hydrology nearest
 * water distance). Honest «—» where a field is absent.
 */
export function adaptEnvironmentCard(a: ParcelAnalysis): PticaScanCard {
  const noise = a.noise;
  const aq = a.air_quality;
  const wind = a.wind;
  const hydro = a.hydrology;
  const nearestWater = hydro?.nearest?.[0]?.distance_m;

  const noiseField: PticaField = noise
    ? {
        value: `${fmtNum(noise.estimated_db, 1)} дБ`,
        isReal: true,
        caption: `уровень: ${noise.level}`,
      }
    : placeholder("нет данных шума");

  // Воздух — честное прямое чтение PM2.5 (не выдуманный AQI-индекс).
  const airField: PticaField = aq
    ? {
        value: `PM2.5 ${fmtNum(aq.pm2_5, 1)}`,
        isReal: true,
        caption: "мкг/м³ · атмосферный слой",
      }
    : placeholder("нет данных AQI");

  const windField: PticaField = wind
    ? wind.max_speed_m_s != null
      ? real(
          `${wind.dominant_direction_label} · ${fmtNum(wind.max_speed_m_s, 1)} м/с`,
        )
      : real(wind.dominant_direction_label)
    : placeholder("нет данных ветра");

  return {
    title: "Среда",
    badge: "экология",
    rows: [
      { key: "Шум", field: noiseField },
      { key: "Воздух (PM2.5)", field: airField },
      { key: "Ветер", field: windField },
      {
        key: "Водоёмы",
        field:
          nearestWater != null
            ? real(`${formatInt(nearestWater)} м`)
            : placeholder("нет ближайшего водоёма"),
      },
    ],
  };
}

// (ОКС + Инсоляция moved to the lower-grid — see cockpit/OksCard + InsolationCard.)

// ══════════════════════════════════════════════════════════════════════════════
// DRAWER-LEVEL ADAPTERS (PR#4) — typed view-models, real-vs-placeholder centralized
// ══════════════════════════════════════════════════════════════════════════════
//
// Each drawer consumes a typed view-model below rather than reaching into
// ParcelAnalysis / ForecastReport in the React tree. REAL data is surfaced where
// the backend provides it; absent fields become honest `{value:"—", isReal:false,
// caption}` placeholders — NO fabricated numbers. `notReal()` rows are styled
// muted by the drawer content so they never read as live.

const DASH = "—";

function notReal(caption: string): PticaField {
  return { value: DASH, isReal: false, caption };
}

/** A single "Параметр → Значение" row with real/placeholder provenance. */
export interface DrawerKvRow {
  k: string;
  field: PticaField;
}

function metersField(
  m: number | null | undefined,
  caption: string,
): PticaField {
  return m != null ? real(`${formatInt(m)} м`) : notReal(caption);
}

// ── passport — full ЕГРН / НСПД (REAL) ────────────────────────────────────────

export interface PticaPassportDrawer {
  summary: string;
  registry: DrawerKvRow[];
  zouitCount: number | null;
  zouitOverlaps: Array<{ layer: string; name: string; sub: string }>;
}

export function adaptPassportDrawer(a: ParcelAnalysis): PticaPassportDrawer {
  const p = adaptPassport(a);
  const areaHa = a.geometry_suitability?.area_ha;
  const zouit = a.nspd_zouit_overlaps ?? null;

  const districtPart = a.district?.district_name
    ? `, ${a.district.district_name} р-н`
    : "";
  const summary =
    `Участок ${a.cad_num}${districtPart}. ` +
    (a.egrn?.land_category ? `Категория — ${a.egrn.land_category}. ` : "") +
    (a.egrn?.permitted_use_text ? `ВРИ — ${a.egrn.permitted_use_text}. ` : "") +
    (a.egrn?.parcel_status ? `Статус ЕГРН — ${a.egrn.parcel_status}.` : "");

  const registry: DrawerKvRow[] = [
    { k: "Кадастровый номер", field: real(a.cad_num) },
    { k: "Адрес", field: p.address },
    { k: "Район", field: p.district },
    { k: "Площадь", field: p.area },
    {
      k: "Площадь, га",
      field: areaHa != null ? real(formatHa(areaHa)) : notReal("нет геометрии"),
    },
    { k: "Категория земель", field: p.landCategory },
    { k: "ВРИ", field: p.vri },
    { k: "Статус ЕГРН", field: p.status },
    {
      k: "Кадастровая стоимость",
      field:
        a.egrn?.cadastral_value_rub != null
          ? real(`${formatInt(a.egrn.cadastral_value_rub)} ₽`)
          : notReal("нет данных ЕГРН"),
    },
    {
      k: "Стоимость, ₽/м²",
      field:
        a.egrn?.cost_per_m2_rub != null
          ? real(formatRubPerM2(a.egrn.cost_per_m2_rub))
          : notReal("нет данных ЕГРН"),
    },
    {
      k: "Дата обновления ЕГРН",
      field: a.egrn?.last_egrn_update_date
        ? real(a.egrn.last_egrn_update_date)
        : notReal("нет данных ЕГРН"),
    },
    { k: "Обременения", field: notReal("источник ЕГРН") },
  ];

  const zouitOverlaps = (zouit ?? []).map((z) => ({
    layer: z.layer,
    name: z.name ?? z.type_zone ?? z.category_name ?? "ЗОУИТ",
    sub: z.subcategory != null ? String(z.subcategory) : DASH,
  }));

  return {
    summary,
    registry,
    zouitCount: zouit != null ? zouit.length : null,
    zouitOverlaps,
  };
}

// ── buildability — factor breakdown (DERIVED / предв.) ─────────────────────────

export interface PticaFactorBar {
  name: string;
  pct: number;
  value: string;
  tone: "good" | "bad" | "neutral";
  reason: string;
}

export interface PticaBuildabilityDrawer {
  gauge: PticaGauge;
  summary: string;
  factors: PticaFactorBar[];
  geometry: DrawerKvRow[];
  penalties: Array<{ label: string; impact: string }>;
  isReal: boolean;
}

export function adaptBuildabilityDrawer(
  a: ParcelAnalysis,
): PticaBuildabilityDrawer {
  const gauge = adaptBuildabilityGauge(a);
  const gate = a.gate_verdict;
  const geom = a.geometry_suitability;
  const utilCount = a.utilities?.summary?.length ?? null;

  // Soft, derived factor contributions — labelled «предв.» everywhere.
  const factors: PticaFactorBar[] = [
    {
      name: "Гейт-вердикт",
      pct:
        gate?.can_build_mkd === true
          ? 90
          : gate?.can_build_mkd === false
            ? 20
            : 55,
      value: gate?.verdict_label ?? DASH,
      tone:
        gate?.can_build_mkd === true
          ? "good"
          : gate?.can_build_mkd === false
            ? "bad"
            : "neutral",
      reason: gate ? `источник: ${gate.source}` : "нет gate_verdict",
    },
    {
      name: "Геометрия",
      pct: geom?.suitability_score != null ? geom.suitability_score * 100 : 0,
      value:
        geom?.suitability_score != null
          ? `${fmtNum(geom.suitability_score * 10, 1)} / 10`
          : DASH,
      tone: "neutral",
      reason: geom?.label ? `форма: ${geom.label}` : "нет геометрии",
    },
    {
      name: "Инженерия",
      pct: utilCount != null ? Math.min(100, utilCount * 20) : 0,
      value: utilCount != null ? `${utilCount} сетей` : DASH,
      tone: utilCount != null && utilCount >= 3 ? "good" : "neutral",
      reason: "близость инж. узлов (utilities)",
    },
  ];

  // ── Пятно / продаваемая площадь — REAL when ПЗЗ max_far flows (PR #1847) ──
  // КСИТ ёмкость (надземная GFA) = площадь × max_far. Footprint (пятно) needs
  // the building-coverage % (max_building_pct); sellable still depends on
  // МОП/parking, so it stays an estimate placeholder.
  const z = a.nspd_zoning;
  const areaM2 = parcelAreaM2(a);
  const maxFar = coerceFloat(z?.max_far);
  const farReal = maxFar != null;
  const buildingPct = coerceFloat(z?.max_building_pct);

  const spotRow: DrawerKvRow =
    areaM2 != null && buildingPct != null
      ? {
          k: "Пятно застройки",
          field: {
            value: `${formatInt(areaM2 * (buildingPct / 100))} м²`,
            isReal: true,
            caption: `${formatInt(buildingPct)} % площади · НСПД`,
          },
        }
      : { k: "Пятно застройки", field: notReal(SRC_NSPD_ZONING) };

  const sellableRow: DrawerKvRow =
    farReal && areaM2 != null
      ? {
          k: "Продаваемая площадь",
          field: {
            value: `≈ ${formatInt(areaM2 * maxFar)} м²`,
            isReal: true,
            caption: "оценка по КСИТ-ёмкости · без вычета МОП/паркинга",
          },
        }
      : { k: "Продаваемая площадь", field: notReal("нужен max_far (НСПД)") };

  const geometry: DrawerKvRow[] = [
    {
      k: "Площадь",
      field:
        geom?.area_ha != null
          ? real(`${formatHa(geom.area_ha)} · ${areaM2FromHa(geom.area_ha)}`)
          : notReal("нет геометрии"),
    },
    {
      k: "Периметр",
      field: metersField(geom?.perimeter_m, "нет геометрии"),
    },
    {
      k: "Соотношение сторон",
      field:
        geom?.aspect_ratio != null
          ? real(fmtNum(geom.aspect_ratio, 2))
          : notReal("нет геометрии"),
    },
    {
      k: "Suitability score",
      field:
        geom?.suitability_score != null
          ? real(`${fmtNum(geom.suitability_score * 10, 1)} / 10`)
          : notReal("нет геометрии"),
    },
    spotRow,
    sellableRow,
  ];

  const penalties = (geom?.penalties ?? []).map((p) => ({
    label: p,
    impact: "штраф",
  }));

  const summary = farReal
    ? "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
      "участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
      "значение появится после финмодели. КСИТ-ёмкость рассчитана по регламенту НСПД."
    : "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
      "участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
      "значение появится после расчёта потенциала (max_far НСПД) и финмодели.";

  return {
    gauge,
    summary,
    factors,
    geometry,
    penalties,
    isReal: false,
  };
}

// ── urban — Градостроительство (mostly PLACEHOLDER) ───────────────────────────

export interface PticaUrbanDrawer {
  summary: string;
  regulation: DrawerKvRow[];
  zoneCode: PticaField;
  zoneName: PticaField;
}

export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
  const zoning = a.nspd_zoning;
  // Территориальная зона: prefer human index ("Ж-2"), else reg-number code.
  const zone = regulationZone(zoning);
  const zoneCode = zone ? real(zone) : notReal(SRC_NSPD_ZONING);
  const zoneName = zoning?.zone_name
    ? real(zoning.zone_name)
    : notReal(SRC_NSPD_ZONING);

  const maxFar = coerceFloat(zoning?.max_far);
  const maxHeight = coerceFloat(zoning?.max_height_m);
  const buildingPct = coerceFloat(zoning?.max_building_pct);
  const areaM2 = parcelAreaM2(a);
  const regSource = zoning?.regulation_source;

  // КСИТ ёмкость (надземная GFA) = площадь × max_far.
  const densityField: PticaField =
    maxFar != null && areaM2 != null
      ? {
          value: `${formatInt(areaM2 * maxFar)} м²`,
          isReal: true,
          caption: regSource
            ? `КСИТ ${formatFar(maxFar)} · ${regSource}`
            : `КСИТ ${formatFar(maxFar)} · НСПД`,
        }
      : maxFar != null
        ? notReal("нужна геометрия участка")
        : notReal("после расчёта потенциала");

  const farReal = maxFar != null;
  const regulation: DrawerKvRow[] = [
    { k: "Территориальная зона", field: zoneCode },
    { k: "Наименование зоны", field: zoneName },
    {
      k: "ВРИ (основной)",
      field: a.egrn?.permitted_use_text
        ? real(a.egrn.permitted_use_text)
        : notReal("нет данных ЕГРН"),
    },
    {
      k: "КСИТ (max FAR)",
      field: farReal
        ? real(formatFar(maxFar))
        : notReal("нужен max_far (НСПД)"),
    },
    {
      k: "Предельная высота",
      field:
        maxHeight != null
          ? real(`${formatInt(maxHeight)} м`)
          : notReal(SRC_NSPD_ZONING),
    },
    {
      k: "Этажность (предельная)",
      field:
        zoning?.max_floors != null
          ? real(`${formatInt(zoning.max_floors)} эт.`)
          : notReal(SRC_NSPD_ZONING),
    },
    {
      k: "Коэфф. застройки (пятно)",
      field:
        buildingPct != null
          ? real(`${formatInt(buildingPct)} %`)
          : notReal(SRC_NSPD_ZONING),
    },
    { k: "Плотность (КСИТ)", field: densityField },
  ];

  const summary = farReal
    ? "Градостроительный регламент участка из НСПД-зонирования (ПЗЗ). " +
      "Территориальная зона, КСИТ (max_far), предельная высота и коэффициент " +
      "застройки — реальные регламентные значения; плотность рассчитана от " +
      "площади участка."
    : "Градостроительный регламент участка. Территориальная зона и ВРИ берутся из " +
      "НСПД-зонирования / ЕГРН; КСИТ (max_far), предельная высота и плотность пока " +
      "недоступны в выгрузке — отмечены «—» с источником.";

  return { summary, regulation, zoneCode, zoneName };
}

// ── restrictions / legal — ЗОУИТ (REAL list) ──────────────────────────────────

export interface PticaRestrictionsDrawer {
  summary: string;
  zouitCount: number | null;
  zouitRows: Array<{ layer: string; sub: string; reg: string }>;
  statusRows: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
}

export function adaptRestrictionsDrawer(
  a: ParcelAnalysis,
): PticaRestrictionsDrawer {
  const zouit = a.nspd_zouit_overlaps ?? null;
  const count = zouit != null ? zouit.length : null;

  const zouitRows = (zouit ?? []).map((z) => ({
    layer: z.name ?? z.type_zone ?? z.layer,
    sub:
      z.category_name ?? (z.subcategory != null ? String(z.subcategory) : DASH),
    reg: z.group_key,
  }));

  const statusRows: Array<{
    label: string;
    state: string;
    tone: "ok" | "warn";
  }> = [
    {
      label: "ЗОУИТ (зоны с особыми условиями)",
      state: count != null ? `${count}` : "нет данных НСПД",
      tone: count != null && count > 0 ? "warn" : "ok",
    },
    {
      label: "Красные линии",
      state:
        a.nspd_red_lines != null
          ? `${a.nspd_red_lines.length}`
          : "источник НСПД",
      tone: (a.nspd_red_lines?.length ?? 0) > 0 ? "warn" : "ok",
    },
    {
      label: "ОКН (объекты культ. наследия)",
      state: "источник НСПД",
      tone: "ok",
    },
    { label: "Сервитуты", state: "источник ЕГРН", tone: "ok" },
  ];

  const summary =
    count != null
      ? `Зон с особыми условиями использования территории (ЗОУИТ): ${count}. ` +
        "Красные линии, ОКН и сервитуты требуют отдельной выгрузки НСПД / ЕГРН."
      : "Данные НСПД по ограничениям недоступны — ЗОУИТ, красные линии, ОКН и " +
        "сервитуты отмечены источником.";

  return { summary, zouitCount: count, zouitRows, statusRows };
}

export interface PticaLegalDrawer {
  summary: string;
  gate: DrawerKvRow[];
  restrictions: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
  registry: DrawerKvRow[];
}

export function adaptLegalDrawer(a: ParcelAnalysis): PticaLegalDrawer {
  const gate = a.gate_verdict;
  const restr = adaptRestrictionsDrawer(a);

  const gateRows: DrawerKvRow[] = [
    {
      k: "Можно строить МКД",
      field: gate ? real(gate.verdict_label) : notReal("нет gate_verdict"),
    },
    {
      k: "Статус участка",
      field: a.egrn?.parcel_status
        ? real(a.egrn.parcel_status)
        : notReal("нет данных ЕГРН"),
    },
    {
      k: "Категория земель",
      field: a.egrn?.land_category
        ? real(a.egrn.land_category)
        : notReal("нет данных ЕГРН"),
    },
    {
      k: "ВРИ",
      field: a.egrn?.permitted_use_text
        ? real(a.egrn.permitted_use_text)
        : notReal("нет данных ЕГРН"),
    },
    {
      k: "Зона ПЗЗ",
      field: a.nspd_zoning?.zone_code
        ? real(a.nspd_zoning.zone_code)
        : notReal("источник НСПД"),
    },
  ];

  const registry: DrawerKvRow[] = [
    {
      k: "ЗОУИТ",
      field:
        restr.zouitCount != null
          ? real(formatInt(restr.zouitCount))
          : notReal("источник НСПД"),
    },
    { k: "Обременения", field: notReal("источник ЕГРН") },
    {
      k: "Блокеры (gate)",
      field: gate
        ? real(formatInt(gate.blockers.length))
        : notReal("нет gate_verdict"),
    },
    {
      k: "Предупреждения (gate)",
      field: gate
        ? real(formatInt(gate.warnings.length))
        : notReal("нет gate_verdict"),
    },
  ];

  const summary =
    "Правовой статус: гейт-вердикт о допустимости МКД (НСПД-дамп) + реестр " +
    "ограничений. Обременения в текущей выгрузке не раскрываются («—»).";

  return { summary, gate: gateRows, restrictions: restr.statusRows, registry };
}

// ── engineering — utilities.summary (REAL) ────────────────────────────────────

export interface PticaEngineeringDrawer {
  summary: string;
  rows: Array<{ label: string; nearest: string; name: string; count: string }>;
  bars: PticaFactorBar[];
  hasData: boolean;
}

export function adaptEngineeringDrawer(
  a: ParcelAnalysis,
): PticaEngineeringDrawer {
  const summaryArr = a.utilities?.summary ?? [];
  const hasData = summaryArr.length > 0;

  const rows = summaryArr.map((u) => ({
    label: utilityLabel(u.subtype),
    nearest: `${formatInt(u.nearest_m)} м`,
    name: u.name ?? DASH,
    count: u.count_within_2km != null ? formatInt(u.count_within_2km) : DASH,
  }));

  // bar = inverse distance (closer → fuller). Cap reference 2000 m.
  const bars: PticaFactorBar[] = summaryArr.map((u) => ({
    name: utilityLabel(u.subtype),
    pct: Math.max(4, 100 - Math.min(100, (u.nearest_m / 2000) * 100)),
    value: `${formatInt(u.nearest_m)} м`,
    tone: u.nearest_m <= 700 ? "good" : "neutral",
    reason: u.name ?? "",
  }));

  const summary = hasData
    ? `Инженерная обеспеченность: ${summaryArr.length} типов сетей в радиусе ` +
      "анализа. Расстояния — до ближайшего узла каждого ресурса (НСПД)."
    : "Данные по инженерным сетям недоступны для участка.";

  return { summary, rows, bars, hasData };
}

// ── market — 4 sub-tabs (comp REAL · plan/speed/trend mixed) ───────────────────

export interface PticaMarketCompetitor {
  name: string;
  dev: string;
  cls: string;
  flats: string;
  distance: string;
  status: string;
}

export interface PticaMarketDrawer {
  summary: string;
  competitors: PticaMarketCompetitor[];
  medianField: PticaField;
  trend: {
    field: PticaField;
    recent: string;
    prior: string;
    count: string;
    available: boolean;
  };
  speed: {
    field: PticaField;
    velocityScore: string;
    monthly: string;
    median: string;
    confidence: string;
    available: boolean;
  };
  plan: {
    available: boolean;
    note: string;
    pipelineByClass: Array<{ cls: string; count: number }>;
  };
}

function competitorRow(c: ParcelAnalysisCompetitor): PticaMarketCompetitor {
  return {
    name: c.comm_name ?? `#${c.obj_id}`,
    dev: c.dev_name ?? DASH,
    cls: c.obj_class ?? DASH,
    flats: c.flat_count != null ? formatInt(c.flat_count) : DASH,
    distance: `${formatInt(c.distance_m)} м`,
    status: c.site_status ?? DASH,
  };
}

export function adaptMarketDrawer(a: ParcelAnalysis): PticaMarketDrawer {
  const competitors = (a.competitors ?? []).slice(0, 12).map(competitorRow);
  const median = a.district?.median_price_per_m2;
  const medianField =
    median != null
      ? real(formatRubPerM2(median))
      : notReal("нет данных района");

  const mt = a.market_trend;
  // P2 (#1871): тренд «настоящий» только при status "ok" (или старом ответе без
  // status, где числовые поля присутствуют). source_stale/no_deals → честный
  // placeholder с причиной вместо фейкового процента.
  const mtStatus = mt?.status;
  const mtComputed =
    mt != null &&
    (mtStatus == null || mtStatus === "ok") &&
    mt.delta_6m_pct != null &&
    mt.recent_avg_price_per_m2 != null &&
    mt.prior_avg_price_per_m2 != null;
  const trendCaption =
    mtStatus === "source_stale"
      ? "источник сделок устарел"
      : mtStatus === "no_deals"
        ? "нет сделок за период"
        : "после прогноза";
  const trend = {
    field: mtComputed
      ? real(
          `${mt!.delta_6m_pct! >= 0 ? "+" : ""}${fmtNum(mt!.delta_6m_pct!, 1)}%`,
        )
      : notReal(trendCaption),
    recent: mtComputed ? formatRubPerM2(mt!.recent_avg_price_per_m2!) : DASH,
    prior: mtComputed ? formatRubPerM2(mt!.prior_avg_price_per_m2!) : DASH,
    count: mt != null ? `${formatInt(mt.recent_deals_count)} сделок` : DASH,
    available: mtComputed,
  };

  const v = a.velocity ?? null;
  const speed = {
    field:
      v != null
        ? real(`${fmtNum(v.velocity_score * 10, 1)} / 10`)
        : notReal("после прогноза"),
    velocityScore: v != null ? fmtNum(v.velocity_score * 10, 1) : DASH,
    monthly: v != null ? `${formatInt(v.monthly_velocity_sqm)} м²/мес` : DASH,
    median: v != null ? `${formatInt(v.ekb_median_sqm)} м²/мес` : DASH,
    confidence: v != null ? v.confidence : DASH,
    available: v != null,
  };

  const pipeline = a.pipeline_24mo ?? null;
  const pipelineByClass = pipeline
    ? Object.entries(pipeline.by_class).map(([cls, count]) => ({ cls, count }))
    : [];
  const plan = {
    available: pipeline != null,
    note:
      pipeline != null
        ? `Пайплайн 24 мес: ${pipeline.objects_count} проектов, ` +
          `${formatInt(pipeline.flats_total)} квартир.`
        : "Планировки и квартирография — после прогноза (§22).",
    pipelineByClass,
  };

  const summary =
    `Конкурентов в радиусе анализа: ${competitors.length}` +
    (median != null ? `, медиана ${formatRubPerM2(median)}` : "") +
    (pipeline != null
      ? `, ${pipeline.objects_count} проектов в пайплайне`
      : "") +
    ". Тренд, скорость и планировки уточняются прогнозом §22.";

  return { summary, competitors, medianField, trend, speed, plan };
}

// ── environment (Среда) — noise / AQI / wind / weather (REAL) ──────────────────

export interface PticaEnvironmentDrawer {
  summary: string;
  noise: {
    rows: DrawerKvRow[];
    sources: Array<{ name: string; db: string; dist: string }>;
    available: boolean;
  };
  air: { rows: DrawerKvRow[]; available: boolean };
  wind: { rows: DrawerKvRow[]; available: boolean };
  weather: { rows: DrawerKvRow[]; available: boolean };
}

const WIND_DIR_NOTE = "источник: прогноз ветра";

export function adaptEnvironmentDrawer(
  a: ParcelAnalysis,
): PticaEnvironmentDrawer {
  const n = a.noise;
  const aq = a.air_quality;
  const w = a.wind;
  const wt = a.weather;

  const noiseRows: DrawerKvRow[] = n
    ? [
        { k: "Уровень", field: real(n.level) },
        { k: "Оценка шума", field: real(`${formatInt(n.estimated_db)} дБ`) },
        { k: "Скор", field: real(fmtNum(n.score, 2)) },
        { k: "Источников шума", field: real(formatInt(n.sources.length)) },
      ]
    : [{ k: "Шум", field: notReal("нет данных шума") }];

  const airRows: DrawerKvRow[] = aq
    ? [
        { k: "PM2.5", field: real(`${fmtNum(aq.pm2_5, 1)} мкг/м³`) },
        { k: "PM10", field: real(`${fmtNum(aq.pm10, 1)} мкг/м³`) },
        { k: "NO₂", field: real(`${fmtNum(aq.no2, 1)} мкг/м³`) },
        { k: "Источник", field: real(aq.source) },
      ]
    : [{ k: "Воздух", field: notReal("нет данных AQI") }];

  const windRows: DrawerKvRow[] = w
    ? [
        {
          k: "Направление",
          field: real(
            `${w.dominant_direction_label} (${formatInt(w.dominant_direction_deg)}°)`,
          ),
        },
        {
          k: "Макс. скорость",
          field:
            w.max_speed_m_s != null
              ? real(`${fmtNum(w.max_speed_m_s, 1)} м/с`)
              : notReal(WIND_DIR_NOTE),
        },
        {
          k: "Горизонт прогноза",
          field: real(`${formatInt(w.forecast_days)} дн.`),
        },
      ]
    : [{ k: "Ветер", field: notReal("нет данных ветра") }];

  const weatherRows: DrawerKvRow[] = wt
    ? [
        {
          k: "Ср. макс. °C",
          field:
            wt.temperature.avg_max_c != null
              ? real(`${fmtNum(wt.temperature.avg_max_c, 1)} °C`)
              : notReal("нет данных погоды"),
        },
        {
          k: "Ср. мин. °C",
          field:
            wt.temperature.avg_min_c != null
              ? real(`${fmtNum(wt.temperature.avg_min_c, 1)} °C`)
              : notReal("нет данных погоды"),
        },
        {
          k: "Осадки (сумма)",
          field: real(`${formatInt(wt.precipitation_total_mm)} мм`),
        },
        { k: "Дней с осадками", field: real(formatInt(wt.precipitation_days)) },
      ]
    : [{ k: "Погода", field: notReal("нет данных погоды") }];

  const sources = (n?.sources ?? []).map((s) => ({
    name: s.name ?? s.source_type,
    db: `${formatInt(s.estimated_db)} дБ`,
    dist: `${formatInt(s.distance_m)} м`,
  }));

  const summary =
    "Средовые факторы участка: шум, качество воздуха, преобладающий ветер и " +
    "погодный фон. Данные из атмосферного слоя /analyze.";

  return {
    summary,
    noise: { rows: noiseRows, sources, available: n != null },
    air: { rows: airRows, available: aq != null },
    wind: { rows: windRows, available: w != null },
    weather: { rows: weatherRows, available: wt != null },
  };
}

// ── georisks — geology / hydrology / geotech (REAL where present) ──────────────

export interface PticaGeorisksDrawer {
  summary: string;
  geology: {
    rows: DrawerKvRow[];
    links: Array<{ label: string; href: string }>;
    available: boolean;
  };
  hydrology: { rows: DrawerKvRow[]; available: boolean };
  geotech: { rows: DrawerKvRow[]; available: boolean };
}

export function adaptGeorisksDrawer(a: ParcelAnalysis): PticaGeorisksDrawer {
  const g = a.geology;
  const h = a.hydrology;
  const gt = a.geotech_risk;

  const geologyRows: DrawerKvRow[] = g
    ? [
        {
          k: "Данные геологии",
          field: g.data_available
            ? real("доступны")
            : notReal("нет ИГИ — нужен полевой расчёт"),
        },
        { k: "Примечание", field: g.note ? real(g.note) : notReal("—") },
      ]
    : [{ k: "Геология", field: notReal("нет данных геологии") }];

  const geologyLinks = g
    ? [
        { label: "Карпинский WebGIS", href: g.links.karpinsky_webgis },
        { label: "ЕФГИ (фед. реестр)", href: g.links.efgi_federal_registry },
      ]
    : [];

  const hydrologyRows: DrawerKvRow[] = h
    ? [
        {
          k: "Риск подтопления",
          field: real(h.flood_risk_flag ? "есть" : "нет"),
        },
        {
          k: "Ближайший водоём",
          field:
            h.nearest.length > 0
              ? real(`${formatInt(h.nearest[0].distance_m)} м`)
              : notReal("нет данных"),
        },
        { k: "Примечание", field: h.note ? real(h.note) : notReal("—") },
      ]
    : [{ k: "Гидрология", field: notReal("нет данных гидрологии") }];

  const geotechRows: DrawerKvRow[] = gt
    ? [
        { k: "Сейсмика", field: real(gt.seismic_label) },
        {
          k: "Баллы (сейсм.)",
          field:
            gt.seismic_intensity_balls != null
              ? real(formatInt(gt.seismic_intensity_balls))
              : notReal("нет данных"),
        },
        {
          k: "Многолетняя мерзлота",
          field: real(gt.permafrost ? "есть" : "нет"),
        },
        {
          k: "Пром. объектов <500 м",
          field: real(formatInt(gt.industrial_within_500m)),
        },
      ]
    : [{ k: "Геотехника", field: notReal("нет данных геотехники") }];

  const summary =
    "Инженерно-геологический фон участка: геология, гидрология и геотехнические " +
    "риски. Поля без полевых данных отмечены «—» (нужны ИГИ).";

  return {
    summary,
    geology: { rows: geologyRows, links: geologyLinks, available: g != null },
    hydrology: { rows: hydrologyRows, available: h != null },
    geotech: { rows: geotechRows, available: gt != null },
  };
}

// ── risks — composite breakdown (DERIVED / предв.) ─────────────────────────────

export interface PticaRisksDrawer {
  gauge: PticaGauge;
  summary: string;
  breakdown: PticaFactorBar[];
  blockers: Array<{ code: string; detail: string }>;
  warnings: Array<{ code: string; detail: string }>;
  isReal: boolean;
}

export function adaptRisksDrawer(a: ParcelAnalysis): PticaRisksDrawer {
  const gauge = adaptRiskGauge(a);
  const gate = a.gate_verdict;
  const geom = a.geometry_suitability;
  const n = a.noise;

  const breakdown: PticaFactorBar[] = [
    {
      name: "Правовые / гейт",
      pct: gate
        ? Math.min(100, gate.blockers.length * 30 + gate.warnings.length * 12)
        : 0,
      value: gate
        ? `${gate.blockers.length} блок. · ${gate.warnings.length} пред.`
        : DASH,
      tone: (gate?.blockers.length ?? 0) > 0 ? "bad" : "good",
      reason: gate ? "из gate_verdict" : "нет gate_verdict",
    },
    {
      name: "Геометрия",
      pct:
        geom?.suitability_score != null
          ? (1 - geom.suitability_score) * 100
          : 0,
      value:
        geom?.suitability_score != null
          ? `${fmtNum((1 - geom.suitability_score) * 10, 1)} / 10`
          : DASH,
      tone: "neutral",
      reason: geom?.label ? geom.label : "нет геометрии",
    },
    {
      name: "Шум / среда",
      pct: n != null ? Math.min(100, (n.estimated_db / 80) * 100) : 0,
      value: n != null ? `${formatInt(n.estimated_db)} дБ · ${n.level}` : DASH,
      tone: n != null && n.estimated_db >= 60 ? "bad" : "neutral",
      reason: n != null ? "из шумового слоя" : "нет данных шума",
    },
  ];

  const summary =
    "Сводный риск — предварительная композиция из гейт-вердикта, геометрии и " +
    "средовых факторов. Это эвристика («предв.»), а не итоговая риск-модель.";

  return {
    gauge,
    summary,
    breakdown,
    blockers: gate?.blockers ?? [],
    warnings: gate?.warnings ?? [],
    isReal: false,
  };
}

// ── product — §22 product_tz («после прогноза» while absent) ───────────────────

export interface PticaProductDrawer {
  available: boolean;
  summary: string;
  objClass: PticaField;
  mix: Array<{ bucket: string; cls: string; signal: string; pct: number }>;
  usp: Array<{ text: string; cls: string }>;
  reasons: string[];
}

function mixBarPct(entry: ProductMixEntry, total: number): number {
  if (entry.pct != null) return entry.pct;
  // No explicit share → even split as a visual hint only.
  return total > 0 ? Math.round(100 / total) : 0;
}

export function adaptProductDrawer(
  report?: ForecastReport,
): PticaProductDrawer {
  const tz = report?.product_tz;
  if (!tz) {
    return {
      available: false,
      summary:
        "Продуктовая стратегия (класс, квартирография, USP) формируется в " +
        "асинхронном прогнозе §22. Откройте вкладку «Сценарии», чтобы запустить расчёт.",
      objClass: notReal("после прогноза (Scenarios)"),
      mix: [],
      usp: [],
      reasons: [],
    };
  }

  const mix = tz.mix.map((m) => ({
    bucket: m.bucket ?? DASH,
    cls: m.obj_class ?? DASH,
    signal: m.signal ?? DASH,
    pct: mixBarPct(m, tz.mix.length),
  }));

  const usp = tz.usp.map((u) => ({
    text: u.usp_text ?? DASH,
    cls: u.obj_class ?? DASH,
  }));

  const reasons = tz.reasons
    .map((r) => r.why)
    .filter((w): w is string => Boolean(w));

  return {
    available: true,
    summary: tz.summary ?? "Рекомендация продукта из прогноза §22 (advisory).",
    objClass: tz.obj_class
      ? { value: tz.obj_class, isReal: true, caption: "advisory · §22" }
      : notReal("нет рекомендации класса"),
    mix,
    usp,
    reasons,
  };
}

// ── potential — ёмкость (PLACEHOLDER — нужен max_far) ──────────────────────────

export interface PticaPotentialDrawer {
  summary: string;
  rows: DrawerKvRow[];
}

export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer {
  const areaHa = a.geometry_suitability?.area_ha;
  const areaField =
    areaHa != null
      ? real(`${areaM2FromHa(areaHa)} (${formatHa(areaHa)})`)
      : notReal("нет геометрии");

  const z = a.nspd_zoning;
  const areaM2 = parcelAreaM2(a);
  const maxFar = coerceFloat(z?.max_far);
  const farReal = maxFar != null;
  const buildingPct = coerceFloat(z?.max_building_pct);
  const maxHeight = coerceFloat(z?.max_height_m);
  const maxFloors = z?.max_floors;

  // КСИТ ёмкость (надземная GFA) = площадь × max_far — real with both inputs.
  const capacityField: PticaField =
    farReal && areaM2 != null
      ? {
          value: `${formatInt(areaM2 * maxFar)} м²`,
          isReal: true,
          caption: `КСИТ ${formatFar(maxFar)} · площадь × КСИТ`,
        }
      : notReal("нужен max_far (НСПД)");

  // Пятно застройки = площадь × коэф. застройки (max_building_pct).
  const spotField: PticaField =
    areaM2 != null && buildingPct != null
      ? {
          value: `${formatInt(areaM2 * (buildingPct / 100))} м²`,
          isReal: true,
          caption: `${formatInt(buildingPct)} % площади · НСПД`,
        }
      : notReal("нужен % застройки (НСПД)");

  // Расчётная этажность — прямо из регламента, либо высота / 3 м (этаж).
  const floorsField: PticaField =
    maxFloors != null
      ? real(`${formatInt(maxFloors)} эт.`)
      : maxHeight != null
        ? {
            value: `≈ ${formatInt(maxHeight / 3)} эт.`,
            isReal: true,
            caption: `от предельной высоты ${formatInt(maxHeight)} м`,
          }
        : notReal("нужна предельная высота (НСПД)");

  const rows: DrawerKvRow[] = [
    { k: "Площадь участка", field: areaField },
    {
      k: "КСИТ (max_far)",
      field: farReal
        ? real(formatFar(maxFar))
        : notReal("нужен max_far (НСПД)"),
    },
    { k: "Ёмкость по КСИТ", field: capacityField },
    { k: "Пятно застройки", field: spotField },
    {
      k: "Продаваемая площадь",
      field:
        farReal && areaM2 != null
          ? {
              value: `≈ ${formatInt(areaM2 * maxFar)} м²`,
              isReal: true,
              caption: "оценка по КСИТ-ёмкости · без вычета МОП/паркинга",
            }
          : notReal("после расчёта потенциала"),
    },
    { k: "Расчётная этажность", field: floorsField },
  ];

  const summary = farReal
    ? "Расчёт ёмкости по регламенту НСПД-зонирования: ёмкость по КСИТ = площадь × " +
      "max_far, пятно — по коэффициенту застройки. Продаваемая площадь — оценка " +
      "(без вычета МОП и паркинга), уточняется финмоделью."
    : "Расчёт ёмкости (плотность, пятно, продаваемая площадь) требует предельного " +
      "КСИТ (max_far) и высоты из НСПД-зонирования. Доступна только площадь участка.";

  return { summary, rows };
}

// ── economy / finance — PLACEHOLDER (после финмодели) ──────────────────────────

export interface PticaEconomyDrawer {
  summary: string;
  rows: DrawerKvRow[];
}

export function adaptEconomyDrawer(): PticaEconomyDrawer {
  return {
    summary:
      "Экономика проекта (выручка, маржа, ROI, IRR) рассчитывается после " +
      "финансовой модели (§22). Пока показатели недоступны.",
    rows: [
      { k: "Потенц. выручка (GDV)", field: notReal("после финмодели") },
      { k: "Себестоимость", field: notReal("после финмодели") },
      { k: "Маржа", field: notReal("после финмодели") },
      { k: "ROI", field: notReal("после финмодели") },
      { k: "IRR", field: notReal("после финмодели") },
    ],
  };
}

export function adaptFinanceDrawer(
  financial?: ParcelFinancialEstimate | null,
): PticaEconomyDrawer {
  if (!financial) {
    return {
      summary:
        "Финансовая модель (P&L, cash-flow по фазам, сценарный анализ) формируется " +
        "в асинхронном расчёте §22. Откройте «Сценарии», чтобы запустить прогноз.",
      rows: [
        { k: "GDV (выручка)", field: notReal("после финмодели") },
        { k: "Совокупные затраты", field: notReal("после финмодели") },
        { k: "Чистая прибыль", field: notReal("после финмодели") },
        { k: "ROI / IRR", field: notReal("после финмодели") },
        { k: "Срок проекта", field: notReal("после финмодели") },
      ],
    };
  }

  const teap = financial.teap_synth;
  const priceSourceSuffix = priceSourceCaption(financial.price_source);
  const priceLabel = financial.price_is_calibrated
    ? "рынок (Objective)"
    : `норматив класса${priceSourceSuffix ? ` · ${priceSourceSuffix}` : ""}`;
  const irrLabel = financial.irr_is_proxy
    ? `${formatPctFromFraction(financial.irr)} % · оценочный (proxy)`
    : `${formatPctFromFraction(financial.irr)} % · DCF`;
  const paybackLabel =
    financial.payback_months != null
      ? `${fmtNum(financial.payback_months / 12)} лет (${formatInt(
          financial.payback_months,
        )} мес)`
      : "не окупается в горизонте модели";

  // #1881 PR-5 — финансирование (кредит). Показываем строки только когда
  // financing_enabled; проценты — реальная стоимость (чистая прибыль после
  // финансирования < чистой прибыли до). financing_is_simplified → честный
  // caveat в summary, чтобы финдиректор не принял упрощение за точную модель.
  const financingRows: DrawerKvRow[] = financial.financing_enabled
    ? [
        {
          k: "Пиковый долг",
          field: real(`${formatBln(financial.peak_debt_rub)} млрд ₽`),
        },
        {
          k: "Проценты по кредиту",
          field: {
            value: `${formatBln(financial.total_interest_rub)} млрд ₽`,
            isReal: true,
            caption: `ставка ${formatPctFromFraction(
              financial.annual_rate_used,
            )} % годовых`,
          },
        },
        {
          k: "Чистая прибыль после финанс.",
          field: real(
            `${formatBln(financial.net_profit_after_financing_rub)} млрд ₽`,
          ),
        },
        {
          // IRR на собственные средства (equity) при кредитном плече LTC 70 %.
          // Может быть ВЫШЕ проектной IRR (положительный леверидж) ИЛИ НИЖЕ /
          // отрицательной (проект не покрывает стоимость долга) — это корректная
          // финансовая картина, не прячем знак. isReal только когда финмодель
          // посчитала equity-IRR по DCF; proxy/выкл. финансирование → не real.
          k: "IRR на собственные средства",
          field: financial.levered_irr_is_proxy
            ? {
                value: `${formatSignedPctFromFraction(financial.levered_irr)} %`,
                isReal: false,
                caption:
                  "доходность на собственные средства при кредите LTC 70 % · " +
                  "оценка (proxy), не DCF · может быть ниже проектной IRR, если " +
                  "проект не покрывает стоимость долга",
              }
            : {
                value: `${formatSignedPctFromFraction(financial.levered_irr)} %`,
                isReal: true,
                caption:
                  "доходность на собственные средства при кредите LTC 70 % · " +
                  "может быть ниже проектной IRR, если проект не покрывает " +
                  "стоимость долга",
              },
        },
      ]
    : [];

  const summary =
    financial.financing_enabled && financial.financing_is_simplified
      ? `${financial.caveat} Финансирование: 70 % — банк (кредитная линия, норматив МКД РФ), ` +
        "30 % — собственные средства инвестора (без процентов). Проценты капитализируются, " +
        "эскроу не моделируется точно."
      : financial.caveat;

  return {
    summary,
    rows: [
      ...financingRows,
      {
        k: "GDV (выручка)",
        field: real(`${formatBln(financial.revenue_rub)} млрд ₽`),
      },
      {
        k: "Совокупные затраты",
        field: real(`${formatBln(financial.cost_rub)} млрд ₽`),
      },
      {
        k: "Чистая прибыль",
        field: real(`${formatBln(financial.net_profit_rub)} млрд ₽`),
      },
      { k: "ROI", field: real(`${formatPctFromFraction(financial.roi)} %`) },
      {
        k: "Маржа",
        field: real(`${formatPctFromFraction(financial.margin_pct)} %`),
      },
      { k: "IRR", field: real(irrLabel) },
      { k: "NPV", field: real(`${formatBln(financial.npv_rub)} млрд ₽`) },
      {
        k: "Ставка дисконт.",
        field: real(`${formatPctFromFraction(financial.discount_rate_used)} %`),
      },
      { k: "Срок окупаемости", field: real(paybackLabel) },
      {
        k: "Цена ₽/м²",
        field: real(
          `${formatRubPerM2(financial.price_per_sqm_used)} · ${priceLabel}`,
        ),
      },
      {
        k: "Класс жилья",
        field: real(
          _HOUSING_CLASS_RU[financial.housing_class_inferred] ??
            financial.housing_class_inferred,
        ),
      },
      {
        k: "Тип застройки",
        field: real(
          _DEVELOPMENT_TYPE_RU[financial.development_type_inferred] ??
            financial.development_type_inferred,
        ),
      },
      {
        k: "Горизонт продаж",
        field:
          financial.sales_duration_months != null
            ? {
                value: `${fmtNum(financial.sales_duration_months, 0)} мес`,
                isReal: true,
                caption: financial.schedule_is_default
                  ? "дефолт-норматив"
                  : "по темпу поглощения рынка",
              }
            : notReal("нет данных"),
      },
      {
        k: "ТЭП · жилая площадь",
        field: real(`${formatInt(teap.residential_area_sqm)} м²`),
      },
      {
        k: "ТЭП · общая площадь",
        field: real(`${formatInt(teap.total_floor_area_sqm)} м²`),
      },
      { k: "ТЭП · квартир", field: real(formatInt(teap.apartments_count)) },
      { k: "ТЭП · машино-мест", field: real(formatInt(teap.parking_spaces)) },
    ],
  };
}

// ── oks — Нет данных ──────────────────────────────────────────────────────────

export interface PticaOksDrawer {
  summary: string;
  available: boolean;
}

export function adaptOksDrawer(): PticaOksDrawer {
  return {
    summary:
      "Объекты капитального строительства на участке в текущей выгрузке /analyze " +
      "не раскрываются. Раздел появится после интеграции реестра ОКС (ЕГРН).",
    available: false,
  };
}

// ══════════════════════════════════════════════════════════════════════════════
// COCKPIT LOWER-GRID / BOTTOM-GRID SECTIONS — typed view-models for the cards
// rendered below DevelopmentScan (porting the prototype `.lower-grid` /
// `.bottom-grid`). Same {value, isReal, caption} honesty contract: REAL where the
// backend provides it, honest "—"/placeholder otherwise. No fabricated numbers.
// ══════════════════════════════════════════════════════════════════════════════

// ── ОКС (lower-grid card) — empty-state «Нет данных» ───────────────────────────

export interface PticaOksLowerCard {
  rows: DrawerKvRow[];
  /** When false the card shows the honest empty-state instead of rows. */
  hasData: boolean;
  emptyState: string;
}

/**
 * ОКС on the parcel are not surfaced by /analyze, so this is an honest
 * empty-state card. The isometric building visual is rendered by the React
 * component; this adapter only carries the (currently empty) data + message.
 */
export function adaptOksLowerCard(): PticaOksLowerCard {
  return {
    rows: [
      { k: "ОКСы на участке", field: notReal("нет реестра ОКС (ЕГРН)") },
      { k: "Общая площадь", field: notReal("нет реестра ОКС (ЕГРН)") },
      { k: "Статус", field: notReal("нет реестра ОКС (ЕГРН)") },
    ],
    hasData: false,
    emptyState: "Нет данных",
  };
}

// ── Development Potential (lower-grid card) — REAL (post-#1847) ─────────────────

/** One big-metric tile: a label, a value (split unit) + real/placeholder flag. */
export interface PticaPotentialMetric {
  label: string;
  value: string;
  unit?: string;
  isReal: boolean;
  caption?: string;
  /** Larger type for the two headline tiles (площадь, КСИТ). */
  lead?: boolean;
}

export interface PticaPotentialCard {
  metrics: PticaPotentialMetric[];
}

/**
 * Development Potential — REAL ёмкость-метрики из геометрии + НСПД-регламента,
 * mirroring `adaptPotentialDrawer` but as the compact lower-grid tiles. Площадь
 * участка (geometry), КСИТ (max_far), плотность = площадь×КСИТ, предельная
 * высота, пятно = площадь×max_building_pct, продаваемая ≈ ёмкость по КСИТ.
 * Honest «—» (placeholder) where an input is absent.
 */
export function adaptPotentialCard(a: ParcelAnalysis): PticaPotentialCard {
  const areaHa = a.geometry_suitability?.area_ha;
  const areaM2 = parcelAreaM2(a);
  const z = a.nspd_zoning;
  const maxFar = coerceFloat(z?.max_far);
  const maxHeight = coerceFloat(z?.max_height_m);
  const buildingPct = coerceFloat(z?.max_building_pct);

  const areaMetric: PticaPotentialMetric =
    areaM2 != null
      ? {
          label: "Площадь участка",
          value: formatInt(areaM2),
          unit: "м²",
          isReal: true,
          lead: true,
        }
      : {
          label: "Площадь участка",
          value: PLACEHOLDER,
          isReal: false,
          caption: "нет геометрии",
          lead: true,
        };

  const farMetric: PticaPotentialMetric =
    maxFar != null
      ? {
          label: "КСИТ",
          value: formatFar(maxFar),
          isReal: true,
          caption: "НСПД",
          lead: true,
        }
      : {
          label: "КСИТ",
          value: PLACEHOLDER,
          isReal: false,
          caption: SRC_NSPD_ZONING,
          lead: true,
        };

  const densityMetric: PticaPotentialMetric =
    maxFar != null && areaM2 != null
      ? {
          label: "Плотность (КСИТ)",
          value: formatInt(areaM2 * maxFar),
          unit: "м²",
          isReal: true,
          caption: `площадь × КСИТ ${formatFar(maxFar)}`,
        }
      : {
          label: "Плотность (КСИТ)",
          value: PLACEHOLDER,
          isReal: false,
          caption: SRC_NSPD_ZONING,
        };

  const heightMetric: PticaPotentialMetric =
    maxHeight != null
      ? {
          label: "Предельная высота",
          value: formatInt(maxHeight),
          unit: "м",
          isReal: true,
          caption: "НСПД",
        }
      : {
          label: "Предельная высота",
          value: PLACEHOLDER,
          isReal: false,
          caption: SRC_NSPD_ZONING,
        };

  const spotMetric: PticaPotentialMetric =
    areaM2 != null && buildingPct != null
      ? {
          label: "Пятно застройки",
          value: formatInt(areaM2 * (buildingPct / 100)),
          unit: "м²",
          isReal: true,
          caption: `${formatInt(buildingPct)} % площади · НСПД`,
        }
      : {
          label: "Пятно застройки",
          value: PLACEHOLDER,
          isReal: false,
          caption: SRC_NSPD_ZONING,
        };

  const sellableMetric: PticaPotentialMetric =
    maxFar != null && areaM2 != null
      ? {
          label: "Продаваемая",
          value: `≈ ${formatInt(areaM2 * maxFar)}`,
          unit: "м²",
          isReal: true,
          caption: "оценка по КСИТ-ёмкости · без МОП/паркинга",
        }
      : {
          label: "Продаваемая",
          value: PLACEHOLDER,
          isReal: false,
          caption: "после расчёта потенциала",
        };

  return {
    metrics: [
      areaMetric,
      farMetric,
      densityMetric,
      heightMetric,
      spotMetric,
      sellableMetric,
    ],
  };
}

// ── Investment Clearance (bottom-grid) — finance bignums (PLACEHOLDER, §23) ─────

export interface PticaClearanceBignum {
  label: string;
  value: string;
  unit?: string;
  isReal: boolean;
  caption?: string;
}

export interface PticaInvestmentClearance {
  bignums: PticaClearanceBignum[];
  caption: string;
}

/**
 * Investment Clearance — GDV/COST/PROFIT + ROI/IRR/NPV/срок окуп.
 *
 * When the backend supplies `financial_estimate` (#1881) the bignums carry REAL
 * numbers (isReal:true): суммы в млрд ₽, ROI/IRR в %, NPV, срок окупаемости. Цены
 * либо откалиброваны под рынок (Objective), либо взяты по нормативу класса —
 * caption честно говорит источник. IRR помечается «оценочный (proxy)» когда
 * `irr_is_proxy`. Caveat backend-а сжимается в общий caption — расчёт по
 * МАКСИМАЛЬНОЙ застройке НСПД, а не по реальной концепции.
 *
 * When `financial` is null/undefined the §23 model is not yet available, so EVERY
 * value stays an honest placeholder «—» with a «после финмодели (§23)» caption —
 * NO fabricated numbers.
 */
export function adaptInvestmentClearance(
  financial?: ParcelFinancialEstimate | null,
): PticaInvestmentClearance {
  if (!financial) {
    const fin = "после финмодели (§23)";
    return {
      bignums: [
        {
          label: "GDV · выручка",
          value: PLACEHOLDER,
          unit: "млрд ₽",
          isReal: false,
          caption: fin,
        },
        {
          label: "Cost · затраты",
          value: PLACEHOLDER,
          unit: "млрд ₽",
          isReal: false,
          caption: fin,
        },
        {
          label: "Profit · прибыль",
          value: PLACEHOLDER,
          unit: "млрд ₽",
          isReal: false,
          caption: fin,
        },
        { label: "ROI", value: PLACEHOLDER, isReal: false, caption: fin },
        { label: "IRR", value: PLACEHOLDER, isReal: false, caption: fin },
      ],
      caption: "после финмодели (§23)",
    };
  }

  const priceSourceSuffix = priceSourceCaption(financial.price_source);
  const priceCaption = financial.price_is_calibrated
    ? "цена: рынок (Objective)"
    : `цена: норматив класса${
        priceSourceSuffix ? ` · ${priceSourceSuffix}` : ""
      }`;
  const irrCaption = financial.irr_is_proxy ? "оценочный (proxy)" : "DCF";
  const paybackValue =
    financial.payback_months != null
      ? `${(financial.payback_months / 12).toLocaleString("ru-RU", {
          minimumFractionDigits: 1,
          maximumFractionDigits: 1,
        })} лет`
      : "не окуп.";

  return {
    bignums: [
      {
        label: "GDV · выручка",
        value: formatBln(financial.revenue_rub),
        unit: "млрд ₽",
        isReal: true,
        caption: priceCaption,
      },
      {
        label: "Cost · затраты",
        value: formatBln(financial.cost_rub),
        unit: "млрд ₽",
        isReal: true,
      },
      {
        label: financial.financing_enabled
          ? "Profit · до финанс."
          : "Profit · прибыль",
        value: formatBln(financial.net_profit_rub),
        unit: "млрд ₽",
        isReal: true,
      },
      ...(financial.financing_enabled
        ? [
            {
              label: "После финанс.",
              value: formatBln(financial.net_profit_after_financing_rub),
              unit: "млрд ₽",
              isReal: true,
              caption: `кредит ${formatPctFromFraction(financial.annual_rate_used)} %`,
            },
          ]
        : []),
      {
        label: "ROI",
        value: `${formatPctFromFraction(financial.roi)} %`,
        isReal: true,
      },
      {
        label: "IRR",
        value: `${formatPctFromFraction(financial.irr)} %`,
        isReal: true,
        caption: irrCaption,
      },
      {
        label: "NPV",
        value: formatBln(financial.npv_rub),
        unit: "млрд ₽",
        isReal: true,
      },
      {
        label: "Срок окуп.",
        value: paybackValue,
        isReal: true,
      },
    ],
    caption: "по макс. застройке НСПД · ориентировочно",
  };
}

// ── Buy Signal (bottom-grid) — gauge from §22 forecast deficit_index ───────────

export interface PticaBuySignalCard {
  gauge: PticaGauge;
  /** The verdict word shown under the gauge (Приобретать / Наблюдать / Пропустить). */
  word: string;
  available: boolean;
  caption: string;
}

/**
 * Buy Signal — derived from the §22 forecast exec-summary deficit_index, the SAME
 * logic as `adaptInvestScore`'s buySignal (>0.05 приобретать / <−0.05 пропустить /
 * else наблюдать). The gauge value maps the (signed) deficit_index onto 0..100 for
 * the visual; advisory (§22). While the forecast is pending → «после прогноза».
 */
export function adaptBuySignalGauge(
  report?: ForecastReport,
): PticaBuySignalCard {
  if (!report) {
    return {
      gauge: {
        value: null,
        label: "после прогноза",
        tone: "neutral",
        isReal: false,
        footnote: "Scenarios §22",
      },
      word: PLACEHOLDER,
      available: false,
      caption: "после прогноза (Scenarios)",
    };
  }

  const di = report.exec_summary.key_numbers.deficit_index;

  // Report present but no deficit_index → the gauge has no real metric. Mirror
  // the "no report" branch: muted track, neutral tone, isReal:false. Never dress
  // a synthetic mid-scale 50 up as a live number.
  if (di == null) {
    return {
      gauge: {
        value: null,
        label: "после прогноза",
        tone: "neutral",
        isReal: false,
        footnote: "Scenarios §22",
      },
      word: PLACEHOLDER,
      available: false,
      caption: "после прогноза (Scenarios)",
    };
  }

  // Map signed deficit_index (roughly −0.5..+0.5) onto a 0..100 gauge value.
  const gaugeValue = Math.max(0, Math.min(100, Math.round(50 + di * 100)));

  let tone: PticaGauge["tone"] = "neutral";
  let word = "Наблюдать";
  if (di > 0.05) {
    tone = "good";
    word = "Приобретать";
  } else if (di < -0.05) {
    tone = "bad";
    word = "Пропустить";
  }

  return {
    gauge: {
      value: gaugeValue,
      label: word,
      tone,
      isReal: true,
      footnote: "advisory · §22",
    },
    word,
    available: true,
    caption: "advisory · §22",
  };
}

// ── Legal Status (bottom-grid) — ОКН / сервитуты / красные линии / ЗОУИТ ────────

export interface PticaLegalStatus {
  rows: DrawerKvRow[];
}

/**
 * Legal Status — REAL where /analyze provides it (ЗОУИТ count, красные линии,
 * gate verdict label), honest placeholder for the rest (ОКН / сервитуты /
 * аренда / ограничения оборота / обременения — not in the dump). Mirrors the
 * prototype's Legal table (Нет / значение rows).
 */
export function adaptLegalStatus(a: ParcelAnalysis): PticaLegalStatus {
  const zouit = a.nspd_zouit_overlaps ?? null;
  const redLines = a.nspd_red_lines ?? null;
  const gate = a.gate_verdict;

  const rows: DrawerKvRow[] = [
    {
      k: "ЗОУИТ",
      field:
        zouit != null
          ? real(formatInt(zouit.length))
          : notReal("источник НСПД"),
    },
    {
      k: "Красные линии",
      field:
        redLines != null
          ? real(redLines.length > 0 ? formatInt(redLines.length) : "Нет")
          : notReal("источник НСПД-граддок"),
    },
    { k: "ОКН", field: notReal("источник НСПД") },
    { k: "Сервитуты", field: notReal("источник ЕГРН") },
    { k: "Аренда", field: notReal("источник ЕГРН") },
    {
      k: "Огранич. оборота",
      field: gate
        ? real(
            gate.blockers.length > 0
              ? `${formatInt(gate.blockers.length)} блок.`
              : "Нет",
          )
        : notReal("источник ЕГРН"),
    },
  ];

  return { rows };
}

// ── Site Verdict (bottom-grid) — headline plaque + checklist ───────────────────

export interface PticaVerdictCheckItem {
  text: string;
  tone: "ok" | "warn" | "bad";
}

export interface PticaSiteVerdict {
  /** The big verdict word (Приобретать / Можно / Нельзя / Нужна проверка). */
  word: string;
  tone: "good" | "warn" | "bad";
  checklist: PticaVerdictCheckItem[];
}

/**
 * Site Verdict — the JTBD «стоит ли брать участок» answer. The verdict word comes
 * from gate_verdict.can_build_mkd (true → «Приобретать» green, false → «Нельзя»
 * red, "unknown" → «Нужна проверка» amber). The checklist is derived from REAL
 * signals where available (рыночный спрос via median price, потенциал застройки
 * via max_far, инженерная обеспеченность via utilities count) and falls back to
 * the gate blockers/warnings.
 */
export function adaptSiteVerdict(
  a: ParcelAnalysis,
  report?: ForecastReport,
): PticaSiteVerdict {
  const gate = a.gate_verdict;

  let word = "Нужна проверка";
  let tone: PticaSiteVerdict["tone"] = "warn";
  if (gate) {
    if (gate.can_build_mkd === true) {
      if (gate.verdict_label === "С ограничениями") {
        word = "С ограничениями";
        tone = "warn";
      } else {
        word = "Приобретать";
        tone = "good";
      }
    } else if (gate.can_build_mkd === false) {
      word = "Нельзя";
      tone = "bad";
    }
  }

  const checklist: PticaVerdictCheckItem[] = [];

  // Рыночный спрос — derived from §22 deficit_index when ready, else from the
  // presence of a district median (proxy «рынок отслеживается»).
  const di = report?.exec_summary.key_numbers.deficit_index ?? null;
  if (di != null) {
    if (di > 0.05) {
      checklist.push({ text: "Высокий спрос", tone: "ok" });
      checklist.push({ text: "Дефицит предложения", tone: "ok" });
    } else if (di < -0.05) {
      checklist.push({ text: "Признаки затоварки рынка", tone: "warn" });
    } else {
      checklist.push({ text: "Рынок сбалансирован", tone: "warn" });
    }
  } else if (a.district?.median_price_per_m2 != null) {
    checklist.push({ text: "Рынок района отслеживается", tone: "ok" });
  }

  // Потенциал застройки — REAL when КСИТ flows from НСПД.
  if (a.nspd_zoning?.max_far != null) {
    checklist.push({ text: "Потенциал застройки (КСИТ)", tone: "ok" });
  } else {
    checklist.push({ text: "Потенциал застройки не рассчитан", tone: "warn" });
  }

  // Инженерная обеспеченность — REAL from utilities count.
  const utilCount = a.utilities?.summary?.length ?? 0;
  if (utilCount >= 3) {
    checklist.push({ text: "Хорошая инженерная обеспеченность", tone: "ok" });
  } else if (utilCount > 0) {
    checklist.push({
      text: "Частичная инженерная обеспеченность",
      tone: "warn",
    });
  } else {
    checklist.push({ text: "Нет данных по инженерии", tone: "warn" });
  }

  // Gate blockers/warnings — surface the real risk items.
  if (gate) {
    for (const b of gate.blockers.slice(0, 2)) {
      checklist.push({ text: b.detail || b.code, tone: "bad" });
    }
    for (const w of gate.warnings.slice(0, 2)) {
      checklist.push({ text: w.detail || w.code, tone: "warn" });
    }
  }

  // Always show at least one item so the plaque never reads empty.
  if (checklist.length === 0) {
    checklist.push({ text: "Недостаточно данных для чек-листа", tone: "warn" });
  }

  return { word, tone, checklist };
}

// ── Map geometry ──────────────────────────────────────────────────────────────

/** EKB fallback center (lat, lon). */
export const EKB_CENTER: [number, number] = [56.838, 60.6];
