/**
 * overlay.js — client-side vector overlay operations (Stage 1 of the
 * LUPMIS2 GIS Analytical Tools concept).
 *
 * Pure geometry helpers built on Turf.js. No DOM, no map — the caller hands
 * in OpenLayers features and gets a GeoJSON FeatureCollection back, ready for
 * MapView.addGeoJSONLayer().
 *
 * All Turf work happens in EPSG:4326 (Turf's area/length functions assume
 * WGS84), so features are reprojected out of the map's EPSG:3857 on the way
 * in and the result is handed back as WGS84 GeoJSON — the same convention
 * gis-export.js uses.
 *
 *   intersectFeatures(a, b)   → parts of A that fall inside B  (attrs: A + B)
 *   clipFeatures(a, b)        → parts of A that fall inside B  (attrs: A only)
 *   differenceFeatures(a, b)  → parts of A that fall OUTSIDE B (attrs: A)
 *   unionFeatures(a[, b])     → dissolved outline of A (and B)
 */

import GeoJSON from 'ol/format/GeoJSON';
import intersect from '@turf/intersect';
import union from '@turf/union';
import difference from '@turf/difference';
import { featureCollection } from '@turf/helpers';

/**
 * Guard: only *candidate* pairs (those whose bounding boxes actually overlap)
 * reach the expensive geometry test, so this ceiling is about real work, not
 * the raw n×m product. District-scale data (tens of thousands of parcels
 * against a few dozen zones) sits far below it.
 */
export const MAX_OVERLAY_CANDIDATES = 400000;

const fmt = new GeoJSON();

/**
 * Convert OpenLayers features (map projection) to WGS84 GeoJSON features.
 * @param {Array<import('ol/Feature').default>} features
 * @param {string} [mapProjection='EPSG:3857']
 * @returns {Array<Object>} GeoJSON Features
 */
export function toGeoJSONFeatures(features, mapProjection = 'EPSG:3857') {
  const out = [];
  for (const f of features || []) {
    const geom = f.getGeometry && f.getGeometry();
    if (!geom) continue;
    const gj = fmt.writeFeatureObject(f, {
      dataProjection: 'EPSG:4326',
      featureProjection: mapProjection,
    });
    if (gj && gj.geometry) out.push(gj);
  }
  return out;
}

/**
 * Bounding box of a GeoJSON feature as [minX, minY, maxX, maxY].
 * Walks the coordinate arrays directly — no extra dependency, and fast enough
 * to run over tens of thousands of features.
 * @param {Object} feature GeoJSON Feature
 * @returns {number[]|null}
 */
export function bboxOf(feature) {
  const geom = feature && feature.geometry;
  if (!geom || !geom.coordinates) return null;
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  const walk = (c) => {
    if (typeof c[0] === 'number') {
      if (c[0] < minX) minX = c[0];
      if (c[0] > maxX) maxX = c[0];
      if (c[1] < minY) minY = c[1];
      if (c[1] > maxY) maxY = c[1];
      return;
    }
    for (const sub of c) walk(sub);
  };
  walk(geom.coordinates);
  return Number.isFinite(minX) ? [minX, minY, maxX, maxY] : null;
}

/** Do two bounding boxes overlap? Cheap pre-filter before real geometry work. */
export function bboxOverlaps(a, b) {
  if (!a || !b) return false;
  return !(a[2] < b[0] || b[2] < a[0] || a[3] < b[1] || b[3] < a[1]);
}

/** Is a point [x, y] inside a bounding box? */
export function bboxContainsPoint(box, x, y) {
  if (!box) return false;
  return x >= box[0] && x <= box[2] && y >= box[1] && y <= box[3];
}

/**
 * Reject a non-polygon input with a message that says what is actually wrong.
 *
 * Overlay operations are polygon-only, but the inputs are silently filtered by
 * polygonsOnly() — so a line layer (contours, roads) used to produce an empty
 * result and the misleading "the layers may not overlap". Failing loudly here
 * tells the user the real reason.
 *
 * @param {Array} polygons  the surviving polygonal features
 * @param {Array} original  everything that was passed in
 * @param {string} role     'Layer A' / 'Layer B'
 */
