import { LocationConflation } from '@rapideditor/location-conflation';
import whichPolygon from 'which-polygon';
import calcArea from '@mapbox/geojson-area';

const _loco = new LocationConflation();    // instance of a location-conflation resolver


/**
 * `LocationManager` maintains an internal index of all the boundaries/geofences used by iD.
 * It's used by presets, community index, background imagery, to know where in the world these things are valid.
 * These geofences should be defined by `locationSet` objects:
 *
 * let locationSet = {
 *   include: [ Array of locations ],
 *   exclude: [ Array of locations ]
 * };
 *
 * For more info see the location-conflation and country-coder projects, see:
 * https://github.com/rapideditor/location-conflation
 * https://github.com/rapideditor/country-coder
 */
export class LocationManager {

  /**
   * @constructor
   */
  constructor() {
    this._wp = null;                        // A which-polygon index
    this._resolved = new Map();             // Map (id -> GeoJSON feature)
    this._knownLocationSets = new Map();    // Map (locationSetID -> Number area)
    this._locationIncludedIn = new Map();   // Map (locationID -> Set(locationSetID) )
    this._locationExcludedIn = new Map();   // Map (locationID -> Set(locationSetID) )

    // pre-resolve the worldwide locationSet
    const world = { locationSet: { include: ['Q2'] } };
    this._resolveLocationSet(world);
    this._rebuildIndex();
  }


  /**
   * _validateLocationSet
   * Pass an Object with a `locationSet` property.
   * Validates the `locationSet` and sets a `locationSetID` property on the object.
   * To avoid so much computation we only resolve the include and exclude regions, but not the locationSet itself.
   *
   * Use `_resolveLocationSet()` instead if you need to resolve geojson of locationSet, for example to render it.
   * Note: You need to call `_rebuildIndex()` after you're all finished validating the locationSets.
   *
   * @param  `obj`  Object to check, it should have `locationSet` property
   */
  _validateLocationSet(obj) {
    if (obj.locationSetID) return;  // work was done already

    try {
      let locationSet = obj.locationSet;
      if (!locationSet) {
        throw new Error('object missing locationSet property');
      }
      if (!locationSet.include) {      // missing `include`, default to worldwide include
        locationSet.include = ['Q2'];  // https://github.com/openstreetmap/iD/pull/8305#discussion_r662344647
      }

      // Validate the locationSet only
      // Resolve the include/excludes
      const locationSetID = _loco.validateLocationSet(locationSet).id;
      obj.locationSetID = locationSetID;
      if (this._knownLocationSets.has(locationSetID)) return;   // seen one like this before

      let area = 0;

      // Resolve and index the 'includes'
      (locationSet.include || []).forEach(location => {
        const locationID = _loco.validateLocation(location).id;
        let geojson = this._resolved.get(locationID);

        if (!geojson) {    // first time seeing a location like this
          geojson = _loco.resolveLocation(location).feature;     // resolve to GeoJSON
          this._resolved.set(locationID, geojson);
        }
        area += geojson.properties.area;

        let s = this._locationIncludedIn.get(locationID);
        if (!s) {
          s = new Set();
          this._locationIncludedIn.set(locationID, s);
        }
        s.add(locationSetID);
      });

      // Resolve and index the 'excludes'
      (locationSet.exclude || []).forEach(location => {
        const locationID = _loco.validateLocation(location).id;
        let geojson = this._resolved.get(locationID);

        if (!geojson) {    // first time seeing a location like this
          geojson = _loco.resolveLocation(location).feature;     // resolve to GeoJSON
          this._resolved.set(locationID, geojson);
        }
        area -= geojson.properties.area;

        let s = this._locationExcludedIn.get(locationID);
        if (!s) {
          s = new Set();
          this._locationExcludedIn.set(locationID, s);
        }
        s.add(locationSetID);
      });

      this._knownLocationSets.set(locationSetID, area);

    } catch {
      obj.locationSet = { include: ['Q2'] };  // default worldwide
      obj.locationSetID = '+[Q2]';
    }
  }


  /**
   * _resolveLocationSet
   * Does everything that `_validateLocationSet()` does, but then "resolves" the locationSet into GeoJSON.
   * This step is a bit more computationally expensive, so really only needed if you intend to render the shape.
   *
   * Note: You need to call `_rebuildIndex()` after you're all finished validating the locationSets.
   *
   * @param  `obj`  Object to check, it should have `locationSet` property
   */
  _resolveLocationSet(obj) {
    this._validateLocationSet(obj);

    if (this._resolved.has(obj.locationSetID)) return;  // work was done already

    try {
      const result = _loco.resolveLocationSet(obj.locationSet);
      const locationSetID = result.id;
      obj.locationSetID = locationSetID;

      if (!result.feature.geometry.coordinates.length || !result.feature.properties.area) {
        throw new Error(`locationSet ${locationSetID} resolves to an empty feature.`);
      }

      let geojson = JSON.parse(JSON.stringify(result.feature));   // deep clone
      geojson.id = locationSetID;      // Important: always use the locationSet `id` (`+[Q30]`), not the feature `id` (`Q30`)
      geojson.properties.id = locationSetID;
      this._resolved.set(locationSetID, geojson);

    } catch {
      obj.locationSet = { include: ['Q2'] };  // default worldwide
      obj.locationSetID = '+[Q2]';
    }
  }


  /**
   * _rebuildIndex
   * Rebuilds the whichPolygon index with whatever features have been resolved into GeoJSON.
   */
  _rebuildIndex() {
    this._wp = whichPolygon({ features: [...this._resolved.values()] });
  }


