/**
 * zonal.js — vector-in-vector zonal statistics (Stage 1 of the LUPMIS2 GIS
 * Analytical Tools concept).
 *
 * Answers questions like "how many parcels are in each collector zone?" or
 * "what is the mean plot size per zone?" — the classic *summarize within*
 * operation. Raster × vector zonal statistics arrives with the raster pipeline
 * in Stage 2; this module is pure vector and runs fully offline.
 *
 * Membership rules:
 *   'centroid'   — a feature belongs to the zone containing its centroid
 *                  (default; each feature counts exactly once)
 *   'intersects' — a feature belongs to every zone it touches
 *                  (features straddling a boundary are counted more than once)
 */

import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import booleanIntersects from '@turf/boolean-intersects';
import centroid from '@turf/centroid';
import area from '@turf/area';
import { toGeoJSONFeatures, bboxOf, bboxOverlaps, bboxContainsPoint } from './overlay.js';

/**
 * Guard on *candidate* pairs only — a bounding-box pre-filter means a feature
 * is geometry-tested against a zone only when it could plausibly fall in it.
 * A district of 25,000 parcels across 40-odd zones produces roughly one
 * candidate per parcel, so real workloads sit far below this ceiling.
 */
export const MAX_ZONAL_CANDIDATES = 400000;

export const MEMBERSHIP_MODES = [
  { key: 'centroid',   label: 'Centroid inside zone',
    hint: 'Each feature is counted once, in the zone containing its centre.' },
  { key: 'intersects', label: 'Any overlap with zone',
    hint: 'A feature is counted in every zone it touches (may double-count).' },
];

/** Statistics offered by the Analysis panel. */
export const STATS = [
  { key: 'count', label: 'Count',    needsField: false },
  { key: 'sum',   label: 'Sum',      needsField: true  },
  { key: 'mean',  label: 'Mean',     needsField: true  },
  { key: 'min',   label: 'Minimum',  needsField: true  },
  { key: 'max',   label: 'Maximum',  needsField: true  },
  { key: 'area',  label: 'Total area (m²)', needsField: false },
];

/**
 * List the numeric attribute names available across a set of OL features, so
 * the panel can offer only fields that can actually be summed or averaged.
 * @param {Array} features OL features
 * @returns {string[]}
 */
export function listNumericFields(features) {
  const counts = new Map();
  for (const f of features || []) {
    const props = f.getProperties ? f.getProperties() : {};
    for (const [k, v] of Object.entries(props)) {
      if (k === 'geometry' || k.startsWith('_')) continue;
      const n = typeof v === 'number' ? v : (v !== '' && v != null && !Number.isNaN(Number(v)) ? Number(v) : null);
      if (n === null) continue;
      counts.set(k, (counts.get(k) || 0) + 1);
    }
  }
  return Array.from(counts.keys()).sort();
}

/** Coerce a property to a finite number, or null when it isn't numeric. */
function num(value) {
  if (value === null || value === undefined || value === '') return null;
  const n = typeof value === 'number' ? value : Number(value);
  return Number.isFinite(n) ? n : null;
}

/**
 * Compute zonal statistics.
 *
 * @param {Object} params
 * @param {Array}  params.zoneFeatures    OL features defining the zones (polygons)
 * @param {Array}  params.inputFeatures   OL features to summarise
 * @param {string[]} params.stats         keys from STATS
 * @param {string} [params.field]         numeric attribute (for sum/mean/min/max)
 * @param {string} [params.membership]    'centroid' | 'intersects'
 * @param {string} [params.prefix='zs_']  prefix for the new attribute names
 * @returns {{ geojson: Object, rows: Array<Object> }}
 *   geojson  — the zones with statistics attached (WGS84), ready to add as a layer
 *   rows     — a plain table of the same numbers, for display/export
 *   warnings — figures that are valid but easy to misread (e.g. area over a
 *              line layer); shown to the user rather than thrown
 */
