/**
 * Unit tests for geo-local.ts — the dependency-free projection + slippy-tile math
 * behind the §7 «Концепция» 3D massing viewer.
 *
 * Highest-risk code: a sign/scale slip in `makeProjector` mirrors buildings or
 * desyncs them from the OSM ground tile, and an off-by-one in the slippy math
 * pins the ground plane to the wrong place. These cases lock the directions and
 * a couple of known tile indices.
 */
import type { Position } from "geojson";
import { describe, expect, it } from "vitest";

import {
  lat2tile,
  lon2tile,
  makeProjector,
  osmTileRangeForBbox,
  polygonOuterRings,
  ringBounds,
  ringCentroid,
  ringsBounds,
  ringsCentroid,
  tile2lat,
  tile2lon,
} from "../geo-local";

// EKB city centre, used as the projection origin.
const LON0 = 60.6057;
const LAT0 = 56.8389;

describe("makeProjector — WGS84 → local metres", () => {
  const project = makeProjector(LON0, LAT0);

  it("maps the origin to (0, 0)", () => {
    const p = project([LON0, LAT0]);
    expect(p.x).toBeCloseTo(0, 6);
    expect(p.z).toBeCloseTo(0, 6);
  });

  it("east → +x, west → −x", () => {
    expect(project([LON0 + 0.001, LAT0]).x).toBeGreaterThan(0);
    expect(project([LON0 - 0.001, LAT0]).x).toBeLessThan(0);
  });

  it("north → −z, south → +z (Three.js right-handed)", () => {
    expect(project([LON0, LAT0 + 0.001]).z).toBeLessThan(0);
    expect(project([LON0, LAT0 - 0.001]).z).toBeGreaterThan(0);
  });

  it("1° of latitude ≈ 111 319 m (R·π/180)", () => {
    // North by 1° → z should be −(R·π/180) ≈ −111 319.49 m (R = 6 378 137).
    const p = project([LON0, LAT0 + 1]);
    expect(-p.z).toBeCloseTo(111_319.49, 0);
  });

  it("a degree of longitude is shorter than a degree of latitude at this lat", () => {
    // cos(56.84°) ≈ 0.547 → lon-metres ≈ 0.547 × lat-metres.
    const east = project([LON0 + 1, LAT0]).x;
    const north = -project([LON0, LAT0 + 1]).z;
    expect(east).toBeCloseTo(north * Math.cos((LAT0 * Math.PI) / 180), 0);
  });
});

describe("ringCentroid + ringBounds", () => {
  // A closed unit-ish square around the origin (first == last vertex).
  const SQUARE: Position[] = [
    [60.6, 56.83],
    [60.61, 56.83],
    [60.61, 56.84],
    [60.6, 56.84],
    [60.6, 56.83],
  ];

  it("centroid is the average of the four unique corners", () => {
    const [lon, lat] = ringCentroid(SQUARE);
    expect(lon).toBeCloseTo(60.605, 6);
    expect(lat).toBeCloseTo(56.835, 6);
  });

  it("bounds wrap the ring", () => {
    const b = ringBounds(SQUARE);
    expect(b).not.toBeNull();
    expect(b?.minLon).toBeCloseTo(60.6, 6);
    expect(b?.maxLon).toBeCloseTo(60.61, 6);
    expect(b?.minLat).toBeCloseTo(56.83, 6);
    expect(b?.maxLat).toBeCloseTo(56.84, 6);
  });
});

describe("whole-parcel helpers (multi-part MultiPolygon)", () => {
  // A MultiPolygon whose FIRST part is a small sliver at the origin and whose
  // SECOND part is a larger square ~1 km east — the shape that displaced context
  // in the §7 massing scene when only part 0 drove the frame (163 prod parcels,
  // max 1611 m offset). Rings are closed (first == last).
  const PART0_SLIVER: Position[] = [
    [60.6, 56.83],
    [60.6005, 56.83],
    [60.6005, 56.8305],
    [60.6, 56.8305],
    [60.6, 56.83],
  ];
  // Larger square ~1 km east (lon +0.016 ≈ 970 m at this latitude).
  const PART1_MAIN: Position[] = [
    [60.616, 56.83],
    [60.62, 56.83],
    [60.62, 56.834],
    [60.616, 56.834],
    [60.616, 56.83],
  ];
  const RINGS = [PART0_SLIVER, PART1_MAIN];

  it("polygonOuterRings returns every part's outer ring for a MultiPolygon", () => {
    const rings = polygonOuterRings({
      type: "MultiPolygon",
      coordinates: [[PART0_SLIVER], [PART1_MAIN]],
    });
    expect(rings).toHaveLength(2);
    expect(rings[0]).toBe(PART0_SLIVER);
    expect(rings[1]).toBe(PART1_MAIN);
  });

  it("polygonOuterRings returns the single outer ring for a bare Polygon", () => {
    const rings = polygonOuterRings({
      type: "Polygon",
      coordinates: [PART1_MAIN],
    });
    expect(rings).toHaveLength(1);
    expect(rings[0]).toBe(PART1_MAIN);
  });

  it("ringsCentroid is the WHOLE-geometry centre, well east of part 0's", () => {
    const whole = ringsCentroid(RINGS);
    const part0 = ringCentroid(PART0_SLIVER);
    // Part 0 sits at lon ≈ 60.60025; the whole-geometry centroid is pulled east
    // toward the larger second part — the core of the fix.
    expect(part0[0]).toBeCloseTo(60.60025, 4);
    expect(whole[0]).toBeGreaterThan(part0[0] + 0.005);
    // It must fall between the two parts, not on either alone.
    expect(whole[0]).toBeGreaterThan(60.6);
    expect(whole[0]).toBeLessThan(60.62);
  });

  it("ringsBounds unions ALL parts (so the OSM tile covers every part)", () => {
    const b = ringsBounds(RINGS);
    expect(b).not.toBeNull();
    // Spans from part 0's west edge to part 1's east edge.
    expect(b?.minLon).toBeCloseTo(60.6, 6);
    expect(b?.maxLon).toBeCloseTo(60.62, 6);
    expect(b?.minLat).toBeCloseTo(56.83, 6);
    expect(b?.maxLat).toBeCloseTo(56.834, 6);
    // A part-0-only bound would stop at 60.6005 — assert we went well past it.
    expect(b?.maxLon).toBeGreaterThan(60.6005 + 0.01);
  });

  it("ringBounds / ringCentroid still delegate correctly for one ring", () => {
    expect(ringBounds(PART1_MAIN)).toEqual(ringsBounds([PART1_MAIN]));
    expect(ringCentroid(PART1_MAIN)).toEqual(ringsCentroid([PART1_MAIN]));
  });
});

