/*
 * Copyright (C) 2018 Robert Bosch GmbH Copyright (C) 2018 Robert Bosch Manufacturing Solutions GmbH, Germany. All rights reserved.
 */

import { AfterViewInit, Directive, HostListener, Inject, OnDestroy } from '@angular/core';
import { cloneDeep } from 'lodash-es';
import { Collection, Feature } from 'ol';
import { GeoJSON } from 'ol/format';
import { Point } from 'ol/geom';
import { DrawEvent } from 'ol/interaction/Draw';
import { ModifyEvent } from 'ol/interaction/Modify';
import { TranslateEvent } from 'ol/interaction/Translate';
import { Vector as VectorSource } from 'ol/source';
import { merge, Subject } from 'rxjs';
import { debounceTime, first, takeUntil } from 'rxjs/operators';
import { DrawInteractionMode } from '../generic-maps/open-layers-map/interactions/modes/draw-interaction-mode';
import { ModificationInteractionMode } from '../generic-maps/open-layers-map/interactions/modes/modification-interaction-mode';
import { PolyGuidesInteractionMode } from '../generic-maps/open-layers-map/interactions/modes/poly-guides-interaction-mode';
import { SelectionInteractionMode } from '../generic-maps/open-layers-map/interactions/modes/selection-interaction-mode';
import { SnapInteractionMode } from '../generic-maps/open-layers-map/interactions/modes/snap-interaction-mode';
import { TranslationInteractionMode } from '../generic-maps/open-layers-map/interactions/modes/translation-interaction-mode';
import { OpenLayersLayerFactory } from '../generic-maps/open-layers-map/open-layers-layer-factory';
import { OpenLayersMapComponent } from '../generic-maps/open-layers-map/open-layers-map.component';
import { GeometryType } from '../generic-maps/open-layers-map/open-layers-model';
import { Projections } from '../generic-maps/projection.utils';

import {
  BOUNDARY_ADD_INTERACTION,
  BOUNDARY_REMOVE_ACTION,
  MODIFY_INTERACTION,
  MOVE_INTERACTION,
  POSITION_ADD_INTERACTION,
  POSITION_REMOVE_ACTION,
  SELECT_INTERACTION,
  Supplier,
} from './interaction.tokens';
import { LocationMapService } from './map/location-map.service';
import { LocationStyleService } from './map/location-style.service';
import { LsBoundary } from './model/lsBoundary';
import { LsLocation } from './model/lsLocation';
import { LsPosition } from './model/lsPosition';

@Directive({
  selector: '[bciLocationEditing]',
  providers: [
    {
      provide: MODIFY_INTERACTION,
      useFactory: LocationEditingDirective.modifyInteractionExtractor,
      deps: [LocationEditingDirective],
    },
    {
      provide: MOVE_INTERACTION,
      useFactory: LocationEditingDirective.moveInteractionExtractor,
      deps: [LocationEditingDirective],
    },
    {
      provide: BOUNDARY_ADD_INTERACTION,
      useFactory: LocationEditingDirective.addBoundaryInteractionExtractor,
      deps: [LocationEditingDirective],
    },
    {
      provide: POSITION_ADD_INTERACTION,
      useFactory: LocationEditingDirective.addPositionInteractionExtractor,
      deps: [LocationEditingDirective],
    },
    {
      provide: BOUNDARY_REMOVE_ACTION,
      useFactory: LocationEditingDirective.removeBoundaryAction,
      deps: [LocationEditingDirective],
    },
    {
      provide: POSITION_REMOVE_ACTION,
      useFactory: LocationEditingDirective.removePositionAction,
      deps: [LocationEditingDirective],
    },
  ],
})
export class LocationEditingDirective implements AfterViewInit, OnDestroy {
  editInteractionMode: ModificationInteractionMode;
  positionInteractionMode: DrawInteractionMode;
  moveInteractionMode: TranslationInteractionMode;

  addPositionInteractionMode: DrawInteractionMode;
  addBoundaryInteractionMode: DrawInteractionMode;

