/**
 * Pure geometry functions for merging two adjacent polygons.
 *
 * No OpenLayers dependency — operates on raw coordinate arrays.
 */

/**
 * Squared distance between two points.
 */
function dist2(a, b) {
  return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
}

/**
 * Signed area of a ring (shoelace formula).
 * Positive = counter-clockwise, negative = clockwise.
 */
function signedArea(ring) {
  let area = 0;
  for (let i = 0, n = ring.length; i < n - 1; i++) {
    area += (ring[i][0] * ring[i + 1][1]) - (ring[i + 1][0] * ring[i][1]);
  }
  return area / 2;
}

/**
 * Test whether a point is inside a ring (ray-casting algorithm).
 */
function pointInRing(pt, ring) {
  let inside = false;
  for (let i = 0, j = ring.length - 2; i < ring.length - 1; j = i++) {
    const xi = ring[i][0], yi = ring[i][1];
    const xj = ring[j][0], yj = ring[j][1];
    if (((yi > pt[1]) !== (yj > pt[1])) &&
        (pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi)) {
      inside = !inside;
    }
  }
  return inside;
}

/**
 * Ensure a ring has the desired winding order.
 */
function ensureWinding(ring, ccw) {
  const area = signedArea(ring);
  if ((ccw && area < 0) || (!ccw && area > 0)) {
    return ring.slice().reverse();
  }
  return ring;
}

/**
 * Close a ring (ensure first === last).
 */
function closeRing(coords) {
  if (coords.length < 2) return coords;
  if (dist2(coords[0], coords[coords.length - 1]) > 1e-10) {
    return [...coords, coords[0].slice()];
  }
  return coords;
}

/**
 * Perpendicular distance from a point to a segment.
 *
 * @param {number[]} pt
 * @param {number[]} segA  Segment start
 * @param {number[]} segB  Segment end
 * @returns {number}  Squared distance
 */
function distToSegmentSq(pt, segA, segB) {
  const dx = segB[0] - segA[0];
  const dy = segB[1] - segA[1];
  const lenSq = dx * dx + dy * dy;

  if (lenSq < 1e-20) return dist2(pt, segA); // degenerate segment

  // Parametric position of the projection
  let t = ((pt[0] - segA[0]) * dx + (pt[1] - segA[1]) * dy) / lenSq;
  t = Math.max(0, Math.min(1, t));

  const projX = segA[0] + t * dx;
  const projY = segA[1] + t * dy;
  return (pt[0] - projX) ** 2 + (pt[1] - projY) ** 2;
}

/**
 * Find the ring edge closest to a click coordinate.
 *
 * @param {number[][]} ring  Closed ring
 * @param {number[]} clickCoord  [x, y]
 * @returns {{ segIdx: number, distSq: number }}
 */
function findClosestEdge(ring, clickCoord) {
  let bestIdx = 0;
  let bestDist = Infinity;
  const n = ring.length - 1; // unique vertices

  for (let i = 0; i < n; i++) {
    const d = distToSegmentSq(clickCoord, ring[i], ring[(i + 1) % n === 0 ? n : i + 1]);
    if (d < bestDist) {
      bestDist = d;
      bestIdx = i;
    }
  }
  return { segIdx: bestIdx, distSq: bestDist };
}

/**
 * Check if two coordinates are equal within tolerance.
 */
function coordsEqual(a, b, tolSq) {
  return dist2(a, b) < tolSq;
}

/**
 * Test whether a point lies within tolerance of any edge of a ring.
 *
 * Unlike coordsEqual (vertex-to-vertex), this checks whether the point is
 * close to the ring's *boundary* — it projects onto segments, so it works
 * even when the two polygons have different vertex density along the shared
 * edge or when vertices are slightly offset from separate digitisation.
 *
 * @param {number[]} pt         Point to test
 * @param {number[][]} ring     Closed ring
 * @param {number} tolSq        Squared distance tolerance
 * @returns {boolean}
 */
