"use client";

/**
 * Massing3DScene — client-only Three.js viewer that renders the ACTUAL placed
 * corpuses from `variant.buildings_geojson` as extruded masses standing on an
 * OSM raster tile of the parcel area («карта как подставка-земля»), lit by a
 * time-of-day sun that casts shadows, with an auto-rotating OrbitControls rig.
 *
 * Read-only (no slider rebuild of the program — the program is whatever the
 * backend placed). The ONE live knob is `floorHeightM` (visual extrusion height,
 * default 3.0 = the backend's `FLOOR_HEIGHT_M`). Light theme: inline styles + CSS
 * var tokens only — this is a report object, NOT the dark cockpit, so it must NOT
 * import ptica.module.css.
 *
 * Loaded exclusively through Massing3DViewer via `dynamic(ssr:false)` so Three.js
 * never touches the server. The renderer/scene/RAF live in one effect with full
 * teardown (cancel RAF, disconnect RO, dispose controls + geometries/materials +
 * the OSM CanvasTexture + renderer, remove the canvas) on unmount.
 */

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import type { MultiPolygon, Polygon, Position } from "geojson";
import { Box, RotateCw } from "lucide-react";

import type { ConceptVariant } from "@/lib/concept-api";
import {
  useNeighborBuildings,
  type NeighborBuildingsResponse,
} from "@/hooks/useNeighborBuildings";
import {
  makeProjector,
  osmTileRangeForBbox,
  padBounds,
  polygonOuterRings,
  ringsBounds,
  ringsCentroid,
  type Bounds,
  type Projector,
} from "@/lib/geo-local";

import { sunFromHour } from "@/components/site-finder/ptica/massing/sun";
import { disposeObject } from "@/components/site-finder/ptica/massing/three-utils";

// ── constants (scene units = metres) ──────────────────────────────────────────

const DEFAULT_FLOOR_HEIGHT = 3.0; // backend `FLOOR_HEIGHT_M` (placement.py)
const DEFAULT_HOUR = 13; // midday — first paint already shows shadows
const HOUR_MIN = 6;
const HOUR_MAX = 20;
const FALLBACK_FLOORS = 12;

// Neighbours (#2180): existing context buildings around the parcel, rendered as
// low-poly grey masses so the concept reads inside a block. Height comes from
// НСПД floors×3 m; when floors is unknown fall back to ~2 storeys so the mass
// still lands. Cap the count and simplify (no edges) — they're backdrop, the
// programme corpuses (accent) must dominate.
const NEIGHBOR_FALLBACK_HEIGHT_M = 6; // ≈2 этажа при неизвестной этажности НСПД
const NEIGHBOR_MAX_RENDER = 200; // cap: features уже отсортированы по расстоянию

// Colours — Three.js wants numeric hex; these mirror the UI tokens by value.
const COLOR_BG = 0xf6f7f9; // --bg-app
const COLOR_SLATE = 0x33424f; // building body
const COLOR_ACCENT = 0x1d4ed8; // --accent (edge lines)
const COLOR_PARCEL_OUTLINE = 0x1d4ed8; // --accent (parcel outline — accent, #2179)
const COLOR_PARCEL_FILL = 0xdbeafe; // --accent-soft (parcel fill — #2179)
const COLOR_GROUND_FALLBACK = 0xfafbfc; // --bg-card-alt
const COLOR_GRID = 0xe6e8ec; // --border-card
const COLOR_NEIGHBOR = 0x9ca3af; // близко к --fg-tertiary — нейтральный серый соседей (#2180)

const TILE_TIMEOUT_MS = 6000;

// ── props ─────────────────────────────────────────────────────────────────────

export interface Massing3DSceneProps {
  /**
   * WGS84 parcel geometry — Polygon OR MultiPolygon. Every part's outer ring
   * drives the projection origin, OSM ground bounds and outline, so a multi-part
   * parcel shares the whole-geometry frame the backend anchors neighbours to
   * (otherwise context around a distant part lands offset from part 0).
   */
  parcel: Polygon | MultiPolygon;
  /** The variant — `buildings_geojson` is the single source of corpuses. */
  variant: ConceptVariant;
  /** Visual floor height in metres; default 3.0 (the backend's constant). */
  floorHeightM?: number;
  /** Optional initial sun hour; default 13. */
  hour?: number;
  /**
   * Кадастровый номер участка — для подгрузки соседних зданий (макет квартала,
   * #2180). Отсутствует на standalone /concept без кадастра → соседи не грузятся.
   */
  cadNum?: string | null;
}