  snapInteractionMode: SnapInteractionMode;

  protected geoJSONReader: GeoJSON;

  protected locationVectorSource: VectorSource;

  private ngUnsubscribe = new Subject<void>();

  constructor(
    protected mapComponent: OpenLayersMapComponent,
    protected locationMapService: LocationMapService,
    protected locationStyleService: LocationStyleService,
    protected openLayersLayerFactory: OpenLayersLayerFactory,
    @Inject(SELECT_INTERACTION) private selectionInteractionModeSupplier: Supplier<SelectionInteractionMode>
  ) {
    mapComponent.loaded.pipe(first(), takeUntil(this.ngUnsubscribe)).subscribe(() => this.afterMapLoaded());
  }

  protected static modifyInteractionExtractor(directive: LocationEditingDirective) {
    // noinspection UnnecessaryLocalVariableJS, reason: AOT compiler issue https://github.com/ng-packagr/ng-packagr/issues/696
    const x = () => directive.editInteractionMode;
    return x;
  }

  protected static moveInteractionExtractor(directive: LocationEditingDirective) {
    // noinspection UnnecessaryLocalVariableJS, reason: AOT compiler issue https://github.com/ng-packagr/ng-packagr/issues/696
    const x = () => directive.moveInteractionMode;
    return x;
  }

  protected static addPositionInteractionExtractor(directive: LocationEditingDirective) {
    // noinspection UnnecessaryLocalVariableJS, reason: AOT compiler issue https://github.com/ng-packagr/ng-packagr/issues/696
    const x = () => directive.addPositionInteractionMode;
    return x;
  }

  protected static addBoundaryInteractionExtractor(directive: LocationEditingDirective) {
    // noinspection UnnecessaryLocalVariableJS, reason: AOT compiler issue https://github.com/ng-packagr/ng-packagr/issues/696
    const x = () => directive.addBoundaryInteractionMode;
    return x;
  }

  protected static removeBoundaryAction(directive: LocationEditingDirective) {
    // noinspection UnnecessaryLocalVariableJS, reason: AOT compiler issue https://github.com/ng-packagr/ng-packagr/issues/696
    const x = () => {
      directive.removeCurrentBoundary();
      return true;
    };
    return x;
  }

  protected static removePositionAction(directive: LocationEditingDirective) {
    // noinspection UnnecessaryLocalVariableJS, reason: AOT compiler issue https://github.com/ng-packagr/ng-packagr/issues/696
    const x = () => {
      directive.removeCurrentPosition();
      return true;
    };
    return x;
  }

  ngAfterViewInit(): void {
    this.geoJSONReader = this.openLayersLayerFactory.createGeoJSONReader(this.mapComponent.projection);
    this.initDrawLayer();
  }

  ngOnDestroy(): void {
    this.ngUnsubscribe.next();
    this.ngUnsubscribe.complete();
  }

  afterMapLoaded(): void {
    this.initEditModes();
  }

  @HostListener('document:keydown.alt', ['$event'])
  @HostListener('document:keyup.alt', ['$event'])
  hostKeyListener(event: KeyboardEvent) {
    this.togglePositionInteractionMode(event);
  }

  /**
   * Remove location's position
   * WARNING: will not affect altitude
   * @param location a LsLocation
   * @returns modifed location
   */
  removePosition(location: LsLocation): LsLocation {
    if (!location.geoCoordinate) {
      return location;
    }

    const clonedLocation = cloneDeep(location);
    if (!!clonedLocation.geoCoordinate.altitude || clonedLocation.geoCoordinate.altitude === 0) {
      delete clonedLocation.geoCoordinate.longitude;
      delete clonedLocation.geoCoordinate.latitude;
    } else {
      delete clonedLocation.geoCoordinate;
    }
    return clonedLocation;
  }

  /**
   * Remove position of location being edited currently
   * WARNING: will not affect altitude
   */
  removeCurrentPosition(): void {
    this.locationMapService.locationBeingEdited = this.removePosition(this.locationMapService.locationBeingEdited);
  }

