import {
    concat as observableConcat,
    merge as observableMerge,
    of as observableOf,
    combineLatest as observableCombineLatest,
    Observable,
    Subscription,
} from "rxjs";

import {
    startWith,
    withLatestFrom,
    skip,
    first,
    publishReplay,
    pairwise,
    switchMap,
    refCount,
    distinctUntilChanged,
    map,
} from "rxjs/operators";

import * as THREE from "three";
import * as when from "when";

import { ILatLon } from "../../API";
import {
    IMarkerConfiguration,
    IMarkerEvent,
    Marker,
    MarkerScene,
    MarkerSet,
    ComponentService,
    Component,
} from "../../Component";
import { IFrame } from "../../State";
import {
    Container,
    Navigator,
} from "../../Viewer";
import {
    IGLRenderHash,
    GLRenderStage,
    RenderCamera,
} from "../../Render";
import {
    GraphCalculator,
    Node,
} from "../../Graph";
import {
    GeoCoords,
    ILatLonAlt,
    ViewportCoords,
} from "../../Geo";

/**
 * @class MarkerComponent
 *
 * @classdesc Component for showing and editing 3D marker objects.
 *
 * The `add` method is used for adding new markers or replacing
 * markers already in the set.
 *
 * If a marker already in the set has the same
 * id as one of the markers added, the old marker will be removed and
 * the added marker will take its place.
 *
 * It is not possible to update markers in the set by updating any properties
 * directly on the marker object. Markers need to be replaced by
 * re-adding them for updates to geographic position or configuration
 * to be reflected.
 *
 * Markers added to the marker component can be either interactive
 * or non-interactive. Different marker types define their behavior.
 * Markers with interaction support can be configured with options
 * to respond to dragging inside the viewer and be detected when
 * retrieving markers from pixel points with the `getMarkerIdAt` method.
 *
 * To retrive and use the marker component
 *
 * @example
 * ```
 * var viewer = new Mapillary.Viewer({ component: { marker: true }, ... });
 *
 * var markerComponent = viewer.getComponent("marker");
 * ```
 */
export class MarkerComponent extends Component<IMarkerConfiguration> {
    public static componentName: string = "marker";

    /**
     * Fired when the position of a marker is changed.
     * @event
     * @type {IMarkerEvent} markerEvent - Marker event data.
     * @example
     * ```
     * markerComponent.on("changed", function(e) {
     *     console.log(e.marker.id, e.marker.latLon);
     * });
     * ```
     */
    public static changed: string = "changed";

    /**
     * Fired when a marker drag interaction starts.
     * @event
     * @type {IMarkerEvent} markerEvent - Marker event data.
     * @example
     * ```
     * markerComponent.on("dragstart", function(e) {
     *     console.log(e.marker.id, e.marker.latLon);
     * });
     * ```
     */
    public static dragstart: string = "dragstart";

    /**
     * Fired when a marker drag interaction ends.
     * @event
     * @type {IMarkerEvent} markerEvent - Marker event data.
     * @example
     * ```
     * markerComponent.on("dragend", function(e) {
     *     console.log(e.marker.id, e.marker.latLon);
     * });
     * ```
     */
    public static dragend: string = "dragend";

    private _relativeGroundAltitude: number;

    private _geoCoords: GeoCoords;
    private _graphCalculator: GraphCalculator;
    private _markerScene: MarkerScene;
    private _markerSet: MarkerSet;
    private _viewportCoords: ViewportCoords;

    private _adjustHeightSubscription: Subscription;
    private _dragEventSubscription: Subscription;
    private _markersUpdatedSubscription: Subscription;
    private _mouseClaimSubscription: Subscription;
    private _referenceSubscription: Subscription;
    private _renderSubscription: Subscription;
    private _setChangedSubscription: Subscription;
    private _updateMarkerSubscription: Subscription;

    /** @ignore */
    constructor(name: string, container: Container, navigator: Navigator) {
        super(name, container, navigator);

        this._relativeGroundAltitude = -2;

        this._geoCoords = new GeoCoords();
        this._graphCalculator = new GraphCalculator();
        this._markerScene = new MarkerScene();
        this._markerSet = new MarkerSet();
        this._viewportCoords = new ViewportCoords();
    }