// ── derived model (pure, memoized) ────────────────────────────────────────────

interface BuildingLocal {
  ring: { x: number; z: number }[];
  height: number;
}

interface MassingModel {
  buildings: BuildingLocal[];
  /** Every parcel part's outer ring, projected to local metres (one per part). */
  parcelRings: { x: number; z: number }[][];
  /** Local-metre bbox over all parcel parts + building rings. */
  bbox: { minX: number; maxX: number; minZ: number; maxZ: number };
  centerX: number;
  centerZ: number;
  diag: number;
  maxHeight: number;
  /** WGS84 padded bounds for the OSM ground tile. */
  groundBounds: Bounds | null;
  /** Shared projector — buildings AND ground tile corners use the same one. */
  project: Projector;
}

/** Per-feature floors, guarded (`properties` is loosely typed GeoJsonProperties). */
function featureFloors(
  props: Record<string, unknown> | null | undefined,
  fallback: number,
): number {
  const raw = props?.floors;
  const n = Number(raw);
  return Number.isFinite(n) && n > 0 ? n : fallback;
}

function buildModel(
  parcel: Polygon | MultiPolygon,
  variant: ConceptVariant,
  floorHeightM: number,
): MassingModel {
  // Whole-parcel-aware: outer ring of EVERY part (a bare Polygon → one ring).
  const parcelRingsWgs = polygonOuterRings(parcel);
  const [lon0, lat0] = ringsCentroid(parcelRingsWgs);
  const project = makeProjector(lon0, lat0);

  const features = variant.buildings_geojson?.features ?? [];

  // Fallback floors = the tallest declared corpus, else a sensible default.
  const declaredMax = features.reduce((m, f) => {
    const n = Number(f.properties?.floors);
    return Number.isFinite(n) && n > m ? n : m;
  }, 0);
  const fallbackFloors = declaredMax > 0 ? declaredMax : FALLBACK_FLOORS;

  const buildings: BuildingLocal[] = [];
  for (const f of features) {
    const geom = f.geometry;
    if (!geom || geom.type !== "Polygon") continue;
    const ringWgs = (geom.coordinates?.[0] ?? []) as Position[];
    if (ringWgs.length < 4) continue;
    const ring = ringWgs.map(project);
    const floors = featureFloors(
      f.properties as Record<string, unknown> | null,
      fallbackFloors,
    );
    buildings.push({
      ring,
      height: Math.max(floors * floorHeightM, floorHeightM),
    });
  }

  const parcelRings = parcelRingsWgs.map((ring) => ring.map(project));

  // bbox over EVERY parcel part + all building rings, so the camera frames the
  // whole lot and its corpuses (a distant part is no longer cropped out).
  let minX = Infinity;
  let maxX = -Infinity;
  let minZ = Infinity;
  let maxZ = -Infinity;
  const pts = [...parcelRings.flat(), ...buildings.flatMap((b) => b.ring)];
  for (const p of pts) {
    if (p.x < minX) minX = p.x;
    if (p.x > maxX) maxX = p.x;
    if (p.z < minZ) minZ = p.z;
    if (p.z > maxZ) maxZ = p.z;
  }
  if (!Number.isFinite(minX)) {
    minX = -50;
    maxX = 50;
    minZ = -50;
    maxZ = 50;
  }
  const centerX = (minX + maxX) / 2;
  const centerZ = (minZ + maxZ) / 2;
  const diag = Math.max(Math.hypot(maxX - minX, maxZ - minZ), 40);
  const maxHeight = buildings.reduce(
    (m, b) => Math.max(m, b.height),
    floorHeightM,
  );

  // Pad the ground tile generously (0.7 = +70% context each side, #2179) so the
  // OSM substrate extends well beyond the parcel; the 4×4 tile cap below keeps it
  // sharp at the same zoom. Union bounds over ALL parts → the tile covers every
  // part so neighbours around any of them land on it (a genuinely disjoint
  // multi-part parcel yields a large, lower-detail tile — correct, not a bug).
  const rawBounds = ringsBounds(parcelRingsWgs);
  const groundBounds = rawBounds ? padBounds(rawBounds, 0.7) : null;

  return {
    buildings,
    parcelRings,
    bbox: { minX, maxX, minZ, maxZ },
    centerX,
    centerZ,
    diag,
    maxHeight,
    groundBounds,
    project,
  };
}

