"use client";

/**
 * SourcesMap — overlay "05 АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ" (SourcesView): real OSM
 * basemap plotting every analog listing (estimate.analogs) + Rosreestr ДКП
 * deal (estimate.actual_deals) that carries lat/lon, coloured per listing
 * source with a legend, target-property pin + analog-radius circle.
 *
 * PORTS the Leaflet-CDN loader pattern from ../MapCard.tsx (the v1
 * app/page.tsx tree, dead in prod — 307-redirected to /v2) — copied rather
 * than imported so v2 stays a self-contained port with no runtime dependency
 * on the legacy tree, and no npm Leaflet dep (same as MapCard).
 *
 * Deal honesty: deals are geocoded to STREET CENTROIDS (backend
 * scripts/geocode_deals_from_houses.py) — several deals on the same street
 * can collapse onto ONE point. Coincident points are grouped and fanned out
 * with a small deterministic jitter + a "×N" badge instead of silently
 * overlapping, and a visible caption states the approximation.
 */
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. MapCard.tsx) */
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";

import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
import { safeUrl } from "@/lib/safeUrl";
import { tokens } from "./tokens";
import {
  adMeta,
  dealMeta,
  deglueAddr,
  fmtDate,
  fmtDist,
  fmtMonthYear,
  numRu,
  pluralRu,
  sourceLabel,
  tierLabel,
} from "./mappers";

const LEAFLET_VER = "1.9.4";
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";

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

// ── Per-source categorical palette ──────────────────────────────────────────
// dataviz-skill categorical order (fixed, never cycled), validated against the
// v2 card surface #eaf1f8: worst-adjacent CVD ΔE 24.2 (PASS); the aqua/yellow
// slots (cian/yandex) dip below 3:1 text-contrast, so they are NEVER
// colour-alone — every legend entry and popup keeps its RU label alongside
// the dot. Leaflet needs raw hex strings, not CSS vars (see MapCard.tsx
// comment) — kept literal, same as the rest of this map's Leaflet colours.
const SOURCE_COLORS: Record<string, string> = {
  avito: "#2a78d6",
  cian: "#1baf7a",
  yandex: "#eda100",
  domklik: "#008300",
  n1: "#4a3aa7",
};
const FALLBACK_SOURCE_COLOR = "#6b7280"; // неизвестный/прочий листинговый источник
const COLOR_TARGET = "#f59e0b"; // та же семантика, что и MapCard.COLOR_TARGET
// Сделки не соревнуются за 6-й категориальный оттенок — они отличаются ФОРМОЙ
// (ромб) и нейтральным тёмным цветом, а не ещё одним hue из палитры источников.
const COLOR_DEAL = tokens.ink;
const DOT_STROKE = "#fff";

function colorForSource(source: string | null | undefined): string {
  if (!source) return FALLBACK_SOURCE_COLOR;
  return SOURCE_COLORS[source.toLowerCase()] ?? FALLBACK_SOURCE_COLOR;
}