  /**
   * Set position of location being edited currently
   * WARNING: will not affect altitude
   * @param position a LsPosition
   */
  setCurrentPosition(position: LsPosition): void {
    this.locationMapService.locationBeingEdited = this.setPosition(
      this.locationMapService.locationBeingEdited,
      position
    );
  }

  /**
   * Remove location's boundary
   * @param location a LsLocation
   * @returns modified location
   */
  removeBoundary(location: LsLocation): LsLocation {
    const shallowLocationClone = Object.assign({}, location) as LsLocation;
    delete shallowLocationClone.boundary;
    return shallowLocationClone;
  }

  /**
   * Remove boundary of location being edited currently
   */
  removeCurrentBoundary(): void {
    this.locationMapService.locationBeingEdited = this.removeBoundary(this.locationMapService.locationBeingEdited);
  }

  /**
   * Set boundary of location being edited currently
   * @param boundary a LsBoundary
   */
  setCurrentBoundary(boundary: LsBoundary): void {
    this.locationMapService.locationBeingEdited = this.setBoundary(
      this.locationMapService.locationBeingEdited,
      boundary
    );
  }

  /**
   * Set location's position
   * WARNING: will not affect altitude
   * @param location a LsLocation
   * @param position a LsPosition
   * @returns modified location
   */
  setPosition(location: LsLocation, position: LsPosition): LsLocation {
    if (!!position) {
      if (location.geoCoordinate && (location.geoCoordinate.altitude || location.geoCoordinate.altitude === 0)) {
        position.altitude = location.geoCoordinate.altitude;
      }
      return Object.assign({}, location, { geoCoordinate: position });
    } else {
      return location;
    }
  }

  /**
   * Set location's boundary
   * @param location a LsLocation
   * @param boundary a LsBoundary
   * @returns modified location
   */
  setBoundary(location: LsLocation, boundary: LsBoundary): LsLocation {
    if (!!boundary) {
      return Object.assign({}, location, { boundary: boundary });
    } else {
      return location;
    }
  }

  /**
   * Set location's geometries
   * WARNING: will not affect altitude
   * @param location a LsLocation
   * @param geometries position and boundary
   * @returns modified location
   */
  setGeometries(location: LsLocation, geometries: Partial<LsLocation>): LsLocation {
    return this.setPosition(this.setBoundary(location, geometries.boundary), geometries.geoCoordinate);
  }

  /**
   * Set geometries of the location currently being edited
   * WARNING: will not affect altitude
   * @param geometries position and boundary
   */
  setCurrentGeometries(geometries: Partial<LsLocation>) {
    this.locationMapService.locationBeingEdited = this.setGeometries(
      this.locationMapService.locationBeingEdited,
      geometries
    );
  }

  /**
   * Turn features into a partial location including a location's geometries.
   * WARNING: altitude is not part of that.
   * @param features features to deserialize
   * @returns deserialized geometries
   */
  getLocationGeometries(features: Collection<Feature>): Partial<LsLocation> {
    const location: Partial<LsLocation> = {};
    features.forEach((f) => {
      const featureId = f.getId() as string;

      const featureIdSuffixBoundary = '.boundary';
      const featureIdSuffixPosition = '.position';
      if (featureId.endsWith(featureIdSuffixBoundary)) {
        location.id = featureId.slice(0, -featureIdSuffixBoundary.length);
        location.boundary = this.getBoundary(f);
      } else if (featureId.endsWith(featureIdSuffixPosition)) {
        location.id = featureId.slice(0, -featureIdSuffixPosition.length);
        location.geoCoordinate = this.getPosition(f);
      }
    });
    return location;
  }

  /**
   * Deserialize position from a feature.
   * WARNING: altitude is not part of that.
   * @param feature feature to deserialize
   * @returns longitude and latitude
   */
  getPosition(feature: Feature) {
    const [x, y] = (feature.getGeometry() as Point).getCoordinates();
    const [lon, lat] = this.mapComponent
      .projectionTo<Point>(Projections.EPSG4326_WGS84)(new Point([x, y]))
      .getCoordinates();
    return <LsPosition>{
      longitude: lon,
      latitude: lat,
    };
  }