// ── building meshes ────────────────────────────────────────────────────────────

/**
 * Build the buildings Group from the local model. Each corpus → a THREE.Shape in
 * the XY plane (x → shape.x, z → shape.y), ExtrudeGeometry(depth = height), with
 * the whole Group rotated −π/2 about X so footprints lie on y=0 and height grows
 * +y. Degenerate rings (≈0 area) fall back to a BoxGeometry at the ring bbox.
 */
function buildBuildingsGroup(model: MassingModel): THREE.Group {
  const group = new THREE.Group();
  group.name = "buildings";
  group.rotation.x = -Math.PI / 2;

  const slate = new THREE.Color(COLOR_SLATE);
  const accent = new THREE.Color(COLOR_ACCENT);

  for (const b of model.buildings) {
    if (b.ring.length < 3) continue;

    // Footprint area (shoelace) to detect a degenerate ring.
    let area2 = 0;
    for (let i = 0; i < b.ring.length; i++) {
      const a = b.ring[i];
      const c = b.ring[(i + 1) % b.ring.length];
      area2 += a.x * c.z - c.x * a.z;
    }
    const area = Math.abs(area2) / 2;

    let geo: THREE.ExtrudeGeometry | THREE.BoxGeometry;
    let basePos: { x: number; y: number } | null = null;
    if (area < 1) {
      // Degenerate → block sized to the ring bbox at its centre.
      let bx0 = Infinity;
      let bx1 = -Infinity;
      let bz0 = Infinity;
      let bz1 = -Infinity;
      for (const p of b.ring) {
        if (p.x < bx0) bx0 = p.x;
        if (p.x > bx1) bx1 = p.x;
        if (p.z < bz0) bz0 = p.z;
        if (p.z > bz1) bz1 = p.z;
      }
      const w = Math.max(bx1 - bx0, 4);
      const d = Math.max(bz1 - bz0, 4);
      geo = new THREE.BoxGeometry(w, d, b.height);
      basePos = { x: (bx0 + bx1) / 2, y: (bz0 + bz1) / 2 };
    } else {
      const shape = new THREE.Shape();
      shape.moveTo(b.ring[0].x, b.ring[0].z);
      for (let i = 1; i < b.ring.length; i++) {
        shape.lineTo(b.ring[i].x, b.ring[i].z);
      }
      shape.closePath();
      geo = new THREE.ExtrudeGeometry(shape, {
        depth: b.height,
        bevelEnabled: false,
      });
    }

    const mat = new THREE.MeshStandardMaterial({
      color: slate,
      roughness: 0.82,
      metalness: 0.05,
    });
    const mesh = new THREE.Mesh(geo, mat);
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    if (basePos) {
      // BoxGeometry is centred at origin; lift it to sit on y=0 (group-local z).
      mesh.position.set(basePos.x, basePos.y, b.height / 2);
    }
    group.add(mesh);

    // Accent edges for building-read.
    const edges = new THREE.EdgesGeometry(geo);
    const line = new THREE.LineSegments(
      edges,
      new THREE.LineBasicMaterial({
        color: accent,
        transparent: true,
        opacity: 0.55,
      }),
    );
    if (basePos) line.position.copy(mesh.position);
    group.add(line);
  }

  return group;
}

/**
 * Parcel highlight (#2179): a translucent accent-soft FILL just above the ground
 * plane plus a prominent accent OUTLINE — drawn for EVERY parcel part (a bare
 * Polygon → one ring; a MultiPolygon → one fill + outline per part) so the whole
 * lot reads, not just part 0. Returns a Group so the caller adds one object.
 * WebGL ignores LineBasicMaterial.linewidth, so the outline is doubled at two
 * heights (y=0.05, y=0.10) to read thick from any camera angle without a new
 * dependency.
 */