function isVertexNearRing(pt, ring, tolSq) {
  const n = ring.length - 1;
  for (let i = 0; i < n; i++) {
    if (distToSegmentSq(pt, ring[i], ring[i + 1]) < tolSq) {
      return true;
    }
  }
  return false;
}

/**
 * Find the shared boundary between two polygon rings.
 *
 * Adjacent polygons share edges in reverse winding direction:
 * if A walks P→Q along the shared boundary, B walks Q→P.
 *
 * The algorithm has two stages:
 *
 *   1. **Seed validation** — uses `isVertexNearRing` (vertex-to-edge proximity)
 *      to confirm the user-clicked edges actually lie on a common boundary.
 *      This is the forgiving check that handles offset vertices from separate
 *      digitisation.
 *
 *   2. **Lockstep extension** — walks both rings together (A forward, B in
 *      the opposite direction) and extends the shared boundary one vertex at
 *      a time.  Three cases are tried at each step:
 *        a) Both rings advance: vertex-to-vertex match (classic case).
 *        b) Only A advances: A has an extra vertex that projects onto B's
 *           frontier edge (different vertex density).
 *        c) Only B advances: vice-versa.
 *      Because extension is coupled to the *frontier edge* of the other ring
 *      (not the entire ring), it cannot overshoot into non-shared territory,
 *      even for small or closely-spaced polygons.
 *
 * @param {number[][]} ringA  Closed ring
 * @param {number[][]} ringB  Closed ring
 * @param {number} seedIdxA  Seed edge index on ring A
 * @param {number} seedIdxB  Seed edge index on ring B
 * @param {number} tolerance  Distance tolerance (in map units)
 * @returns {{ startA: number, endA: number, startB: number, endB: number, reversed: boolean } | null}
 */
function findSharedBoundary(ringA, ringB, seedIdxA, seedIdxB, tolerance) {
  const nA = ringA.length - 1; // unique vertices
  const nB = ringB.length - 1;
  const tolSq = tolerance * tolerance;

  // ── Validate seed edges ─────────────────────────────────────────────
  // Both vertices of the seed edge on A must be near ring B's boundary,
  // or both vertices of the seed edge on B must be near ring A's boundary.
  // This uses vertex-to-edge proximity so it handles offset digitisation.
  const a0 = ringA[seedIdxA];
  const a1 = ringA[(seedIdxA + 1) % nA];
  const b0 = ringB[seedIdxB];
  const b1 = ringB[(seedIdxB + 1) % nB];

  const a0NearB = isVertexNearRing(a0, ringB, tolSq);
  const a1NearB = isVertexNearRing(a1, ringB, tolSq);
  const b0NearA = isVertexNearRing(b0, ringA, tolSq);
  const b1NearA = isVertexNearRing(b1, ringA, tolSq);

  if (!(a0NearB && a1NearB) && !(b0NearA && b1NearA)) {
    console.warn('[polygonMerge] Seed edges are not on the shared boundary');
    return null;
  }

  // ── Determine winding direction ─────────────────────────────────────
  // Reversed (the normal case): A's a0 ≈ B's b1 and A's a1 ≈ B's b0.
  let reversed;
  if (coordsEqual(a0, b1, tolSq) && coordsEqual(a1, b0, tolSq)) {
    reversed = true;
  } else if (coordsEqual(a0, b0, tolSq) && coordsEqual(a1, b1, tolSq)) {
    reversed = false;
  } else {
    // Vertices don't match exactly — use proximity to decide direction
    reversed = dist2(a0, b1) < dist2(a0, b0);
  }

  // ── Initialise shared boundary ──────────────────────────────────────
  let startA = seedIdxA;
  let endA = (seedIdxA + 1) % nA;
  let startB, endB;

  if (reversed) {
    // A walks startA → endA, B walks startB ← endB (reversed ring order)
    startB = (seedIdxB + 1) % nB;
    endB = seedIdxB;
  } else {
    startB = seedIdxB;
    endB = (seedIdxB + 1) % nB;
  }

  // ── Extend forward (endA++, endB-- if reversed) ─────────────────────
  // Walk both rings in lockstep.  At each step try three strategies:
  //   1. Both advance — vertices match (vertex-to-vertex).
  //   2. Only A advances — A's next vertex projects onto B's frontier edge.
  //   3. Only B advances — B's next vertex projects onto A's frontier edge.
  let safety = nA + nB;
  while (safety-- > 0) {
    const nextA = (endA + 1) % nA;
    const nextB = reversed ? (endB - 1 + nB) % nB : (endB + 1) % nB;
    if (nextA === startA || nextB === startB) break; // wrapped around

    // Case 1: vertex-to-vertex match
    if (coordsEqual(ringA[nextA], ringB[nextB], tolSq)) {
      endA = nextA;
      endB = nextB;
      continue;
    }

    // Case 2: A has extra vertex — project onto B's frontier edge
    if (distToSegmentSq(ringA[nextA], ringB[endB], ringB[nextB]) < tolSq) {
      endA = nextA;
      continue;
    }

    // Case 3: B has extra vertex — project onto A's frontier edge
    if (distToSegmentSq(ringB[nextB], ringA[endA], ringA[nextA]) < tolSq) {
      endB = nextB;
      continue;
    }

    break; // no match — end of shared boundary
  }

  // ── Extend backward (startA--, startB++ if reversed) ────────────────
  safety = nA + nB;
  while (safety-- > 0) {
    const prevA = (startA - 1 + nA) % nA;
    const prevB = reversed ? (startB + 1) % nB : (startB - 1 + nB) % nB;
    if (prevA === endA || prevB === endB) break;

    // Case 1: vertex-to-vertex match
    if (coordsEqual(ringA[prevA], ringB[prevB], tolSq)) {
      startA = prevA;
      startB = prevB;
      continue;
    }

    // Case 2: A has extra vertex — project onto B's frontier edge
    if (distToSegmentSq(ringA[prevA], ringB[startB], ringB[prevB]) < tolSq) {
      startA = prevA;
      continue;
    }

    // Case 3: B has extra vertex — project onto A's frontier edge
    if (distToSegmentSq(ringB[prevB], ringA[startA], ringA[prevA]) < tolSq) {
      startB = prevB;
      continue;
    }

    break;
  }

  return { startA, endA, startB, endB, reversed };
}

