/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

import { describe, it } from 'node:test';
import assert from 'node:assert';

import {
  closestYOnVerticalLineFromRay,
  computeCesiumPlacement,
  computeIfcOriginHeight,
  computeOrthogonalHeightForBaseAltitude,
  getMapUnitScale,
  intersectRayWithHorizontalPlane,
  mapUnitsToMeters,
  metersToMapUnits,
  projectedDeltaToViewerDelta,
  shouldPreferOrthometricTerrain,
  viewerDeltaToProjectedDelta,
} from './cesium-placement.js';

describe('cesium placement helpers', () => {
  it('defaults to METRES when MapUnit is absent (overrides project length unit)', () => {
    // Per IFC4 spec, missing MapUnit falls back to the project's length unit.
    // In practice (Bonsai/IfcOpenShell/Revit), MapConversion offsets are
    // authored in METRES regardless of project unit. Honouring the spec
    // pushes offsets thousands of km out of the CRS's valid range and lands
    // models at the projection's antipode (Hans's IXAS_KW 018 file: mm
    // project + MapConversion (126500, 480000) → South Pacific instead of
    // Netherlands). See `resolveMapUnitToMetreScale` rationale.
    assert.strictEqual(getMapUnitScale(undefined, 0.001), 1);
    assert.strictEqual(getMapUnitScale({ mapUnitScale: 1 }, 0.001), 1);
    // Explicit non-metre MapUnit still wins — the heuristic only fires when
    // MapUnit is unset.
    assert.strictEqual(getMapUnitScale({ mapUnitScale: 0.3048006096 }, 1), 0.3048006096);
  });

  it('converts between metres and map units using ProjectedCRS.mapUnitScale', () => {
    const usFoot = { mapUnitScale: 0.3048006096 };
    assert.strictEqual(mapUnitsToMeters(10, usFoot, 1), 3.048006096);
    assert.strictEqual(
      metersToMapUnits(3.048006096, usFoot, 1),
      10,
    );
  });

  it('prefers orthometric terrain when a vertical datum is present', () => {
    assert.strictEqual(shouldPreferOrthometricTerrain({ verticalDatum: 'EPSG:8357' }), true);
    assert.strictEqual(shouldPreferOrthometricTerrain({ verticalDatum: '$' }), false);
    assert.strictEqual(shouldPreferOrthometricTerrain({ verticalDatum: '' }), false);
    assert.strictEqual(shouldPreferOrthometricTerrain(undefined), false);
  });

  it('places the model at its authored IFC height — no terrain/storey clamp', () => {
    // Model placement is purely IfcMapConversion.OrthogonalHeight + the
    // geometry origin. Terrain and storey data never move the model.
    const placement = computeCesiumPlacement({
      coordinateInfo: {
        originShift: { x: 0, y: 0, z: 0 },
        originalBounds: {
          min: { x: 0, y: -3, z: 0 },
          max: { x: 10, y: 9, z: 10 },
        },
        shiftedBounds: {
          min: { x: 0, y: -3, z: 0 },
          max: { x: 10, y: 9, z: 10 },
        },
        hasLargeCoordinates: false,
      },
      projectedCRS: { verticalDatum: 'EPSG:8357' },
      ifcOriginHeight: 244,
      terrainHeight: 245,
      storeyElevations: new Map([[1, -3], [2, 0], [3, 3]]),
    });

    // placementHeight == authored ifcOriginHeight, NOT terrain+anchorOffset.
    assert.strictEqual(placement.placementHeight, 244);
    // clampAnchorY / anchorOffset are still derived (gizmo + clip math use
    // them) but no longer feed placementHeight.
    assert.strictEqual(placement.clampAnchorY, 0);
    assert.strictEqual(placement.anchorOffset, 3);
    assert.strictEqual(placement.preferOrthometricTerrain, true);
  });

  it('keeps the authored height whether it is above OR below terrain', () => {
    // Above terrain — unchanged.
    const above = computeCesiumPlacement({ ifcOriginHeight: 244, terrainHeight: 195.4 });
    assert.strictEqual(above.placementHeight, 244);

    // Below terrain — the model stays sub-grade, NOT lifted to terrain.
    // (Regression: the old Math.max floor pinned it to terrain, which froze
    //  the vertical placement gizmo and lifted basements above ground.)
    const below = computeCesiumPlacement({ ifcOriginHeight: -20, terrainHeight: 70.61 });
    assert.strictEqual(below.placementHeight, -20);
  });

  it('computes OrthogonalHeight from target base altitude with shift and RTC', () => {
    const orthogonalHeight = computeOrthogonalHeightForBaseAltitude({
      coordinateInfo: {
        originShift: { x: 0, y: 2, z: 0 },
        originalBounds: {
          min: { x: 0, y: -1, z: 0 },
          max: { x: 10, y: 11, z: 10 },
        },
        shiftedBounds: {
          min: { x: 0, y: -1, z: 0 },
          max: { x: 10, y: 11, z: 10 },
        },
        hasLargeCoordinates: false,
        wasmRtcOffset: { x: 0, y: 0, z: 3 },
      },
      projectedCRS: { mapUnitScale: 0.3048 },
      lengthUnitScale: 1,
      storeyElevations: new Map([[1, 0]]),
      targetBaseAltitude: 245,
    });

    assert.strictEqual(orthogonalHeight, 787.4);
  });

  it('computes the IFC origin height from OrthogonalHeight and model center', () => {
    const height = computeIfcOriginHeight(
      { orthogonalHeight: 12 },
      { mapUnitScale: 0.5 },
      {
        originShift: { x: 0, y: 3, z: 0 },
        originalBounds: {
          min: { x: 0, y: 2, z: 0 },
          max: { x: 10, y: 8, z: 10 },
        },
        shiftedBounds: {
          min: { x: 0, y: 2, z: 0 },
          max: { x: 10, y: 8, z: 10 },
        },
        hasLargeCoordinates: false,
      },
      1,
    );

    assert.strictEqual(height, 14);
  });

  it('converts viewer XY drag deltas into projected map deltas', () => {
    const projected = viewerDeltaToProjectedDelta(
      2,
      -1,
      { xAxisAbscissa: 1, xAxisOrdinate: 0, scale: 1 },
      { mapUnitScale: 1 },
      1,
    );

    assert.deepStrictEqual(projected, { eastings: 2, northings: 1 });
  });

  it('converts projected map deltas back to viewer drag deltas', () => {
    const viewer = projectedDeltaToViewerDelta(
      2,
      1,
      { xAxisAbscissa: 1, xAxisOrdinate: 0, scale: 1 },
      { mapUnitScale: 1 },
      1,
    );

    assert.deepStrictEqual(viewer, { x: 2, z: -1 });
  });

  it('intersects a downward ray with a horizontal plane at the expected point', () => {
    const hit = intersectRayWithHorizontalPlane(
      { origin: { x: 5, y: 10, z: -3 }, direction: { x: 0, y: -1, z: 0 } },
      0,
    );
    assert.deepStrictEqual(hit, { x: 5, y: 0, z: -3 });
  });

  it('intersects an oblique ray with a horizontal plane consistently for two cursor samples', () => {
    // Two parallel rays separated by a known horizontal offset should map to
    // hit points separated by the same offset on the plane. This is the
    // invariant the placement gizmo relies on for stable XY drag at any
    // camera angle.
    const direction = { x: 0.4, y: -0.6, z: 0.5 };
    const hitA = intersectRayWithHorizontalPlane(
      { origin: { x: 0, y: 12, z: 0 }, direction },
      0,
    );
    const hitB = intersectRayWithHorizontalPlane(
      { origin: { x: 1, y: 12, z: 2 }, direction },
      0,
    );
    assert.ok(hitA && hitB);
    assert.strictEqual(Math.round((hitB.x - hitA.x) * 1e6) / 1e6, 1);
    assert.strictEqual(Math.round((hitB.z - hitA.z) * 1e6) / 1e6, 2);
  });

  it('rejects rays that are parallel to the horizontal plane', () => {
    const hit = intersectRayWithHorizontalPlane(
      { origin: { x: 0, y: 5, z: 0 }, direction: { x: 1, y: 0, z: 0 } },
      0,
    );
    assert.strictEqual(hit, null);
  });

  it('rejects rays whose intersection lies behind the origin', () => {
    // Ray going up, plane below origin: t < 0.
    const hit = intersectRayWithHorizontalPlane(
      { origin: { x: 0, y: 5, z: 0 }, direction: { x: 0, y: 1, z: 0 } },
      0,
    );
    assert.strictEqual(hit, null);
  });

  it('returns the cursor-aligned Y on a vertical line for an oblique ray', () => {
    // Ray that passes exactly through (anchorX, 7, anchorZ) — the closest
    // point on the vertical axis is the same point, so Y = 7.
    const anchorX = 4;
    const anchorZ = -2;
    const target = { x: anchorX, y: 7, z: anchorZ };
    const origin = { x: 0, y: 0, z: 0 };
    const dx = target.x - origin.x;
    const dy = target.y - origin.y;
    const dz = target.z - origin.z;
    const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
    const direction = { x: dx / len, y: dy / len, z: dz / len };
    const y = closestYOnVerticalLineFromRay({ origin, direction }, anchorX, anchorZ);
    assert.ok(y !== null);
    assert.ok(Math.abs((y as number) - 7) < 1e-9);
  });

  it('preserves vertical drag amount when cursor moves up by a known screen offset', () => {
    // Two rays that pass through points (anchorX, y1, anchorZ) and
    // (anchorX, y2, anchorZ) on the vertical line: returned Y values must
    // equal y1 and y2 exactly — the basis of frame-rate-independent height
    // dragging at oblique camera angles.
    const anchorX = 0;
    const anchorZ = 0;
    const origin = { x: 6, y: 0, z: 4 };

    const aim = (y: number) => {
      const dx = anchorX - origin.x;
      const dy = y - origin.y;
      const dz = anchorZ - origin.z;
      const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
      return { origin, direction: { x: dx / len, y: dy / len, z: dz / len } };
    };

    const y1 = closestYOnVerticalLineFromRay(aim(2), anchorX, anchorZ);
    const y2 = closestYOnVerticalLineFromRay(aim(9), anchorX, anchorZ);
    assert.ok(y1 !== null && y2 !== null);
    assert.ok(Math.abs((y1 as number) - 2) < 1e-9);
    assert.ok(Math.abs((y2 as number) - 9) < 1e-9);
  });

  it('rejects vertical rays for closest-Y-on-line (degenerate)', () => {
    const y = closestYOnVerticalLineFromRay(
      { origin: { x: 0, y: 10, z: 0 }, direction: { x: 0, y: -1, z: 0 } },
      0,
      0,
    );
    assert.strictEqual(y, null);
  });
});