function assertPolygons(polygons, original, role) {
  if (polygons.length > 0 || original.length === 0) return;
  const types = [...new Set(original.map((f) => f?.geometry?.type).filter(Boolean))];
  const found = types.length ? types.join(', ') : 'no geometry';
  throw new Error(
    `${role} contains no polygons (found ${found}). Overlay operations work on ` +
    `polygon layers only — line layers such as contours or roads cannot be used here. ` +
    `To summarise a line layer per zone, use Zonal statistics instead.`
  );
}

/** Keep only polygonal features — overlay is undefined for points/lines here. */
function polygonsOnly(gjFeatures) {
  return (gjFeatures || []).filter((f) => {
    const t = f?.geometry?.type;
    return t === 'Polygon' || t === 'MultiPolygon';
  });
}

/** Merge two property bags, prefixing B's keys on collision so nothing is lost. */
function mergeProps(propsA, propsB, prefixB = 'b_') {
  const out = { ...(propsA || {}) };
  for (const [k, v] of Object.entries(propsB || {})) {
    out[Object.prototype.hasOwnProperty.call(out, k) ? `${prefixB}${k}` : k] = v;
  }
  return out;
}

function fc(features) {
  return { type: 'FeatureCollection', features };
}

/**
 * Intersection: every piece of A that also lies inside B.
 * Result carries A's attributes merged with the matching B feature's.
 *
 * @param {Array} aFeatures OL features (layer A)
 * @param {Array} bFeatures OL features (layer B)
 * @param {Object} [opts]
 * @param {boolean} [opts.keepBAttributes=true] merge B's properties into the result
 * @returns {Object} GeoJSON FeatureCollection (WGS84)
 */
export function intersectFeatures(aFeatures, bFeatures, opts = {}) {
  const { keepBAttributes = true } = opts;
  const gjA = toGeoJSONFeatures(aFeatures);
  const gjB = toGeoJSONFeatures(bFeatures);
  const A = polygonsOnly(gjA);
  const B = polygonsOnly(gjB);
  assertPolygons(A, gjA, 'Layer A');
  assertPolygons(B, gjB, 'Layer B');

  // Pre-compute B's boxes once, then only test pairs that can possibly meet.
  const bBoxes = B.map(bboxOf);
  let candidates = 0;
  const out = [];
  for (const a of A) {
    const aBox = bboxOf(a);
    for (let i = 0; i < B.length; i++) {
      if (!bboxOverlaps(aBox, bBoxes[i])) continue;
      if (++candidates > MAX_OVERLAY_CANDIDATES) throw candidateOverflow(candidates);
      let piece = null;
      try { piece = intersect(featureCollection([a, B[i]])); } catch { piece = null; }
      if (!piece || !piece.geometry) continue;
      piece.properties = keepBAttributes
        ? mergeProps(a.properties, B[i].properties)
        : { ...(a.properties || {}) };
      out.push(piece);
    }
  }
  return fc(out);
}

/**
 * Clip: same geometry result as intersect, but the output keeps ONLY layer A's
 * attributes — the usual "cut this layer to that boundary" operation.
 */
export function clipFeatures(aFeatures, clipFeatures_, opts = {}) {
  return intersectFeatures(aFeatures, clipFeatures_, { ...opts, keepBAttributes: false });
}

/**
 * Difference: the parts of A that do NOT fall inside any feature of B.
 * Each A feature is reduced by every overlapping B feature in turn; features
 * fully covered by B drop out of the result.
 *
 * @returns {Object} GeoJSON FeatureCollection (WGS84)
 */