/**
 * Walk a ring from startIdx to endIdx (exclusive), going forward and wrapping.
 * Skips startIdx and stops before reaching endIdx.
 * Returns the vertices of the non-shared portion.
 *
 * @param {number[][]} ring  Closed ring
 * @param {number} fromIdx  Start walking from this index (inclusive)
 * @param {number} toIdx    Stop at this index (inclusive)
 * @returns {number[][]}
 */
function walkRing(ring, fromIdx, toIdx) {
  const n = ring.length - 1;
  const result = [];
  let idx = fromIdx;
  while (true) {
    result.push(ring[idx]);
    if (idx === toIdx) break;
    idx = (idx + 1) % n;
    // Safety: prevent infinite loops
    if (result.length > n + 1) break;
  }
  return result;
}

/**
 * Merge two adjacent polygons along their shared boundary.
 *
 * @param {number[][][]} polygonCoordsA  Polygon A coordinates [exteriorRing, ...holes]
 * @param {number[][][]} polygonCoordsB  Polygon B coordinates [exteriorRing, ...holes]
 * @param {number[]} clickCoordA  Click coordinate on the shared edge of polygon A
 * @param {number[]} clickCoordB  Click coordinate on the shared edge of polygon B
 * @param {number} [tolerance=5]  Distance tolerance in map units (default 5 metres in EPSG:3857).
 *   A larger tolerance handles polygons that were digitised separately and
 *   whose shared vertices don't coincide exactly.
 * @returns {{ coords: number[][][], error?: undefined } | { coords: null, error: string }}
 *   On success: `{ coords: [...] }`.  On failure: `{ coords: null, error: 'reason' }`.
 */