    /**
     * Add markers to the marker set or replace markers in the marker set.
     *
     * @description If a marker already in the set has the same
     * id as one of the markers added, the old marker will be removed
     * the added marker will take its place.
     *
     * Any marker inside the visible bounding bbox
     * will be initialized and placed in the viewer.
     *
     * @param {Array<Marker>} markers - Markers to add.
     *
     * @example ```markerComponent.add([marker1, marker2]);```
     */
    public add(markers: Marker[]): void {
        this._markerSet.add(markers);
    }

    /**
     * Returns the marker in the marker set with the specified id, or
     * undefined if the id matches no marker.
     *
     * @param {string} markerId - Id of the marker.
     *
     * @example ```var marker = markerComponent.get("markerId");```
     *
     */
    public get(markerId: string): Marker {
        return this._markerSet.get(markerId);
    }

    /**
     * Returns an array of all markers.
     *
     * @example ```var markers = markerComponent.getAll();```
     */
    public getAll(): Marker[] {
        return this._markerSet.getAll();
    }

    /**
     * Returns the id of the interactive marker closest to the current camera
     * position at the specified point.
     *
     * @description Notice that the pixelPoint argument requires x, y
     * coordinates from pixel space.
     *
     * With this function, you can use the coordinates provided by mouse
     * events to get information out of the marker component.
     *
     * If no interactive geometry of an interactive marker exist at the pixel
     * point, `null` will be returned.
     *
     * @param {Array<number>} pixelPoint - Pixel coordinates on the viewer element.
     * @returns {string} Id of the interactive marker closest to the camera. If no
     * interactive marker exist at the pixel point, `null` will be returned.
     *
     * @example
     * ```
     * markerComponent.getMarkerIdAt([100, 100])
     *     .then((markerId) => { console.log(markerId); });
     * ```
     */
    public getMarkerIdAt(pixelPoint: number[]): when.Promise<string> {
        return when.promise<string>((resolve: (value: string) => void, reject: (reason: Error) => void): void => {
            this._container.renderService.renderCamera$.pipe(
                first(),
                map(
                    (render: RenderCamera): string => {
                        const viewport: number[] = this._viewportCoords
                            .canvasToViewport(
                                pixelPoint[0],
                                pixelPoint[1],
                                this._container.container);

                        const id: string = this._markerScene.intersectObjects(viewport, render.perspective);

                        return id;
                    }))
                .subscribe(
                    (id: string): void => {
                        resolve(id);
                    },
                    (error: Error): void => {
                        reject(error);
                    });
        });
    }

    /**
     * Check if a marker exist in the marker set.
     *
     * @param {string} markerId - Id of the marker.
     *
     * @example ```var markerExists = markerComponent.has("markerId");```
     */
    public has(markerId: string): boolean {
        return this._markerSet.has(markerId);
    }

    /**
     * Remove markers with the specified ids from the marker set.
     *
     * @param {Array<string>} markerIds - Ids for markers to remove.
     *
     * @example ```markerComponent.remove(["id-1", "id-2"]);```
     */
    public remove(markerIds: string[]): void {
        this._markerSet.remove(markerIds);
    }

    /**
     * Remove all markers from the marker set.
     *
     * @example ```markerComponent.removeAll();```
     */
    public removeAll(): void {
        this._markerSet.removeAll();
    }