  /**
   * Deserialize the boundary from a feature.
   * @param feature feature representing a boundary
   * @returns deserialized boundary polygon
   */
  getBoundary(feature: Feature) {
    return this.geoJSONReader.writeFeatureObject(feature)['geometry'] as LsBoundary;
  }

  protected togglePositionInteractionMode(event: KeyboardEvent) {
    if (!!this.positionInteractionMode && this.positionInteractionMode.isActive) {
      const enable = event.altKey && !event.ctrlKey && !event.shiftKey && event.code === 'AltLeft';
      if (enable !== this.positionInteractionMode.isEnabled) {
        this.positionInteractionMode.isEnabled = !this.positionInteractionMode.isEnabled;
      }
      this.eatEvent(event);
    }
  }

  protected eatEvent(event: KeyboardEvent) {
    event.preventDefault();
    event.stopPropagation();
  }

  protected initDrawLayer(): void {
    // Draw Layer
    this.locationVectorSource = this.openLayersLayerFactory.createVectorSource();
    this.mapComponent.addLayer(
      this.openLayersLayerFactory.createVectorLayer(this.locationVectorSource, 10, <any>{
        style: this.locationStyleService.locationStyleDefault,
        declutter: false,
      })
    );
  }

  protected initEditModes() {
    // SET POSITION
    this.addPositionInteractionMode = new DrawInteractionMode({
      source: this.locationVectorSource,
      type: GeometryType.POINT,
    });
    this.positionInteractionMode = new DrawInteractionMode({
      source: this.locationVectorSource,
      type: GeometryType.POINT,
      stopClick: true,
    });
    this.positionInteractionMode.isEnabled = false;

    // Apply position modifications
    merge(this.positionInteractionMode.created, this.addPositionInteractionMode.created)
      .pipe(takeUntil(this.ngUnsubscribe))
      .subscribe((event: DrawEvent) => {
        this.setCurrentPosition(this.getPosition(event.feature));
        this.clearDrawLayer();
        this.locationMapService.leaveDrawMode();
      });

    // TODO: make this configurable and dynamic
    this.snapInteractionMode = new SnapInteractionMode([{ source: this.locationVectorSource }]);

    // DRAW BOUNDARY
    this.addBoundaryInteractionMode = new DrawInteractionMode({
      source: this.locationVectorSource,
      type: GeometryType.POLYGON,
    }).extends(this.snapInteractionMode);
    this.addBoundaryInteractionMode.extends(
      new PolyGuidesInteractionMode(this.addBoundaryInteractionMode.drawInteraction)
    );

    // Apply boundary creation
    this.addBoundaryInteractionMode.created.pipe(takeUntil(this.ngUnsubscribe)).subscribe((event: DrawEvent) => {
      this.setCurrentBoundary(this.getBoundary(event.feature));
      this.clearDrawLayer();
      this.locationMapService.leaveDrawMode();
    });

    // EDIT & MOVE
    const selectionInteractionMode = this.selectionInteractionModeSupplier();
    this.editInteractionMode = new ModificationInteractionMode(selectionInteractionMode.selectedFeaturesCollection)
      .extends(selectionInteractionMode)
      .extends(this.positionInteractionMode);
    this.moveInteractionMode = new TranslationInteractionMode(
      selectionInteractionMode.selectedFeaturesCollection
    ).extends(selectionInteractionMode);

    // Apply modifications and translations
    merge(this.editInteractionMode.modified, this.moveInteractionMode.translated)
      .pipe(takeUntil(this.ngUnsubscribe), debounceTime(250))
      .subscribe((event: ModifyEvent | TranslateEvent) => {
        this.setCurrentGeometries(this.getLocationGeometries(event.features));
      });
  }

  protected clearDrawLayer() {
    setTimeout(() => this.locationVectorSource.clear()); // timeout to avoid zombie geometries on draw layer
  }
}