export function mergePolygons(polygonCoordsA, polygonCoordsB, clickCoordA, clickCoordB, tolerance = 5) {
  const ringA = polygonCoordsA[0];
  const ringB = polygonCoordsB[0];
  const holesA = polygonCoordsA.slice(1);
  const holesB = polygonCoordsB.slice(1);

  // 1. Find seed edges (closest to user clicks)
  const seedA = findClosestEdge(ringA, clickCoordA);
  const seedB = findClosestEdge(ringB, clickCoordB);

  // 2. Find shared boundary
  const shared = findSharedBoundary(ringA, ringB, seedA.segIdx, seedB.segIdx, tolerance);
  if (!shared) {
    console.warn('[polygonMerge] Could not find shared boundary between polygons — seed edges are not near the other ring');
    return { coords: null, error: 'The selected edges are not on a shared boundary. Click edges that lie on the common border between the two polygons.' };
  }

  const { startA, endA, startB, endB, reversed } = shared;
  const nA = ringA.length - 1;
  const nB = ringB.length - 1;

  // 3. Stitch the non-shared portions together
  //    A's shared goes from startA → endA.  Non-shared: endA → startA (forward, wrapping).
  //    B's shared (reversed case) goes from startB backward to endB.
  //      Non-shared: startB → endB (forward).
  //    B's shared (same-dir case) goes from startB → endB.
  //      Non-shared: endB → startB (forward, wrapping).
  //
  //    The last vertex of partA (startA) must coincide with the first vertex
  //    of partB for a clean join.

  const partA = walkRing(ringA, endA, startA);

  let partB;
  if (reversed) {
    // B's non-shared goes from startB forward to endB.
    // startB vertex ≈ startA vertex (they meet at one end of the shared boundary).
    partB = walkRing(ringB, startB, endB);
  } else {
    // B's non-shared goes from endB forward to startB.
    partB = walkRing(ringB, endB, startB);
  }

  // partA ends at startA, partB starts at a vertex that should coincide.
  // Skip the first vertex of partB to avoid the duplicate junction point.
  const merged = [...partA, ...partB.slice(1)];

  // Snap the closing junction.  With non-coincident vertices (separate
  // digitisation) the last vertex of partB may be a few metres from the
  // first vertex of partA (ringA[endA]).  Replace it to avoid a tiny
  // sliver edge that closeRing would otherwise create.
  const tolSq = tolerance * tolerance;
  if (merged.length > 2 && dist2(merged[merged.length - 1], merged[0]) < tolSq) {
    merged[merged.length - 1] = merged[0].slice();
  }

  const mergedRing = closeRing(merged);

  // 4. Validate: the merged ring should have a reasonable area
  const areaA = Math.abs(signedArea(ringA));
  const areaB = Math.abs(signedArea(ringB));
  const areaMerged = Math.abs(signedArea(mergedRing));
  const expectedArea = areaA + areaB;

  // Allow 10% tolerance for area mismatch (shared edges can cause slight differences)
  if (areaMerged < expectedArea * 0.5 || areaMerged > expectedArea * 1.5) {
    console.warn(`[polygonMerge] Area mismatch: A=${areaA.toFixed(1)}, B=${areaB.toFixed(1)}, merged=${areaMerged.toFixed(1)}, expected≈${expectedArea.toFixed(1)}`);
    return { coords: null, error: 'Merge produced an invalid polygon (area mismatch). The polygons may not be truly adjacent — try clicking closer to the shared boundary.' };
  }

  // 5. Match winding order to original
  const originalCCW = signedArea(ringA) > 0;
  const finalRing = ensureWinding(mergedRing, originalCCW);

  // 6. Collect holes from both polygons
  const allHoles = [...holesA, ...holesB];
  // Filter: only include holes that actually fall inside the merged ring
  const validHoles = allHoles.filter(hole => {
    const cx = hole.reduce((s, p) => s + p[0], 0) / (hole.length - 1);
    const cy = hole.reduce((s, p) => s + p[1], 0) / (hole.length - 1);
    return pointInRing([cx, cy], finalRing);
  });

  return { coords: [finalRing, ...validHoles] };
}