    protected _activate(): void {
        const groundAltitude$: Observable<number> = this._navigator.stateService.currentState$.pipe(
            map(
                (frame: IFrame): number => {
                    return frame.state.camera.position.z + this._relativeGroundAltitude;
                }),
            distinctUntilChanged(
                (a1: number, a2: number): boolean => {
                    return Math.abs(a1 - a2) < 0.01;
                }),
            publishReplay(1),
            refCount());

        const geoInitiated$: Observable<void> = observableCombineLatest(
            groundAltitude$,
            this._navigator.stateService.reference$).pipe(
                first(),
                map((): void => { /* noop */ }),
                publishReplay(1),
                refCount());

        const clampedConfiguration$: Observable<IMarkerConfiguration> = this._configuration$.pipe(
            map(
                (configuration: IMarkerConfiguration): IMarkerConfiguration => {
                    return { visibleBBoxSize: Math.max(1, Math.min(200, configuration.visibleBBoxSize)) };
                }));

        const currentlatLon$: Observable<ILatLon> = this._navigator.stateService.currentNode$.pipe(
            map((node: Node): ILatLon => { return node.latLon; }),
            publishReplay(1),
            refCount());

        const visibleBBox$: Observable<[ILatLon, ILatLon]> = observableCombineLatest(
            clampedConfiguration$,
            currentlatLon$).pipe(
                map(
                    ([configuration, latLon]: [IMarkerConfiguration, ILatLon]): [ILatLon, ILatLon] => {
                        return this._graphCalculator
                            .boundingBoxCorners(latLon, configuration.visibleBBoxSize / 2);
                    }),
                publishReplay(1),
                refCount());

        const visibleMarkers$: Observable<Marker[]> = observableCombineLatest(
            observableConcat(
                observableOf<MarkerSet>(this._markerSet),
                this._markerSet.changed$),
            visibleBBox$).pipe(
                map(
                    ([set, bbox]: [MarkerSet, [ILatLon, ILatLon]]): Marker[] => {
                        return set.search(bbox);
                    }));

        this._setChangedSubscription = geoInitiated$.pipe(
            switchMap(
                (): Observable<[Marker[], ILatLonAlt, number]> => {
                    return visibleMarkers$.pipe(
                        withLatestFrom(
                            this._navigator.stateService.reference$,
                            groundAltitude$));
                }))
            .subscribe(
                ([markers, reference, alt]: [Marker[], ILatLonAlt, number]): void => {
                    const geoCoords: GeoCoords = this._geoCoords;
                    const markerScene: MarkerScene = this._markerScene;
                    const sceneMarkers: { [id: string]: Marker } = markerScene.markers;
                    const markersToRemove: { [id: string]: Marker } = Object.assign({}, sceneMarkers);

                    for (const marker of markers) {
                        if (marker.id in sceneMarkers) {
                            delete markersToRemove[marker.id];
                        } else {
                            const point3d: number[] = geoCoords
                                .geodeticToEnu(
                                    marker.latLon.lat,
                                    marker.latLon.lon,
                                    reference.alt + alt,
                                    reference.lat,
                                    reference.lon,
                                    reference.alt);

                            markerScene.add(marker, point3d);
                        }
                    }

                    for (const id in markersToRemove) {
                        if (!markersToRemove.hasOwnProperty(id)) {
                            continue;
                        }

                        markerScene.remove(id);
                    }
                });

        this._markersUpdatedSubscription = geoInitiated$.pipe(
            switchMap(
                (): Observable<[Marker[], [ILatLon, ILatLon], ILatLonAlt, number]> => {
                    return this._markerSet.updated$.pipe(
                        withLatestFrom(
                            visibleBBox$,
                            this._navigator.stateService.reference$,
                            groundAltitude$));
                }))
            .subscribe(
                ([markers, [sw, ne], reference, alt]: [Marker[], [ILatLon, ILatLon], ILatLonAlt, number]): void => {
                    const geoCoords: GeoCoords = this._geoCoords;
                    const markerScene: MarkerScene = this._markerScene;

                    for (const marker of markers) {
                        const exists: boolean = markerScene.has(marker.id);
                        const visible: boolean = marker.latLon.lat > sw.lat &&
                            marker.latLon.lat < ne.lat &&
                            marker.latLon.lon > sw.lon &&
                            marker.latLon.lon < ne.lon;

                        if (visible) {
                            const point3d: number[] = geoCoords
                                .geodeticToEnu(
                                    marker.latLon.lat,
                                    marker.latLon.lon,
                                    reference.alt + alt,
                                    reference.lat,
                                    reference.lon,
                                    reference.alt);

                            markerScene.add(marker, point3d);
                        } else if (!visible && exists) {
                            markerScene.remove(marker.id);
                        }
                    }
                });

        this._referenceSubscription = this._navigator.stateService.reference$.pipe(
            skip(1),
            withLatestFrom(groundAltitude$))
            .subscribe(
                ([reference, alt]: [ILatLonAlt, number]): void => {
                    const geoCoords: GeoCoords = this._geoCoords;
                    const markerScene: MarkerScene = this._markerScene;

                    for (const marker of markerScene.getAll()) {
                        const point3d: number[] = geoCoords
                            .geodeticToEnu(
                                marker.latLon.lat,
                                marker.latLon.lon,
                                reference.alt + alt,
                                reference.lat,
                                reference.lon,
                                reference.alt);

                        markerScene.update(marker.id, point3d);
                    }
                });

        this._adjustHeightSubscription = groundAltitude$.pipe(
            skip(1),
            withLatestFrom(
                this._navigator.stateService.reference$,
                currentlatLon$))
            .subscribe(
                ([alt, reference, latLon]: [number, ILatLonAlt, ILatLon]): void => {
                    const geoCoords: GeoCoords = this._geoCoords;
                    const markerScene: MarkerScene = this._markerScene;

                    const position: number[] = geoCoords
                        .geodeticToEnu(
                            latLon.lat,
                            latLon.lon,
                            reference.alt + alt,
                            reference.lat,
                            reference.lon,
                            reference.alt);

                    for (const marker of markerScene.getAll()) {
                        const point3d: number[] = geoCoords
                            .geodeticToEnu(
                                marker.latLon.lat,
                                marker.latLon.lon,
                                reference.alt + alt,
                                reference.lat,
                                reference.lon,
                                reference.alt);

                        const distanceX: number = point3d[0] - position[0];
                        const distanceY: number = point3d[1] - position[1];

                        const groundDistance: number = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
                        if (groundDistance > 50) {
                            continue;
                        }

                        markerScene.lerpAltitude(marker.id, alt, Math.min(1, Math.max(0, 1.2 - 1.2 * groundDistance / 50)));
                    }
                });

        this._renderSubscription = this._navigator.stateService.currentState$.pipe(
            map(
                (frame: IFrame): IGLRenderHash => {
                    const scene: MarkerScene = this._markerScene;

                    return {
                        name: this._name,
                        render: {
                            frameId: frame.id,
                            needsRender: scene.needsRender,
                            render: scene.render.bind(scene),
                            stage: GLRenderStage.Foreground,
                        },
                    };
                }))
            .subscribe(this._container.glRenderer.render$);

        const hoveredMarkerId$: Observable<string> = observableCombineLatest(
            this._container.renderService.renderCamera$,
            this._container.mouseService.mouseMove$).pipe(
                map(
                    ([render, event]: [RenderCamera, MouseEvent]): string => {
                        const element: HTMLElement = this._container.container;
                        const [canvasX, canvasY]: number[] = this._viewportCoords.canvasPosition(event, element);
                        const viewport: number[] = this._viewportCoords.canvasToViewport(
                            canvasX,
                            canvasY,
                            element);

                        const markerId: string = this._markerScene.intersectObjects(viewport, render.perspective);

                        return markerId;
                    }),
                publishReplay(1),
                refCount());

        const draggingStarted$: Observable<boolean> =
            this._container.mouseService
                .filtered$(this._name, this._container.mouseService.mouseDragStart$).pipe(
                    map(
                        (event: MouseEvent): boolean => {
                            return true;
                        }));

        const draggingStopped$: Observable<boolean> =
            this._container.mouseService
                .filtered$(this._name, this._container.mouseService.mouseDragEnd$).pipe(
                    map(
                        (event: Event): boolean => {
                            return false;
                        }));

        const filteredDragging$: Observable<boolean> = observableMerge(
            draggingStarted$,
            draggingStopped$).pipe(
                startWith(false));

        this._dragEventSubscription = observableMerge(
            draggingStarted$.pipe(
                withLatestFrom(hoveredMarkerId$)),
            observableCombineLatest(
                draggingStopped$,
                observableOf<string>(null))).pipe(
                    startWith<[boolean, string]>([false, null]),
                    pairwise())
            .subscribe(
                ([previous, current]: [boolean, string][]): void => {
                    const dragging: boolean = current[0];
                    const eventType: string = dragging ? MarkerComponent.dragstart : MarkerComponent.dragend;
                    const id: string = dragging ? current[1] : previous[1];
                    const marker: Marker = this._markerScene.get(id);
                    const markerEvent: IMarkerEvent = { marker: marker, target: this, type: eventType };

                    this.fire(eventType, markerEvent);
                });

        const mouseDown$: Observable<boolean> = observableMerge(
            this._container.mouseService.mouseDown$.pipe(
                map((event: MouseEvent): boolean => { return true; })),
            this._container.mouseService.documentMouseUp$.pipe(
                map((event: MouseEvent): boolean => { return false; }))).pipe(
                    startWith(false));

        this._mouseClaimSubscription = observableCombineLatest(
            this._container.mouseService.active$,
            hoveredMarkerId$.pipe(distinctUntilChanged()),
            mouseDown$,
            filteredDragging$).pipe(
                map(
                    ([active, markerId, mouseDown, filteredDragging]: [boolean, string, boolean, boolean]): boolean => {
                        return (!active && markerId != null && mouseDown) || filteredDragging;
                    }),
                distinctUntilChanged())
            .subscribe(
                (claim: boolean): void => {
                    if (claim) {
                        this._container.mouseService.claimMouse(this._name, 1);
                        this._container.mouseService.claimWheel(this._name, 1);
                    } else {
                        this._container.mouseService.unclaimMouse(this._name);
                        this._container.mouseService.unclaimWheel(this._name);
                    }
                });

        const offset$: Observable<[Marker, number[], RenderCamera]> = this._container.mouseService
            .filtered$(this._name, this._container.mouseService.mouseDragStart$).pipe(
                withLatestFrom(
                    hoveredMarkerId$,
                    this._container.renderService.renderCamera$),
                map(
                    ([e, id, r]: [MouseEvent, string, RenderCamera]): [Marker, number[], RenderCamera] => {
                        const marker: Marker = this._markerScene.get(id);
                        const element: HTMLElement = this._container.container;

                        const [groundCanvasX, groundCanvasY]: number[] =
                            this._viewportCoords.projectToCanvas(
                                marker.geometry.position.toArray(),
                                element,
                                r.perspective);

                        const [canvasX, canvasY]: number[] = this._viewportCoords.canvasPosition(e, element);

                        const offset: number[] = [canvasX - groundCanvasX, canvasY - groundCanvasY];

                        return [marker, offset, r];
                    }),
                publishReplay(1),
                refCount());

        this._updateMarkerSubscription = this._container.mouseService
            .filtered$(this._name, this._container.mouseService.mouseDrag$).pipe(
                withLatestFrom(
                    offset$,
                    this._navigator.stateService.reference$,
                    clampedConfiguration$))
            .subscribe(
                ([event, [marker, offset, render], reference, configuration]:
                    [MouseEvent, [Marker, number[], RenderCamera], ILatLonAlt, IMarkerConfiguration]): void => {
                    if (!this._markerScene.has(marker.id)) {
                        return;
                    }

                    const element: HTMLElement = this._container.container;
                    const [canvasX, canvasY]: number[] = this._viewportCoords.canvasPosition(event, element);

                    const groundX: number = canvasX - offset[0];
                    const groundY: number = canvasY - offset[1];

                    const [viewportX, viewportY]: number[] = this._viewportCoords
                        .canvasToViewport(
                            groundX,
                            groundY,
                            element);

                    const direction: THREE.Vector3 = new THREE.Vector3(viewportX, viewportY, 1)
                        .unproject(render.perspective)
                        .sub(render.perspective.position)
                        .normalize();

                    const distance: number = Math.min(
                        this._relativeGroundAltitude / direction.z,
                        configuration.visibleBBoxSize / 2 - 0.1);

                    if (distance < 0) {
                        return;
                    }

                    const intersection: THREE.Vector3 = direction
                        .clone()
                        .multiplyScalar(distance)
                        .add(render.perspective.position);

                    intersection.z = render.perspective.position.z + this._relativeGroundAltitude;

                    const [lat, lon]: number[] = this._geoCoords
                        .enuToGeodetic(
                            intersection.x,
                            intersection.y,
                            intersection.z,
                            reference.lat,
                            reference.lon,
                            reference.alt);

                    this._markerScene.update(marker.id, intersection.toArray(), { lat: lat, lon: lon });
                    this._markerSet.update(marker);

                    const markerEvent: IMarkerEvent = { marker: marker, target: this, type: MarkerComponent.changed };
                    this.fire(MarkerComponent.changed, markerEvent);
                });
    }

    protected _deactivate(): void {
        this._adjustHeightSubscription.unsubscribe();
        this._dragEventSubscription.unsubscribe();
        this._markersUpdatedSubscription.unsubscribe();
        this._mouseClaimSubscription.unsubscribe();
        this._referenceSubscription.unsubscribe();
        this._renderSubscription.unsubscribe();
        this._setChangedSubscription.unsubscribe();
        this._updateMarkerSubscription.unsubscribe();

        this._markerScene.clear();
    }

    protected _getDefaultConfiguration(): IMarkerConfiguration {
        return { visibleBBoxSize: 100 };
    }
}

ComponentService.register(MarkerComponent);
export default MarkerComponent;