function buildParcelHighlight(model: MassingModel): THREE.Group {
  const group = new THREE.Group();
  group.name = "parcel";

  // One shared outline material across every part (linewidth is a no-op in WebGL,
  // so each part's outline is doubled at y=0.05 and y=0.10 to read prominently).
  const outlineMat = new THREE.LineBasicMaterial({
    color: COLOR_PARCEL_OUTLINE,
  });

  for (const ring of model.parcelRings) {
    if (ring.length < 3) continue;

    // Fill: THREE.Shape from the part ring → ShapeGeometry, laid into the ground
    // plane (rotate −π/2 about X so the XY shape sits on the XZ world plane) and
    // lifted to y≈0.03 (above the ground substrate at y=−0.02, under the outline).
    const shape = new THREE.Shape();
    shape.moveTo(ring[0].x, ring[0].z);
    for (let i = 1; i < ring.length; i++) {
      shape.lineTo(ring[i].x, ring[i].z);
    }
    shape.closePath();
    const fillGeo = new THREE.ShapeGeometry(shape);
    const fill = new THREE.Mesh(
      fillGeo,
      new THREE.MeshBasicMaterial({
        color: COLOR_PARCEL_FILL, // --accent-soft
        transparent: true,
        opacity: 0.35,
        depthWrite: false,
        side: THREE.DoubleSide,
      }),
    );
    // Shape is built in XY; rotate to lie in the ground (XZ) plane. After the
    // −π/2 X-rotation shape.y (= parcel z) maps to +z, so no mirroring.
    fill.rotation.x = -Math.PI / 2;
    fill.position.y = 0.03;
    group.add(fill);

    // Outline: accent LineLoop, doubled at y=0.05 and y=0.10.
    for (const y of [0.05, 0.1]) {
      const pts: number[] = [];
      for (const p of ring) {
        pts.push(p.x, y, p.z);
      }
      const geo = new THREE.BufferGeometry();
      geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
      group.add(new THREE.LineLoop(geo, outlineMat));
    }
  }

  return group;
}

// ── neighbour context buildings (#2180) ────────────────────────────────────────

/** Outer ring (index 0) of one GeoJSON polygon, projected to local metres. */
function projectOuterRing(
  polygon: Position[][],
  project: Projector,
): { x: number; z: number }[] {
  const ringWgs = (polygon?.[0] ?? []) as Position[];
  return ringWgs.map(project);
}

/**
 * Build the neighbours Group from the fetched FeatureCollection, projected with
 * the SCENE's projector (`model.project`) so the context sits geo-aligned around
 * the parcel. Each footprint → a THREE.Shape (Polygon → its outer ring;
 * MultiPolygon → one shape per polygon) extruded to `height_m` (НСПД floors×3 m),
 * falling back to ~2 storeys when the height is unknown.
 *
 * Neutral grey (COLOR_NEIGHBOR), opacity 0.55 / transparent, NO edges — a backdrop
 * that must not compete with the accent programme corpuses. Rotated −π/2 about X
 * (like `buildBuildingsGroup`) so footprints lie on y=0 and height grows +y.
 * Returns null when there's nothing renderable, so the caller can skip adding it.
 */
function buildNeighborsGroup(
  data: NeighborBuildingsResponse,
  model: MassingModel,
): THREE.Group | null {
  const features = data.features.slice(0, NEIGHBOR_MAX_RENDER);
  if (features.length === 0) return null;

  const group = new THREE.Group();
  group.name = "neighbors";
  group.rotation.x = -Math.PI / 2;

  // One shared material for the whole context — grey backdrop, semi-transparent.
  const mat = new THREE.MeshStandardMaterial({
    color: new THREE.Color(COLOR_NEIGHBOR),
    roughness: 0.9,
    metalness: 0.0,
    transparent: true,
    opacity: 0.55,
  });

  // Extrude one grey mass per footprint ring at that feature's height.
  const extrudeRing = (ring: { x: number; z: number }[], heightM: number) => {
    if (ring.length < 4) return; // need a closed ring (≥3 verts + closing point)
    const shape = new THREE.Shape();
    shape.moveTo(ring[0].x, ring[0].z);
    for (let i = 1; i < ring.length; i++) shape.lineTo(ring[i].x, ring[i].z);
    shape.closePath();
    const geo = new THREE.ExtrudeGeometry(shape, {
      depth: heightM,
      bevelEnabled: false,
    });
    const mesh = new THREE.Mesh(geo, mat);
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    group.add(mesh);
  };

  for (const f of features) {
    const geom = f.geometry;
    if (!geom) continue;
    const heightM =
      typeof f.properties.height_m === "number" && f.properties.height_m > 0
        ? f.properties.height_m
        : NEIGHBOR_FALLBACK_HEIGHT_M;
    if (geom.type === "Polygon") {
      // Polygon → its outer ring (index 0).
      extrudeRing(projectOuterRing(geom.coordinates, model.project), heightM);
    } else {
      // MultiPolygon → one mass per polygon (outer ring only).
      for (const poly of geom.coordinates) {
        extrudeRing(projectOuterRing(poly, model.project), heightM);
      }
    }
  }

  if (group.children.length === 0) return null;
  return group;
}

