/**
 * geo-local.ts — dependency-free geo helpers for the §7 «Концепция» 3D massing
 * viewer.
 *
 * Two jobs:
 *  1. Project WGS84 [lon, lat] → local metres about a parcel centroid
 *     (equirectangular), so building footprints and the OSM ground tile can be
 *     placed in a shared Three.js metre-space and stay geo-aligned.
 *  2. Slippy-tile (OSM XYZ) math to pick + fetch + geo-pin a raster ground tile.
 *
 * No Three.js / DOM imports — pure functions, trivially unit-testable. `R` matches
 * `polygonAreaSqm` in concept-api.ts (WGS84 equatorial radius) for consistency.
 */

import type { MultiPolygon, Polygon, Position } from "geojson";

/** WGS84 equatorial radius (m) — same constant as `polygonAreaSqm`. */
export const R = 6_378_137;
const DEG = Math.PI / 180;

// ── bounds / centroid ─────────────────────────────────────────────────────────

export interface Bounds {
  minLon: number;
  minLat: number;
  maxLon: number;
  maxLat: number;
}

/** Axis-aligned WGS84 union bounds over several rings. Null when all are empty. */
export function ringsBounds(rings: Position[][]): Bounds | null {
  let minLon = Infinity;
  let minLat = Infinity;
  let maxLon = -Infinity;
  let maxLat = -Infinity;
  for (const ring of rings) {
    for (const [lon, lat] of ring) {
      if (lon < minLon) minLon = lon;
      if (lon > maxLon) maxLon = lon;
      if (lat < minLat) minLat = lat;
      if (lat > maxLat) maxLat = lat;
    }
  }
  if (!Number.isFinite(minLon) || !Number.isFinite(minLat)) return null;
  return { minLon, minLat, maxLon, maxLat };
}

/** Axis-aligned WGS84 bounds of a single ring. Null on an empty/degenerate ring. */
export function ringBounds(ring: Position[]): Bounds | null {
  return ringsBounds([ring]);
}

/**
 * Average of unique vertices (skips each ring's closing vertex) across ALL rings,
 * so a multi-part parcel projects about its whole-geometry centre rather than one
 * part's — the frame the backend neighbour endpoint anchors context to. Vertex-
 * count weighted (good enough as a projection origin at lot scale). Falls back to
 * [0, 0] when every ring is empty.
 */
export function ringsCentroid(rings: Position[][]): [number, number] {
  let sLon = 0;
  let sLat = 0;
  let n = 0;
  for (const ring of rings) {
    if (ring.length === 0) continue;
    // Drop the closing vertex when the ring is closed (first == last).
    const closed =
      ring.length > 1 &&
      ring[0][0] === ring[ring.length - 1][0] &&
      ring[0][1] === ring[ring.length - 1][1];
    const pts = closed ? ring.slice(0, ring.length - 1) : ring;
    for (const [lon, lat] of pts) {
      sLon += lon;
      sLat += lat;
      n++;
    }
  }
  if (n === 0) return [0, 0];
  return [sLon / n, sLat / n];
}

/**
 * Ring-average of unique vertices (skips the closing vertex), mirroring
 * `polygonCentroidWkt`. Returns [lon, lat]. Falls back to [0, 0] on an empty ring.
 */
export function ringCentroid(ring: Position[]): [number, number] {
  return ringsCentroid([ring]);
}

/**
 * Outer ring(s) of a parcel geometry — one per Polygon part. A bare Polygon
 * yields its single outer ring; a MultiPolygon yields EVERY part's outer ring, so
 * the massing scene can project origin, ground bounds and outline over the WHOLE
 * parcel (the frame the neighbour endpoint anchors to). Inner rings (holes) are
 * dropped — origin/bounds/outline only need the outer boundary.
 */
export function polygonOuterRings(geom: Polygon | MultiPolygon): Position[][] {
  if (geom.type === "MultiPolygon") {
    const out: Position[][] = [];
    for (const part of geom.coordinates) {
      const outer = part[0];
      if (outer && outer.length > 0) out.push(outer);
    }
    return out;
  }
  const outer = geom.coordinates[0];
  return outer && outer.length > 0 ? [outer] : [];
}

// ── projection (WGS84 → local metres) ─────────────────────────────────────────