describe("slippy-tile math", () => {
  it("known tile indices at z=0 / z=1", () => {
    // The whole world is one tile at z=0.
    expect(lon2tile(0, 0)).toBe(0);
    expect(lat2tile(0, 0)).toBe(0);
    // At z=1 the prime meridian / equator sit on the (1,1) tile corner.
    expect(lon2tile(0.0001, 1)).toBe(1);
    expect(lat2tile(0.0001, 1)).toBe(0); // just north of equator → top half
    expect(lat2tile(-0.0001, 1)).toBe(1);
  });

  it("EKB centre lands on a known z=15 tile", () => {
    // Reference (computed from the standard slippy formula):
    //   x = floor((60.6057+180)/360 · 32768) = 21900
    //   y = floor((1 − asinh(tan(56.8389°))/π)/2 · 32768) = 10065
    expect(lon2tile(LON0, 15)).toBe(21900);
    expect(lat2tile(LAT0, 15)).toBe(10065);
  });

  it("tile2lon / tile2lat invert lon2tile / lat2tile (tile NW corner)", () => {
    const z = 15;
    const tx = lon2tile(LON0, z);
    const ty = lat2tile(LAT0, z);
    // The NW corner of the containing tile is ≤ the point, and the next tile's
    // corner is > it.
    expect(tile2lon(tx, z)).toBeLessThanOrEqual(LON0);
    expect(tile2lon(tx + 1, z)).toBeGreaterThan(LON0);
    expect(tile2lat(ty, z)).toBeGreaterThanOrEqual(LAT0); // north corner
    expect(tile2lat(ty + 1, z)).toBeLessThan(LAT0);
  });
});

describe("osmTileRangeForBbox", () => {
  it("caps the stitched block at ≤ 4 tiles within [16,19]", () => {
    // A ~250 m lot bbox around EKB centre.
    const bbox = {
      minLon: LON0 - 0.002,
      maxLon: LON0 + 0.002,
      minLat: LAT0 - 0.0012,
      maxLat: LAT0 + 0.0012,
    };
    const r = osmTileRangeForBbox(bbox);
    expect(r.z).toBeGreaterThanOrEqual(16);
    expect(r.z).toBeLessThanOrEqual(19);
    expect(r.cols * r.rows).toBeLessThanOrEqual(4);
    // The stitched block must fully contain the bbox.
    expect(r.westLon).toBeLessThanOrEqual(bbox.minLon);
    expect(r.eastLon).toBeGreaterThanOrEqual(bbox.maxLon);
    expect(r.northLat).toBeGreaterThanOrEqual(bbox.maxLat);
    expect(r.southLat).toBeLessThanOrEqual(bbox.minLat);
  });

  it("honours a 4×4 (16-tile) cap and holds a higher zoom than the 2×2 cap (#2179)", () => {
    // A wider ~700 m-padded bbox around EKB centre (mirrors padBounds(_, 0.7)).
    const bbox = {
      minLon: LON0 - 0.006,
      maxLon: LON0 + 0.006,
      minLat: LAT0 - 0.004,
      maxLat: LAT0 + 0.004,
    };
    const r16 = osmTileRangeForBbox(bbox, { maxTiles: 16 });
    const r4 = osmTileRangeForBbox(bbox, { maxTiles: 4 });
    expect(r16.cols * r16.rows).toBeLessThanOrEqual(16);
    // A larger tile budget keeps a zoom ≥ the 2×2 cap (never sharper-losing).
    expect(r16.z).toBeGreaterThanOrEqual(r4.z);
    // The stitched block must still fully contain the bbox.
    expect(r16.westLon).toBeLessThanOrEqual(bbox.minLon);
    expect(r16.eastLon).toBeGreaterThanOrEqual(bbox.maxLon);
    expect(r16.northLat).toBeGreaterThanOrEqual(bbox.maxLat);
    expect(r16.southLat).toBeLessThanOrEqual(bbox.minLat);
  });
});