// ── OSM ground tile (fetch + stitch + CanvasTexture) ──────────────────────────

interface GroundTexResult {
  texture: THREE.CanvasTexture;
  /** World-space placement of the geo-pinned plane. */
  planeW: number;
  planeH: number;
  centerX: number;
  centerZ: number;
}

/** Load one OSM tile into an Image; resolves null on error/abort. */
function loadTile(url: string): Promise<HTMLImageElement | null> {
  return new Promise((resolve) => {
    const img = new Image();
    img.crossOrigin = "anonymous"; // OSM tiles are CORS-clean → canvas not tainted
    img.onload = () => resolve(img);
    img.onerror = () => resolve(null);
    img.src = url;
  });
}

/**
 * Fetch + stitch the OSM tile block for the ground bounds into a CanvasTexture,
 * geo-pinned via the shared projector. Races a ~6 s timeout; resolves null on any
 * failed tile / timeout so the caller can drop to the flat fallback plane.
 */
async function loadGroundTexture(
  model: MassingModel,
  signal: { cancelled: boolean },
): Promise<GroundTexResult | null> {
  if (!model.groundBounds) return null;
  // Allow up to a 4×4 (16-tile) block (#2179): a larger extent WITHOUT dropping
  // zoom, so the substrate stays sharp while covering more surroundings.
  const range = osmTileRangeForBbox(model.groundBounds, { maxTiles: 16 });

  const canvas = document.createElement("canvas");
  canvas.width = 256 * range.cols;
  canvas.height = 256 * range.rows;
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;

  const loads: Promise<{
    img: HTMLImageElement | null;
    col: number;
    row: number;
  }>[] = [];
  for (let x = range.x0; x <= range.x1; x++) {
    for (let y = range.y0; y <= range.y1; y++) {
      const url = `https://tile.openstreetmap.org/${range.z}/${x}/${y}.png`;
      const col = x - range.x0;
      const row = y - range.y0;
      loads.push(loadTile(url).then((img) => ({ img, col, row })));
    }
  }

  const timeout = new Promise<null>((resolve) =>
    setTimeout(() => resolve(null), TILE_TIMEOUT_MS),
  );
  const all = Promise.all(loads);
  const result = await Promise.race([all, timeout]);
  if (signal.cancelled || result === null) return null;
  // Any tile failed → fall back (geo-pin needs the full block).
  if (result.some((r) => r.img === null)) return null;

  for (const { img, col, row } of result) {
    if (img) ctx.drawImage(img, col * 256, row * 256);
  }

  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;

  // Geo-pin: project the stitched block's exact WGS84 corners with the SAME
  // projector as the buildings. Texture is drawn north→south; after the plane's
  // −π/2 X-rotation, V maps to −z (north), so UVs align without flipping.
  const nw = model.project([range.westLon, range.northLat]);
  const se = model.project([range.eastLon, range.southLat]);
  const planeW = se.x - nw.x;
  const planeH = se.z - nw.z;
  const centerX = (nw.x + se.x) / 2;
  const centerZ = (nw.z + se.z) / 2;

  return { texture, planeW, planeH, centerX, centerZ };
}

// ── formatting ────────────────────────────────────────────────────────────────

function fmtHour(hour: number): string {
  return `${String(Math.floor(hour)).padStart(2, "0")}:00`;
}

// ── component ─────────────────────────────────────────────────────────────────