export function differenceFeatures(aFeatures, bFeatures) {
  const gjA = toGeoJSONFeatures(aFeatures);
  const gjB = toGeoJSONFeatures(bFeatures);
  const A = polygonsOnly(gjA);
  const B = polygonsOnly(gjB);
  assertPolygons(A, gjA, 'Layer A');
  assertPolygons(B, gjB, 'Layer B');

  const bBoxes = B.map(bboxOf);
  let candidates = 0;
  const out = [];
  for (const a of A) {
    const aBox = bboxOf(a);
    let remainder = a;
    for (let i = 0; i < B.length; i++) {
      if (!remainder) break;
      // Only subtract a B feature that can actually touch what's left.
      if (!bboxOverlaps(aBox, bBoxes[i])) continue;
      if (++candidates > MAX_OVERLAY_CANDIDATES) throw candidateOverflow(candidates);
      try {
        const next = difference(featureCollection([remainder, B[i]]));
        remainder = next && next.geometry ? next : null;
      } catch {
        // A malformed pair shouldn't kill the whole run — keep what we have.
      }
    }
    if (remainder && remainder.geometry) {
      remainder.properties = { ...(a.properties || {}) };
      out.push(remainder);
    }
  }
  return fc(out);
}

/**
 * Union / dissolve: merge everything into a single outline. Pass only `a` to
 * dissolve one layer, or both to merge two layers together.
 *
 * @returns {Object} GeoJSON FeatureCollection with 0 or 1 feature (WGS84)
 */
export function unionFeatures(aFeatures, bFeatures = []) {
  // Check each layer separately rather than the combined set: if only the
  // second layer had polygons, a combined check would pass and the first
  // layer's features would be dropped without the user being told.
  const gjA = toGeoJSONFeatures(aFeatures);
  const gjB = toGeoJSONFeatures(bFeatures);
  const polyA = polygonsOnly(gjA);
  const polyB = polygonsOnly(gjB);
  assertPolygons(polyA, gjA, 'Layer A');
  assertPolygons(polyB, gjB, 'Layer B');   // no-op when no second layer is given
  const all = [...polyA, ...polyB];
  if (all.length === 0) return fc([]);
  if (all.length === 1) return fc([all[0]]);

  let merged = null;
  try {
    merged = union(featureCollection(all));
  } catch {
    // Fall back to a pairwise fold if the bulk union chokes on bad geometry.
    merged = all[0];
    for (let i = 1; i < all.length; i++) {
      try {
        const next = union(featureCollection([merged, all[i]]));
        if (next && next.geometry) merged = next;
      } catch { /* skip the offending feature */ }
    }
  }
  if (!merged || !merged.geometry) return fc([]);
  merged.properties = { parts: all.length };
  return fc([merged]);
}

/** @private Raised only when genuinely overlapping work exceeds the budget. */
function candidateOverflow(count) {
  return new Error(
    `This overlay has more than ${count.toLocaleString()} genuinely overlapping feature pairs, ` +
    `which is too much to run in the browser. Narrow the inputs using “Apply to” ` +
    `(current map view or selected features), or run it server-side.`
  );
}

/** Operation registry used by the Analysis panel. */
export const OVERLAY_OPS = [
  { key: 'intersect',  label: 'Intersect',  needsB: true,
    hint: 'Keep the parts of layer A that fall inside layer B (attributes from both).' },
  { key: 'clip',       label: 'Clip',       needsB: true,
    hint: 'Cut layer A to the boundary of layer B (keeps only A’s attributes).' },
  { key: 'difference', label: 'Difference', needsB: true,
    hint: 'Keep the parts of layer A that fall outside layer B.' },
  { key: 'union',      label: 'Union / Dissolve', needsB: false,
    hint: 'Merge features into a single outline (optionally with a second layer).' },
];

/**
 * Run an overlay operation by key.
 * @param {string} op one of OVERLAY_OPS keys
 * @param {Array} aFeatures
 * @param {Array} bFeatures
 * @returns {Object} GeoJSON FeatureCollection (WGS84)
 */
export function runOverlay(op, aFeatures, bFeatures) {
  switch (op) {
    case 'intersect':  return intersectFeatures(aFeatures, bFeatures);
    case 'clip':       return clipFeatures(aFeatures, bFeatures);
    case 'difference': return differenceFeatures(aFeatures, bFeatures);
    case 'union':      return unionFeatures(aFeatures, bFeatures);
    default: throw new Error(`Unknown overlay operation: ${op}`);
  }
}