/** Безопасное экранирование для вставки в HTML popup (Leaflet вставляет как raw HTML). */
function esc(s: string): string {
  return s.replace(/[&<>"']/g, (c) =>
    c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;",
  );
}

interface GeoLot {
  lot: AnalogLot;
  lat: number;
  lon: number;
}

function withCoords(lots: AnalogLot[]): GeoLot[] {
  return lots
    .filter(
      (l): l is AnalogLot & { lat: number; lon: number } =>
        typeof l.lat === "number" && typeof l.lon === "number",
    )
    .map((l) => ({ lot: l, lat: l.lat, lon: l.lon }));
}

/** Группирует точки с ИДЕНТИЧНЫМИ координатами (street-centroid stacking). */
function groupByCoord(points: GeoLot[]): GeoLot[][] {
  const groups = new Map<string, GeoLot[]>();
  for (const p of points) {
    const key = `${p.lat.toFixed(6)},${p.lon.toFixed(6)}`;
    const arr = groups.get(key);
    if (arr) arr.push(p);
    else groups.set(key, [p]);
  }
  return Array.from(groups.values());
}

// Небольшой детерминированный jitter — чисто визуальный (карта и так подписана
// как street-centroid approximation, честность не меняется). ~0.00018° на
// широте Екатеринбурга (~56.8°) ≈ 12-15 м; поправка на cos(lat) держит разлёт
// круглым, а не сплюснутым по долготе.
const JITTER_DEG = 0.00018;

function jitter(
  lat: number,
  lon: number,
  index: number,
  count: number,
): [number, number] {
  if (count <= 1) return [lat, lon];
  const angle = (index / count) * 2 * Math.PI;
  const r = JITTER_DEG * (1 + Math.min(count, 8) * 0.15);
  const latRad = (lat * Math.PI) / 180;
  return [
    lat + r * Math.sin(angle),
    lon + (r * Math.cos(angle)) / Math.max(0.2, Math.cos(latRad)),
  ];
}

function analogPopupHtml(l: AnalogLot): string {
  const href = safeUrl(l.source_url);
  const exposure =
    l.days_on_market != null ? ` · ${l.days_on_market} дн. на рынке` : "";
  const dist = l.distance_m != null ? ` · ${fmtDist(l.distance_m)} от квартиры` : "";
  return `<div style="font-family:${tokens.font.sans};font-size:12px;line-height:1.55;min-width:190px">
    <div style="font-weight:600;color:${tokens.ink2}">${esc(deglueAddr(l.address || "—"))}</div>
    <div style="color:${tokens.muted2};margin:2px 0 6px">${esc(sourceLabel(l.source))} · ${esc(adMeta(l))}</div>
    <div style="color:${tokens.ink2}">${numRu(l.price_per_m2)} ₽/м² · <b>${numRu(l.price_rub)} ₽</b></div>
    <div style="color:${tokens.muted};margin-top:4px">${esc(fmtDate(l.listing_date) + exposure + dist)}</div>
    ${
      href
        ? `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer" style="display:inline-block;margin-top:8px;font-size:10.5px;color:${tokens.accent};border:1px solid ${tokens.line2};border-radius:4px;padding:3px 8px;text-decoration:none">Источник ↗</a>`
        : ""
    }
  </div>`;
}

function dealPopupHtml(l: AnalogLot, stackCount: number): string {
  const tier = l.tier ? tierLabel(l.tier) : "";
  const stackNote =
    stackCount > 1
      ? `<div style="margin-top:6px;color:${tokens.warn};font-size:10.5px">Ещё ${stackCount - 1} ${esc(
          pluralRu(stackCount - 1, ["сделка", "сделки", "сделок"]),
        )} на этой точке — геопривязка по улице/кварталу, не по дому</div>`
      : "";
  return `<div style="font-family:${tokens.font.sans};font-size:12px;line-height:1.55;min-width:190px">
    <div style="font-weight:600;color:${tokens.ink2}">${esc(deglueAddr(l.address || "—"))}</div>
    <div style="color:${tokens.muted2};margin:2px 0 6px">Сделка · ${esc(sourceLabel(l.source))}${
      tier ? ` · ${esc(tier)}` : ""
    }</div>
    <div style="color:${tokens.ink2}">${numRu(l.price_per_m2)} ₽/м² · <b>${numRu(l.price_rub)} ₽</b></div>
    <div style="color:${tokens.muted}">${esc(dealMeta(l))}</div>
    <div style="color:${tokens.muted};margin-top:4px">Период сделки: ${esc(
      fmtMonthYear(l.listing_date),
    )} (месяц, Росреестр)</div>
    ${stackNote}
  </div>`;
}

function targetPopupHtml(estimate: AggregatedEstimate): string {
  return `<div style="font-family:${tokens.font.sans};font-size:12px">
    <div style="font-weight:600;color:${tokens.ink2}">Ваша квартира</div>
    <div style="color:${tokens.muted};margin-top:2px">${esc(estimate.target_address ?? "—")}</div>
  </div>`;
}

/** Ромбовидная divIcon для сделки + бейдж «×N» при стеке в одной точке. */
function dealDivIcon(L: any, count: number): any {
  const size = Math.min(28, 14 + count * 2);
  const badge =
    count > 1
      ? `<div style="position:absolute;top:-7px;right:-7px;background:${tokens.ink2};color:#fff;font-family:${tokens.font.mono};font-size:9px;line-height:14px;min-width:14px;height:14px;border-radius:7px;text-align:center;padding:0 3px;box-shadow:0 0 0 1px #fff">${count}</div>`
      : "";
  const html = `<div style="position:relative;width:${size}px;height:${size}px">
    <div style="position:absolute;inset:3px;background:${COLOR_DEAL};border:2px solid ${DOT_STROKE};border-radius:2px;transform:rotate(45deg);box-shadow:0 1px 2px rgba(0,0,0,.3)"></div>
    ${badge}
  </div>`;
  return L.divIcon({ html, className: "", iconSize: [size, size], iconAnchor: [size / 2, size / 2] });
}

// ── Card chrome — mirrors SourcesView's local style consts (v2 has no shared
//    card component; each view file owns its own, see AnalyticsView/LeadForm). ──
const card: CSSProperties = {
  background: tokens.surface.w55,
  border: `1px solid ${tokens.line2}`,
  borderRadius: 8,
  overflow: "hidden",
};

const cardHeader: CSSProperties = {
  display: "flex",
  alignItems: "flex-start",
  justifyContent: "space-between",
  padding: "14px 18px",
  borderBottom: `1px solid ${tokens.lineSoft}`,
};

const eyebrow: CSSProperties = {
  fontSize: "8.5px",
  letterSpacing: "2px",
  color: tokens.muted2,
  marginBottom: 5,
};

const cardTitle: CSSProperties = {
  fontSize: "12px",
  fontWeight: 600,
  color: tokens.ink2,
};

const headerRight: CSSProperties = {
  textAlign: "right",
  fontSize: "9.5px",
  color: tokens.muted2,
  lineHeight: 1.6,
};

const legendRow: CSSProperties = {
  display: "flex",
  flexWrap: "wrap",
  gap: 14,
  alignItems: "center",
  padding: "10px 18px",
  borderTop: `1px solid ${tokens.lineSoft3}`,
  fontSize: "10px",
  color: tokens.body2,
};

const legendItem: CSSProperties = {
  display: "inline-flex",
  alignItems: "center",
  gap: 6,
};

const legendDot: CSSProperties = {
  width: 9,
  height: 9,
  borderRadius: "50%",
  display: "inline-block",
  boxShadow: "0 0 0 1px #fff, 0 0 0 1.5px rgba(0,0,0,.15)",
};

const legendDiamond: CSSProperties = {
  width: 8,
  height: 8,
  background: COLOR_DEAL,
  border: "1.5px solid #fff",
  boxShadow: `0 0 0 1px ${tokens.line2}`,
  transform: "rotate(45deg)",
  display: "inline-block",
};

const honestyFoot: CSSProperties = {
  padding: "10px 18px",
  fontSize: "9.5px",
  color: tokens.hint,
  background: "rgba(238,244,250,.5)", // тот же one-off tint, что и SourcesView KPI_BG
  borderTop: `1px solid ${tokens.lineSoft3}`,
};

interface Props {
  estimate: AggregatedEstimate | null | undefined;
}

export function SourcesMap({ estimate }: Props) {
  const mapRef = useRef<HTMLDivElement>(null);
  const [mapError, setMapError] = useState(false);

  const analogPoints = useMemo(
    () => (estimate ? withCoords(estimate.analogs) : []),
    [estimate],
  );
  const dealPoints = useMemo(
    () => (estimate ? withCoords(estimate.actual_deals) : []),
    [estimate],
  );

  const hasTarget =
    !!estimate &&
    typeof estimate.target_lat === "number" &&
    typeof estimate.target_lon === "number";

  // Радиус круга = тот же максимум distance_m по аналогам, что уже заявлен в
  // фильтр-чипе таблицы объявлений выше в этом overlay ("Расстояние ≤ N км",
  // mappers.ts buildAdFilters) — карта не должна утверждать другой радиус.
  const radiusM = useMemo(() => {
    const ds = analogPoints
      .map((p) => p.lot.distance_m)
      .filter((d): d is number => d != null && Number.isFinite(d));
    return ds.length ? Math.max(...ds) : null;
  }, [analogPoints]);

  const presentSources = useMemo(() => {
    const set = new Set<string>();
    for (const p of analogPoints) if (p.lot.source) set.add(p.lot.source.toLowerCase());
    return Array.from(set);
  }, [analogPoints]);

  const totalPins = analogPoints.length + dealPoints.length + (hasTarget ? 1 : 0);

  useEffect(() => {
    if (!estimate || totalPins === 0) return;
    let map: any = null;
    let cancelled = false;

    loadLeaflet()
      .then((L) => {
        if (cancelled || !mapRef.current) return;
        const firstPin = analogPoints[0] ?? dealPoints[0];
        let center: [number, number] | null = null;
        if (hasTarget) {
          center = [estimate.target_lat as number, estimate.target_lon as number];
        } else if (firstPin) {
          center = [firstPin.lat, firstPin.lon];
        }
        if (!center) return; // guarded by totalPins>0, но удовлетворяет TS

        // scrollWheelZoom:false — карта встроена в скроллящуюся панель overlay
        // (SectionOverlay), захват колеса мыши иначе крадёт скролл панели.
        map = L.map(mapRef.current, { scrollWheelZoom: false }).setView(center, 14);
        L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
          attribution: "© OpenStreetMap",
          maxZoom: 19,
        }).addTo(map);
        setTimeout(() => map && map.invalidateSize(), 120);

        const bounds: [number, number][] = [];

        // Объявления — цвет по источнику, стек в одной точке — jitter по кругу.
        for (const group of groupByCoord(analogPoints)) {
          group.forEach((p, i) => {
            const [lat, lon] = jitter(p.lat, p.lon, i, group.length);
            bounds.push([lat, lon]);
            L.circleMarker([lat, lon], {
              radius: 7,
              color: DOT_STROKE,
              weight: 2,
              fillColor: colorForSource(p.lot.source),
              fillOpacity: 0.9,
            })
              .addTo(map)
              .bindPopup(analogPopupHtml(p.lot));
          });
        }

        // ДКП-сделки — ромб, нейтральный цвет, size+badge по числу в стеке.
        for (const group of groupByCoord(dealPoints)) {
          group.forEach((p, i) => {
            const [lat, lon] = jitter(p.lat, p.lon, i, group.length);
            bounds.push([lat, lon]);
            L.marker([lat, lon], { icon: dealDivIcon(L, group.length) })
              .addTo(map)
              .bindPopup(dealPopupHtml(p.lot, group.length));
          });
        }

        // Целевая квартира — крупный янтарный маркер + радиус-круг (та же
        // семантика/паттерн, что и MapCard.tsx).
        if (hasTarget) {
          const ll: [number, number] = [
            estimate.target_lat as number,
            estimate.target_lon as number,
          ];
          bounds.push(ll);
          if (radiusM != null && radiusM > 0) {
            L.circle(ll, {
              radius: radiusM,
              color: COLOR_TARGET,
              weight: 1,
              fillColor: COLOR_TARGET,
              fillOpacity: 0.05,
              // interactive:false — декоративный круг не должен перехватывать
              // клики по пинам под ним (см. MapCard.tsx / issue #786).
              interactive: false,
            }).addTo(map);
          }
          L.circleMarker(ll, {
            radius: 9,
            color: COLOR_TARGET,
            weight: 3,
            fillColor: "#fff",
            fillOpacity: 1,
          })
            .addTo(map)
            .bindPopup(targetPopupHtml(estimate));
        }

        if (bounds.length > 1) {
          map.fitBounds(bounds, { padding: [30, 30], maxZoom: 16 });
        }
      })
      .catch(() => setMapError(true));

    return () => {
      cancelled = true;
      if (map) map.remove();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- пересоздаём карту только при смене оценки/состава пинов, не на каждый ре-рендер
  }, [estimate?.estimate_id, totalPins]);

  // Честный empty state: нет оценки или нет геоточек вообще — карточку не
  // показываем (то же поведение, что и MapCard.tsx), таблицы ниже остаются.
  if (!estimate || totalPins === 0) return null;

  return (
    <article style={card}>
      <div style={cardHeader}>
        <div>
          <div style={eyebrow}>РЫНОК · КАРТА</div>
          <div style={cardTitle}>Аналоги и сделки на карте</div>
        </div>
        <div style={headerRight}>
          {/* Fix #1 — analogPoints is the top-10 display sample (only what the
              backend returns coords for); estimate.n_analogs is the true total
              used in the calc. "N из M" mirrors the deals-table "Показано N из
              M" pattern so this never contradicts the market KPI band above. */}
          Объявлений: <b style={{ color: tokens.ink2 }}>{analogPoints.length}</b>
          {" из "}
          {estimate.n_analogs}
          {dealPoints.length > 0 && (
            <>
              {" · "}сделок: <b style={{ color: tokens.ink2 }}>{dealPoints.length}</b>
            </>
          )}
        </div>
      </div>

      {mapError ? (
        <div
          style={{
            padding: "24px 18px",
            textAlign: "center",
            color: tokens.muted,
            fontSize: 12,
          }}
        >
          Не удалось загрузить карту. Проверьте интернет-соединение.
        </div>
      ) : (
        <div
          ref={mapRef}
          style={{ height: 400, width: "100%", background: tokens.mapBg }}
          aria-label="Карта аналогов и сделок"
        />
      )}

      <div style={legendRow}>
        {presentSources.map((s) => (
          <span key={s} style={legendItem}>
            <span style={{ ...legendDot, background: colorForSource(s) }} />
            {sourceLabel(s)}
          </span>
        ))}
        {dealPoints.length > 0 && (
          <span style={legendItem}>
            <span style={legendDiamond} />
            Сделки (Росреестр)
          </span>
        )}
        {hasTarget && (
          <span style={legendItem}>
            <span style={{ ...legendDot, background: "#fff", boxShadow: `0 0 0 2px ${COLOR_TARGET}` }} />
            Ваша квартира
          </span>
        )}
      </div>

      <div style={honestyFoot}>
        Сделки Росреестра геокодированы по улице/кадастральному кварталу, а не по
        конкретному дому — совпадающие точки визуально разнесены и подписаны
        «×N». Клик по пину — детали.
      </div>
    </article>
  );
}

export default SourcesMap;