export function zonalStatistics({
  zoneFeatures,
  inputFeatures,
  stats = ['count'],
  field = null,
  membership = 'centroid',
  prefix = 'zs_',
}) {
  const zones = (toGeoJSONFeatures(zoneFeatures) || []).filter((z) => {
    const t = z?.geometry?.type;
    return t === 'Polygon' || t === 'MultiPolygon';
  });
  const inputs = toGeoJSONFeatures(inputFeatures) || [];

  if (zones.length === 0) throw new Error('The zone layer contains no polygons.');

  // Warnings are returned alongside the result rather than thrown: the run is
  // still valid and useful, but some figures would be silently meaningless
  // without an explanation.
  const warnings = [];
  const inputTypes = new Set(inputs.map((f) => f?.geometry?.type).filter(Boolean));
  const inputsHavePolygons = inputTypes.has('Polygon') || inputTypes.has('MultiPolygon');

  // Lines and points have no area, so "Total area" would report a silent 0.
  if (stats.includes('area') && !inputsHavePolygons && inputs.length > 0) {
    warnings.push(
      `Total area is 0 because the features being summarised are ` +
      `${[...inputTypes].join('/')} — only polygons have an area.`
    );
  }

  // Line inputs only. Averaging a field over line segments is not an
  // area-weighted average of the surface those lines describe — the classic
  // trap being "mean elevation per parcel" from contours, where segment count
  // has no relation to the area at each elevation. (Averaging over points is a
  // normal statistic, so it is not flagged.)
  const inputsAreLines = inputTypes.has('LineString') || inputTypes.has('MultiLineString');
  if (stats.includes('mean') && inputsAreLines && !inputsHavePolygons && field) {
    warnings.push(
      `Mean is the average of “${field}” across the individual line features in ` +
      `each zone, not an area-weighted average of the surface they describe. ` +
      `For contour data, Minimum and Maximum (the elevation range) are the ` +
      `figures you can rely on.`
    );
  }

  // Pre-compute once: each input's centroid (centroid mode) or bbox
  // (intersects mode), and every zone's bbox. The bbox comparisons below are
  // cheap arithmetic; only survivors reach the real geometry test.
  const centroids = membership === 'centroid'
    ? inputs.map((f) => { try { return centroid(f); } catch { return null; } })
    : null;
  const inputBoxes = membership === 'centroid' ? null : inputs.map(bboxOf);
  const zoneBoxes = zones.map(bboxOf);

  let candidates = 0;
  const rows = [];
  for (let z = 0; z < zones.length; z++) {
    const zone = zones[z];
    const zBox = zoneBoxes[z];
    const members = [];
    for (let i = 0; i < inputs.length; i++) {
      let inside = false;
      try {
        if (membership === 'centroid') {
          const c = centroids[i];
          if (!c) continue;
          const [cx, cy] = c.geometry.coordinates;
          if (!bboxContainsPoint(zBox, cx, cy)) continue;   // cheap reject
          if (++candidates > MAX_ZONAL_CANDIDATES) throw candidateOverflow(candidates);
          inside = booleanPointInPolygon(c, zone);
        } else {
          if (!bboxOverlaps(zBox, inputBoxes[i])) continue;  // cheap reject
          if (++candidates > MAX_ZONAL_CANDIDATES) throw candidateOverflow(candidates);
          inside = booleanIntersects(inputs[i], zone);
        }
      } catch { inside = false; }
      if (inside) members.push(inputs[i]);
    }

    const values = field
      ? members.map((f) => num(f.properties ? f.properties[field] : null)).filter((v) => v !== null)
      : [];

    const result = {};
    for (const s of stats) {
      switch (s) {
        case 'count':
          result[`${prefix}count`] = members.length; break;
        case 'sum':
          result[`${prefix}sum`] = values.length ? round(values.reduce((a, b) => a + b, 0)) : 0; break;
        case 'mean':
          result[`${prefix}mean`] = values.length
            ? round(values.reduce((a, b) => a + b, 0) / values.length) : null; break;
        case 'min':
          result[`${prefix}min`] = values.length ? round(Math.min(...values)) : null; break;
        case 'max':
          result[`${prefix}max`] = values.length ? round(Math.max(...values)) : null; break;
        case 'area': {
          let total = 0;
          for (const m of members) { try { total += area(m); } catch { /* skip */ } }
          result[`${prefix}area_m2`] = Math.round(total); break;
        }
        default: break;
      }
    }

    zone.properties = { ...(zone.properties || {}), ...result };
    rows.push({ ...labelOf(zone), ...result });
  }

  return { geojson: { type: 'FeatureCollection', features: zones }, rows, warnings };
}

/** @private Raised only when genuinely plausible pairs exceed the budget. */
function candidateOverflow(count) {
  return new Error(
    `More than ${count.toLocaleString()} features fall within the zones' bounding boxes, ` +
    `which is too much to summarise in the browser. Narrow the inputs using “Apply to” ` +
    `(current map view or selected features).`
  );
}

/** Round to 3 decimals to keep attribute tables readable. */
function round(n) {
  return Math.round(n * 1000) / 1000;
}

/**
 * Pick a human label for a zone row — first of the usual naming fields,
 * else a generic index.
 */
function labelOf(zone) {
  const p = zone.properties || {};
  for (const k of ['zone_name', 'name', 'ZONE_NAME', 'NAME', 'upn', 'UPN', 'id', 'ID']) {
    if (p[k] !== undefined && p[k] !== null && String(p[k]).trim() !== '') {
      return { zone: String(p[k]) };
    }
  }
  return { zone: '(unnamed)' };
}