export interface LocalPoint {
  /** East offset (metres), east → +x. */
  x: number;
  /** South offset (metres), north → −z (Three.js right-handed). */
  z: number;
}

export type Projector = (pos: Position) => LocalPoint;

/**
 * Equirectangular projector about (lon0, lat0). Sub-metre accurate at lot scale
 * (<1 km). Returns [lon, lat] → { x (east +), z (south +) } in metres.
 *
 * Sign discipline: north → −z, east → +x, height → +y — matches the azimuth
 * convention in `sunFromHour` so the sun arc lands east→south→west correctly.
 */
export function makeProjector(lon0: number, lat0: number): Projector {
  const kx = R * DEG * Math.cos(lat0 * DEG); // metres per degree lon at this lat
  const kz = R * DEG; // metres per degree lat
  return ([lon, lat]: Position) => ({
    x: (lon - lon0) * kx,
    z: -(lat - lat0) * kz,
  });
}

// ── slippy-tile (OSM XYZ) math ────────────────────────────────────────────────

export function lon2tile(lon: number, z: number): number {
  return Math.floor(((lon + 180) / 360) * 2 ** z);
}

export function lat2tile(lat: number, z: number): number {
  const r = lat * DEG;
  return Math.floor(
    ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * 2 ** z,
  );
}

export function tile2lon(x: number, z: number): number {
  return (x / 2 ** z) * 360 - 180;
}

export function tile2lat(y: number, z: number): number {
  const n = Math.PI - (2 * Math.PI * y) / 2 ** z;
  return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
}

export interface TileRange {
  z: number;
  x0: number;
  x1: number;
  y0: number;
  y1: number;
  cols: number;
  rows: number;
  /** Exact WGS84 span of the stitched tile block (NW + SE corners). */
  westLon: number;
  eastLon: number;
  northLat: number;
  southLat: number;
}

/**
 * Pick the OSM zoom for a bbox such that the stitched tile block stays ≤ maxTiles,
 * clamped to [minZ, maxZ]. Starts at maxZ and steps the zoom down until the tile
 * count fits. `maxTiles` defaults to 4 (a 2×2 cap); the massing scene passes 16
 * (a 4×4 cap, #2179) so a larger surrounding extent renders WITHOUT dropping zoom.
 *
 * Returns the integer tile range plus the EXACT WGS84 corners of the stitched
 * block (tile2lon/lat of the inclusive range), which the scene projects with the
 * SAME `makeProjector` as the buildings to geo-pin the ground plane.
 */
export function osmTileRangeForBbox(
  bbox: Bounds,
  opts: { minZ?: number; maxZ?: number; maxTiles?: number } = {},
): TileRange {
  const minZ = opts.minZ ?? 16;
  const maxZ = opts.maxZ ?? 19;
  const maxTiles = opts.maxTiles ?? 4;

  let z = maxZ;
  let x0 = 0;
  let x1 = 0;
  let y0 = 0;
  let y1 = 0;
  for (; z >= minZ; z--) {
    x0 = lon2tile(bbox.minLon, z);
    x1 = lon2tile(bbox.maxLon, z);
    // lat → tile is inverted (north = smaller y), so y0 (north) uses maxLat.
    y0 = lat2tile(bbox.maxLat, z);
    y1 = lat2tile(bbox.minLat, z);
    const cols = x1 - x0 + 1;
    const rows = y1 - y0 + 1;
    if (cols * rows <= maxTiles || z === minZ) break;
  }
  const cols = x1 - x0 + 1;
  const rows = y1 - y0 + 1;
  return {
    z,
    x0,
    x1,
    y0,
    y1,
    cols,
    rows,
    westLon: tile2lon(x0, z),
    eastLon: tile2lon(x1 + 1, z),
    northLat: tile2lat(y0, z),
    southLat: tile2lat(y1 + 1, z),
  };
}

/** Pad a bbox by a fraction of its span on every side (e.g. 0.3 → +30% context). */
export function padBounds(bbox: Bounds, frac: number): Bounds {
  const dLon = (bbox.maxLon - bbox.minLon) * frac;
  const dLat = (bbox.maxLat - bbox.minLat) * frac;
  return {
    minLon: bbox.minLon - dLon,
    minLat: bbox.minLat - dLat,
    maxLon: bbox.maxLon + dLon,
    maxLat: bbox.maxLat + dLat,
  };
}