  /**
   * mergeCustomGeoJSON
   * Accepts a FeatureCollection-like object containing custom locations
   * Each feature must have a filename-like `id`, for example: `something.geojson`
   * {
   *   "type": "FeatureCollection"
   *   "features": [
   *     {
   *       "type": "Feature",
   *       "id": "philly_metro.geojson",
   *       "properties": { … },
   *       "geometry": { … }
   *     }
   *   ]
   * }
   *
   * @param  `fc`  FeatureCollection-like Object containing custom locations
   */
  mergeCustomGeoJSON(fc) {
    if (!fc || fc.type !== 'FeatureCollection' || !Array.isArray(fc.features)) return;

    fc.features.forEach(feature => {
      feature.properties = feature.properties || {};
      let props = feature.properties;

      // Get `id` from either `id` or `properties`
      let id = feature.id || props.id;
      if (!id || !/^\S+\.geojson$/i.test(id)) return;

      // Ensure `id` exists and is lowercase
      id = id.toLowerCase();
      feature.id = id;
      props.id = id;

      // Ensure `area` property exists
      if (!props.area) {
        const area = calcArea.geometry(feature.geometry) / 1e6;  // m² to km²
        props.area = Number(area.toFixed(2));
      }

      _loco._cache[id] = feature;   // insert directly into LocationConflations internal cache
    });
  }


  /**
   * mergeLocationSets
   * Accepts an Array of Objects containing `locationSet` properties:
   * [
   *  { id: 'preset1', locationSet: {…} },
   *  { id: 'preset2', locationSet: {…} },
   *  …
   * ]
   * After validating, the Objects will be decorated with a `locationSetID` property:
   * [
   *  { id: 'preset1', locationSet: {…}, locationSetID: '+[Q2]' },
   *  { id: 'preset2', locationSet: {…}, locationSetID: '+[Q30]' },
   *  …
   * ]
   *
   * @param  `objects`  Objects to check - they should have `locationSet` property
   * @return  Promise resolved true (this function used to be slow/async, now it's faster and sync)
   */
  mergeLocationSets(objects) {
    if (!Array.isArray(objects)) return Promise.reject('nothing to do');

    objects.forEach(obj => this._validateLocationSet(obj));
    this._rebuildIndex();
    return Promise.resolve(objects);
  }


  /**
   * locationSetID
   * Returns a locationSetID for a given locationSet (fallback to `+[Q2]`, world)
   * (The locationSet doesn't necessarily need to be resolved to compute its `id`)
   *
   * @param  `locationSet`  A locationSet Object, e.g. `{ include: ['us'] }`
   * @return  String locationSetID, e.g. `+[Q30]`
   */
  locationSetID(locationSet) {
    let locationSetID;
    try {
      locationSetID = _loco.validateLocationSet(locationSet).id;
    } catch {
      locationSetID = '+[Q2]';  // the world
    }
    return locationSetID;
  }


  /**
   * feature
   * Returns the resolved GeoJSON feature for a given locationSetID (fallback to 'world')
   * A GeoJSON feature:
   * {
   *   type: 'Feature',
   *   id: '+[Q30]',
   *   properties: { id: '+[Q30]', area: 21817019.17, … },
   *   geometry: { … }
   * }
   *
   * @param  `locationSetID`  String identifier, e.g. `+[Q30]`
   * @return  GeoJSON object (fallback to world)
   */
  feature(locationSetID = '+[Q2]') {
    const feature = this._resolved.get(locationSetID);
    return feature || this._resolved.get('+[Q2]');
  }


  /**
   * locationSetsAt
   * Find all the locationSets valid at the given location.
   * Results include the area (in km²) to facilitate sorting.
   *
   * Object of locationSetIDs to areas (in km²)
   * {
   *   "+[Q2]": 511207893.3958111,
   *   "+[Q30]": 21817019.17,
   *   "+[new_jersey.geojson]": 22390.77,
   *   …
   * }
   *
   * @param  `loc`  `[lon,lat]` location to query, e.g. `[-74.4813, 40.7967]`
   * @return  Object of locationSetIDs valid at given location
   */
  locationSetsAt(loc) {
    let result = {};

    const hits = this._wp(loc, true) || [];
    const thiz = this;

    // locationSets
    hits.forEach(prop => {
      if (prop.id[0] !== '+') return;  // skip - it's a location
      const locationSetID = prop.id;
      const area = thiz._knownLocationSets.get(locationSetID);
      if (area) {
        result[locationSetID] = area;
      }
    });

    // locations included
    hits.forEach(prop => {
      if (prop.id[0] === '+') return;   // skip - it's a locationset
      const locationID = prop.id;
      const included = thiz._locationIncludedIn.get(locationID);
      (included || []).forEach(locationSetID => {
        const area = thiz._knownLocationSets.get(locationSetID);
        if (area) {
          result[locationSetID] = area;
        }
      });
    });

    // locations excluded
    hits.forEach(prop => {
      if (prop.id[0] === '+') return;   // skip - it's a locationset
      const locationID = prop.id;
      const excluded = thiz._locationExcludedIn.get(locationID);
      (excluded || []).forEach(locationSetID => {
        delete result[locationSetID];
      });
    });

    return result;
  }


  // Direct access to the location-conflation resolver
  loco() {
    return _loco;
  }
}


const _sharedLocationManager = new LocationManager();
export { _sharedLocationManager as locationManager };