export default function Massing3DScene({
  parcel,
  variant,
  floorHeightM = DEFAULT_FLOOR_HEIGHT,
  hour: initialHour = DEFAULT_HOUR,
  cadNum,
}: Massing3DSceneProps): React.JSX.Element {
  const [hour, setHour] = useState(initialHour);
  const [autoRotate, setAutoRotate] = useState(true);
  const [webglFailed, setWebglFailed] = useState(false);

  const mountRef = useRef<HTMLDivElement | null>(null);
  const sceneRef = useRef<THREE.Scene | null>(null);
  const controlsRef = useRef<OrbitControls | null>(null);
  const sunRef = useRef<THREE.DirectionalLight | null>(null);
  const groundTexRef = useRef<THREE.CanvasTexture | null>(null);

  const model = useMemo(
    () => buildModel(parcel, variant, floorHeightM),
    [parcel, variant, floorHeightM],
  );

  // Neighbour context buildings (#2180). enabled только при наличии cadNum
  // (standalone /concept без кадастра → соседи не грузятся). Данные приходят
  // async; отдельный effect ниже дорисовывает их в уже построенную сцену без
  // полного rebuild.
  const neighbors = useNeighborBuildings(cadNum);
  const neighborsData = neighbors.data;
  const neighborsCount = neighborsData?.count ?? 0;

  // place the sun from the current hour (light-only; no rebuild).
  const placeSun = useCallback(
    (h: number) => {
      const sun = sunRef.current;
      if (!sun) return;
      const p = sunFromHour(h);
      sun.position.set(p.x, p.y, p.z);
      sun.target.position.set(model.centerX, 10, model.centerZ);
      sun.intensity = 0.45 + 0.85 * Math.max(0.05, Math.sin(p.elevation));
      sun.color.setHSL(
        0.11 - 0.04 * (1 - p.warmth),
        0.55 * p.warmth + 0.12,
        0.62,
      );
    },
    [model.centerX, model.centerZ],
  );

  // ── scene lifecycle (rebuilds on model identity change) ─────────────────────
  useEffect(() => {
    const mount = mountRef.current;
    if (!mount) return;

    // WebGL availability guard.
    let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null;
    try {
      const probe = document.createElement("canvas");
      gl =
        probe.getContext("webgl2") ??
        (probe.getContext("webgl") as WebGLRenderingContext | null);
    } catch {
      gl = null;
    }
    if (!gl) {
      setWebglFailed(true);
      return;
    }

    let width = mount.clientWidth || 600;
    let height = mount.clientHeight || 420;

    let renderer: THREE.WebGLRenderer;
    try {
      renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
    } catch {
      setWebglFailed(true);
      return;
    }
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    renderer.setSize(width, height, false);
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    renderer.domElement.style.display = "block";
    renderer.domElement.style.width = "100%";
    renderer.domElement.style.height = "100%";
    mount.appendChild(renderer.domElement);

    const scene = new THREE.Scene();
    scene.background = new THREE.Color(COLOR_BG);
    sceneRef.current = scene;

    const camera = new THREE.PerspectiveCamera(42, width / height, 0.5, 4000);
    const target = new THREE.Vector3(
      model.centerX,
      0.4 * model.maxHeight,
      model.centerZ,
    );
    const dir = new THREE.Vector3(1, 0.9, 1.15).normalize();
    camera.position.copy(target).addScaledVector(dir, 1.5 * model.diag);

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = 0.08;
    controls.maxPolarAngle = Math.PI / 2 - 0.04;
    controls.minDistance = 0.4 * model.diag;
    controls.maxDistance = 4 * model.diag;
    controls.autoRotate = autoRotate;
    controls.autoRotateSpeed = 0.6;
    controls.target.copy(target);
    controls.update();
    controlsRef.current = controls;

    // ── ground: flat fallback now, swap to OSM tile when (if) it loads ────────
    const planeW = model.bbox.maxX - model.bbox.minX || 100;
    const planeH = model.bbox.maxZ - model.bbox.minZ || 100;
    const fallbackSize = Math.max(planeW, planeH) * 2.2 + 60;
    const groundGeo = new THREE.PlaneGeometry(fallbackSize, fallbackSize);
    const groundMat = new THREE.MeshStandardMaterial({
      color: COLOR_GROUND_FALLBACK,
      roughness: 1,
      metalness: 0,
    });
    const ground = new THREE.Mesh(groundGeo, groundMat);
    ground.rotation.x = -Math.PI / 2;
    ground.position.set(model.centerX, -0.02, model.centerZ);
    ground.receiveShadow = true;
    scene.add(ground);

    // faint grid as part of the fallback context (removed when the tile lands).
    const grid = new THREE.GridHelper(fallbackSize, 24, COLOR_GRID, COLOR_GRID);
    grid.position.set(model.centerX, 0.01, model.centerZ);
    const gridMat = grid.material as THREE.Material;
    gridMat.opacity = 0.5;
    gridMat.transparent = true;
    scene.add(grid);

    // parcel highlight (accent-soft fill + accent outline, #2179)
    const parcel = buildParcelHighlight(model);
    scene.add(parcel);

    // buildings
    const buildings = buildBuildingsGroup(model);
    scene.add(buildings);

    // ── lighting (sun rig reused via sun.ts) ──────────────────────────────────
    const hemi = new THREE.HemisphereLight(0xffffff, 0xb8c2cc, 0.55);
    scene.add(hemi);
    const ambient = new THREE.AmbientLight(0xffffff, 0.25);
    scene.add(ambient);

    const sun = new THREE.DirectionalLight(0xfff2d8, 1.15);
    sun.castShadow = true;
    sun.shadow.mapSize.set(2048, 2048);
    const extent = 0.6 * model.diag + 20;
    sun.shadow.camera.near = 1;
    sun.shadow.camera.far = 4 * extent;
    sun.shadow.camera.left = -extent;
    sun.shadow.camera.right = extent;
    sun.shadow.camera.top = extent;
    sun.shadow.camera.bottom = -extent;
    sun.shadow.bias = -0.0005;
    scene.add(sun);
    scene.add(sun.target);
    sunRef.current = sun;
    placeSun(hour);

    // ── async OSM ground ──────────────────────────────────────────────────────
    const tileSignal = { cancelled: false };
    loadGroundTexture(model, tileSignal).then((res) => {
      if (!res || tileSignal.cancelled || !sceneRef.current) {
        if (res) res.texture.dispose();
        return;
      }
      // Swap the fallback colour for the geo-pinned tiled plane.
      groundMat.map = res.texture;
      groundMat.color.set(0xffffff);
      groundMat.needsUpdate = true;
      groundTexRef.current = res.texture;
      // Resize/reposition the plane to the geo-pinned block.
      ground.geometry.dispose();
      ground.geometry = new THREE.PlaneGeometry(
        Math.abs(res.planeW),
        Math.abs(res.planeH),
      );
      ground.position.set(res.centerX, -0.02, res.centerZ);
      grid.visible = false;
    });

    // resize handling
    const ro = new ResizeObserver(() => {
      const w = mount.clientWidth;
      const h = mount.clientHeight;
      if (!w || !h) return;
      width = w;
      height = h;
      camera.aspect = w / h;
      camera.updateProjectionMatrix();
      renderer.setSize(w, h, false);
    });
    ro.observe(mount);

    // render loop
    let alive = true;
    let raf = 0;
    const tick = () => {
      if (!alive) return;
      raf = requestAnimationFrame(tick);
      controls.update();
      renderer.render(scene, camera);
    };
    raf = requestAnimationFrame(tick);

    // full teardown
    return () => {
      alive = false;
      tileSignal.cancelled = true;
      if (raf) cancelAnimationFrame(raf);
      ro.disconnect();
      controls.dispose();
      controlsRef.current = null;
      disposeObject(scene);
      if (groundTexRef.current) {
        groundTexRef.current.dispose();
        groundTexRef.current = null;
      }
      sceneRef.current = null;
      sunRef.current = null;
      renderer.dispose();
      const canvas = renderer.domElement;
      if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
    };
    // model identity (variant / floorHeightM) drives a full rebuild; hour and
    // autoRotate are applied by the dedicated effects below without re-init.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [model]);

  // apply time-of-day changes by repositioning the sun (no rebuild).
  useEffect(() => {
    placeSun(hour);
  }, [hour, placeSun]);

  // apply auto-rotate toggle.
  useEffect(() => {
    if (controlsRef.current) controlsRef.current.autoRotate = autoRotate;
  }, [autoRotate]);

  // Neighbour context (#2180): add the grey backdrop masses to the LIVE scene
  // without a full rebuild. Runs when the neighbours data lands (async, after
  // first paint) and re-runs after a model rebuild (new scene ref) — declared
  // AFTER the main scene effect so on a `model` change the new scene already
  // exists. Cleanup removes + disposes the group (the main teardown also
  // disposeObject(scene)s it as a belt-and-braces if unmount races this).
  useEffect(() => {
    const scene = sceneRef.current;
    if (!scene || !neighborsData) return;
    const group = buildNeighborsGroup(neighborsData, model);
    if (!group) return;
    scene.add(group);
    return () => {
      scene.remove(group);
      disposeObject(group);
    };
  }, [model, neighborsData]);

  if (webglFailed) {
    return (
      <div
        style={{
          height: 420,
          background: "var(--bg-card-alt)",
          border: "1px solid var(--border-card)",
          borderRadius: 12,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          gap: 8,
          padding: 24,
          textAlign: "center",
        }}
      >
        <div
          style={{ fontSize: 14, fontWeight: 600, color: "var(--fg-primary)" }}
        >
          3D-просмотр недоступен
        </div>
        <p style={{ margin: 0, fontSize: 13, color: "var(--fg-secondary)" }}>
          Браузер не поддерживает WebGL — объёмную модель отрисовать нельзя.
          Откройте план застройки на вкладке «План (2D)».
        </p>
      </div>
    );
  }

  return (
    <div>
      {/* Controls bar — light-themed, inline styles (NOT ptica.module.css). */}
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 24,
          flexWrap: "wrap",
          marginBottom: 12,
        }}
      >
        <label
          style={{
            display: "flex",
            alignItems: "center",
            gap: 8,
            fontSize: 13,
            color: "var(--fg-secondary)",
          }}
        >
          <span
            style={{ display: "inline-flex", alignItems: "center", gap: 8 }}
          >
            Время суток
            <span
              style={{
                fontWeight: 600,
                color: "var(--fg-primary)",
                fontVariantNumeric: "tabular-nums",
                minWidth: 44,
              }}
            >
              {fmtHour(hour)}
            </span>
          </span>
          <input
            type="range"
            min={HOUR_MIN}
            max={HOUR_MAX}
            step={1}
            value={hour}
            onChange={(e) => setHour(Number(e.target.value))}
            aria-label="Время суток (положение солнца и теней)"
            style={{ accentColor: "var(--accent)", width: 180 }}
          />
        </label>

        <label
          style={{
            display: "inline-flex",
            alignItems: "center",
            gap: 8,
            fontSize: 13,
            color: "var(--fg-secondary)",
            cursor: "pointer",
          }}
        >
          <input
            type="checkbox"
            checked={autoRotate}
            onChange={(e) => setAutoRotate(e.target.checked)}
            style={{ accentColor: "var(--accent)" }}
          />
          <RotateCw size={16} strokeWidth={1.5} aria-hidden="true" />
          Автоповорот
        </label>
      </div>

      {/* Viewport */}
      <div
        ref={mountRef}
        style={{
          height: 420,
          background: "var(--bg-app)",
          border: "1px solid var(--border-card)",
          borderRadius: 12,
          overflow: "hidden",
        }}
      />

      {/* Caption — source + honesty. */}
      <p
        style={{
          margin: "8px 0 0",
          fontSize: 12,
          lineHeight: "16px",
          color: "var(--fg-tertiary)",
          display: "flex",
          alignItems: "center",
          gap: 8,
        }}
      >
        <Box size={16} strokeWidth={1.5} aria-hidden="true" />
        Объёмы по фактически размещённым корпусам; высота этажа{" "}
        {floorHeightM.toLocaleString("ru")} м
        {floorHeightM === DEFAULT_FLOOR_HEIGHT
          ? " (норматив движка)"
          : " — визуально; ТЭП и финмодель считались при 3,0 м/этаж"}
        . Подложка — карта © OpenStreetMap.
        {neighborsCount > 0
          ? " Соседние здания — контуры и этажность НСПД (кадастр), высота этажа 3 м."
          : ""}
      </p>
    </div>
  );
}
