import {
    combineLatest as observableCombineLatest,
    empty as observableEmpty,
    from as observableFrom,
    of as observableOf,
    zip as observableZip,
    Observable,
    Subject,
    Subscription,
} from "rxjs";

import {
    publish,
    finalize,
    startWith,
    publishReplay,
    refCount,
    map,
    distinctUntilChanged,
    switchMap,
    retry,
    catchError,
    scan,
    filter,
    withLatestFrom,
    first,
    timeout,
    bufferCount,
    mergeMap,
} from "rxjs/operators";

import { ILatLon } from "../API";
import { EdgeDirection } from "../Edge";
import {
    Graph,
    GraphCalculator,
    GraphMode,
    GraphService,
    IEdgeStatus,
    Node,
    Sequence,
} from "../Graph";
import {
    ICurrentState,
    IFrame,
    StateService,
    State,
} from "../State";
import SubscriptionHolder from "../utils/SubscriptionHolder";

export class PlayService {
    public static readonly sequenceSpeed: number = 0.54;

    private _graphService: GraphService;
    private _stateService: StateService;
    private _graphCalculator: GraphCalculator;

    private _nodesAhead: number;
    private _playing: boolean;
    private _speed: number;

    private _direction$: Observable<EdgeDirection>;
    private _directionSubject$: Subject<EdgeDirection>;
    private _playing$: Observable<boolean>;
    private _playingSubject$: Subject<boolean>;
    private _speed$: Observable<number>;
    private _speedSubject$: Subject<number>;

    private _playingSubscription: Subscription;
    private _cacheSubscription: Subscription;
    private _clearSubscription: Subscription;
    private _earthSubscription: Subscription;
    private _graphModeSubscription: Subscription;
    private _stopSubscription: Subscription;
    private _subscriptions: SubscriptionHolder = new SubscriptionHolder();

    private _bridging$: Observable<Node>;

    constructor(graphService: GraphService, stateService: StateService, graphCalculator?: GraphCalculator) {
        this._graphService = graphService;
        this._stateService = stateService;
        this._graphCalculator = !!graphCalculator ? graphCalculator : new GraphCalculator();

        const subs = this._subscriptions;

        this._directionSubject$ = new Subject<EdgeDirection>();
        this._direction$ = this._directionSubject$.pipe(
            startWith(EdgeDirection.Next),
            publishReplay(1),
            refCount());

        subs.push(this._direction$.subscribe());

        this._playing = false;
        this._playingSubject$ = new Subject<boolean>();
        this._playing$ = this._playingSubject$.pipe(
            startWith(this._playing),
            publishReplay(1),
            refCount());

        subs.push(this._playing$.subscribe());

        this._speed = 0.5;
        this._speedSubject$ = new Subject<number>();
        this._speed$ = this._speedSubject$.pipe(
            startWith(this._speed),
            publishReplay(1),
            refCount());

        subs.push(this._speed$.subscribe());

        this._nodesAhead = this._mapNodesAhead(this._mapSpeed(this._speed));

        this._bridging$ = null;
    }

    public get playing(): boolean {
        return this._playing;
    }

    public get direction$(): Observable<EdgeDirection> {
        return this._direction$;
    }

    public get playing$(): Observable<boolean> {
        return this._playing$;
    }

    public get speed$(): Observable<number> {
        return this._speed$;
    }

    public play(): void {
        if (this._playing) {
            return;
        }

        this._stateService.cutNodes();
        const stateSpeed: number = this._setSpeed(this._speed);
        this._stateService.setSpeed(stateSpeed);

        this._graphModeSubscription = this._speed$.pipe(
            map(
                (speed: number): GraphMode => {
                    return speed > PlayService.sequenceSpeed ? GraphMode.Sequence : GraphMode.Spatial;
                }),
            distinctUntilChanged())
            .subscribe(
                (mode: GraphMode): void => {
                    this._graphService.setGraphMode(mode);
                });

        this._cacheSubscription = observableCombineLatest(
            this._stateService.currentNode$.pipe(
                map(
                    (node: Node): [string, string] => {
                        return [node.sequenceKey, node.key];
                    }),
                distinctUntilChanged(
                    undefined,
                    ([sequenceKey, nodeKey]: [string, string]): string => {
                        return sequenceKey;
                    })),
            this._graphService.graphMode$,
            this._direction$).pipe(
                switchMap(
                    ([[sequenceKey, nodeKey], mode, direction]: [[string, string], GraphMode, EdgeDirection]):
                        Observable<[Sequence, EdgeDirection]> => {

                        if (direction !== EdgeDirection.Next && direction !== EdgeDirection.Prev) {
                            return observableOf<[Sequence, EdgeDirection]>([undefined, direction]);
                        }

                        const sequence$: Observable<Sequence> = (mode === GraphMode.Sequence ?
                            this._graphService.cacheSequenceNodes$(sequenceKey, nodeKey) :
                            this._graphService.cacheSequence$(sequenceKey)).pipe(
                                retry(3),
                                catchError(
                                    (error: Error): Observable<Sequence> => {
                                        console.error(error);

                                        return observableOf(undefined);
                                    }));

                        return observableCombineLatest(
                            sequence$,
                            observableOf(direction));
                    }),
                switchMap(
                    ([sequence, direction]: [Sequence, EdgeDirection]): Observable<string> => {
                        if (sequence === undefined) {
                            return observableEmpty();
                        }

                        const sequenceKeys: string[] = sequence.keys.slice();
                        if (direction === EdgeDirection.Prev) {
                            sequenceKeys.reverse();
                        }

                        return this._stateService.currentState$.pipe(
                            map(
                                (frame: IFrame): [string, number] => {
                                    return [frame.state.trajectory[frame.state.trajectory.length - 1].key, frame.state.nodesAhead];
                                }),
                            scan(
                                (
                                    [lastRequestKey, previousRequestKeys]: [string, string[]],
                                    [lastTrajectoryKey, nodesAhead]: [string, number]):
                                    [string, string[]] => {

                                    if (lastRequestKey === undefined) {
                                        lastRequestKey = lastTrajectoryKey;
                                    }

                                    const lastIndex: number = sequenceKeys.length - 1;
                                    if (nodesAhead >= this._nodesAhead || sequenceKeys[lastIndex] === lastRequestKey) {
                                        return [lastRequestKey, []];
                                    }

                                    const current: number = sequenceKeys.indexOf(lastTrajectoryKey);
                                    const start: number = sequenceKeys.indexOf(lastRequestKey) + 1;
                                    const end: number = Math.min(lastIndex, current + this._nodesAhead - nodesAhead) + 1;

                                    if (end <= start) {
                                        return [lastRequestKey, []];
                                    }

                                    return [sequenceKeys[end - 1], sequenceKeys.slice(start, end)];
                                },
                                [undefined, []]),
                            mergeMap(
                                ([lastRequestKey, newRequestKeys]: [string, string[]]): Observable<string> => {
                                    return observableFrom(newRequestKeys);
                                }));
                    }),
                mergeMap(
                    (key: string): Observable<Node> => {
                        return this._graphService.cacheNode$(key).pipe(
                            catchError(
                                (): Observable<Node> => {
                                    return observableEmpty();
                                }));
                    },
                    6))
            .subscribe();

        this._playingSubscription = this._stateService.currentState$.pipe(
            filter(
                (frame: IFrame): boolean => {
                    return frame.state.nodesAhead < this._nodesAhead;
                }),
            distinctUntilChanged(
                undefined,
                (frame: IFrame): string => {
                    return frame.state.lastNode.key;
                }),
            map(
                (frame: IFrame): [Node, boolean] => {
                    const lastNode: Node = frame.state.lastNode;
                    const trajectory: Node[] = frame.state.trajectory;
                    let increasingTime: boolean = undefined;

                    for (let i: number = trajectory.length - 2; i >= 0; i--) {
                        const node: Node = trajectory[i];
                        if (node.sequenceKey !== lastNode.sequenceKey) {
                            break;
                        }

                        if (node.capturedAt !== lastNode.capturedAt) {
                            increasingTime = node.capturedAt < lastNode.capturedAt;
                            break;
                        }
                    }

                    return [frame.state.lastNode, increasingTime];
                }),
            withLatestFrom(this._direction$),
            switchMap(
                ([[node, increasingTime], direction]: [[Node, boolean], EdgeDirection]): Observable<Node> => {
                    return observableZip(
                        ([EdgeDirection.Next, EdgeDirection.Prev].indexOf(direction) > -1 ?
                            node.sequenceEdges$ :
                            node.spatialEdges$).pipe(
                                first(
                                    (status: IEdgeStatus): boolean => {
                                        return status.cached;
                                    }),
                                timeout(15000)),
                        observableOf<EdgeDirection>(direction)).pipe(
                            map(
                                ([s, d]: [IEdgeStatus, EdgeDirection]): string => {
                                    for (let edge of s.edges) {
                                        if (edge.data.direction === d) {
                                            return edge.to;
                                        }
                                    }

                                    return null;
                                }),
                            switchMap(
                                (key: string): Observable<Node> => {
                                    return key != null ?
                                        this._graphService.cacheNode$(key) :
                                        this._bridge$(node, increasingTime).pipe(
                                            filter(
                                                (n: Node): boolean => {
                                                    return !!n;
                                                }));
                                }));
                }))
            .subscribe(
                (node: Node): void => {
                    this._stateService.appendNodes([node]);
                },
                (error: Error): void => {
                    console.error(error);
                    this.stop();
                });

        this._clearSubscription = this._stateService.currentNode$.pipe(
            bufferCount(1, 10))
            .subscribe(
                (nodes: Node[]): void => {
                    this._stateService.clearPriorNodes();
                });

        this._setPlaying(true);

        const currentLastNodes$: Observable<Node> = this._stateService.currentState$.pipe(
            map(
                (frame: IFrame): ICurrentState => {
                    return frame.state;
                }),
            distinctUntilChanged(
                ([kc1, kl1]: [string, string], [kc2, kl2]: [string, string]): boolean => {
                    return kc1 === kc2 && kl1 === kl2;
                },
                (state: ICurrentState): [string, string] => {
                    return [state.currentNode.key, state.lastNode.key];
                }),
            filter(
                (state: ICurrentState): boolean => {
                    return state.currentNode.key === state.lastNode.key &&
                        state.currentIndex === state.trajectory.length - 1;
                }),
            map(
                (state: ICurrentState): Node => {
                    return state.currentNode;
                }));

        this._stopSubscription = observableCombineLatest(
            currentLastNodes$,
            this._direction$).pipe(
                switchMap(
                    ([node, direction]: [Node, EdgeDirection]): Observable<boolean> => {
                        const edgeStatus$: Observable<IEdgeStatus> = (
                            [EdgeDirection.Next, EdgeDirection.Prev].indexOf(direction) > -1 ?
                                node.sequenceEdges$ :
                                node.spatialEdges$).pipe(
                                    first(
                                        (status: IEdgeStatus): boolean => {
                                            return status.cached;
                                        }),
                                    timeout(15000),
                                    catchError(
                                        (error: Error): Observable<IEdgeStatus> => {
                                            console.error(error);

                                            return observableOf<IEdgeStatus>({ cached: false, edges: [] });
                                        }));

                        return observableCombineLatest(
                            observableOf(direction),
                            edgeStatus$).pipe(
                                map(
                                    ([d, es]: [EdgeDirection, IEdgeStatus]): boolean => {
                                        for (const edge of es.edges) {
                                            if (edge.data.direction === d) {
                                                return true;
                                            }
                                        }

                                        return false;
                                    }));
                    }),
                mergeMap(
                    (hasEdge: boolean): Observable<boolean> => {
                        if (hasEdge || !this._bridging$) {
                            return observableOf(hasEdge);
                        }

                        return this._bridging$.pipe(
                            map(
                                (node: Node): boolean => {
                                    return node != null;
                                }),
                            catchError(
                                (error: Error): Observable<boolean> => {
                                    console.error(error);

                                    return observableOf<boolean>(false);
                                }));
                    }),
                first(
                    (hasEdge: boolean): boolean => {
                        return !hasEdge;
                    }))
            .subscribe(
                undefined,
                undefined,
                (): void => { this.stop(); });

        if (this._stopSubscription.closed) {
            this._stopSubscription = null;
        }

        this._earthSubscription = this._stateService.state$
            .pipe(
                map(
                    (state: State): boolean => {
                        return state === State.Earth;
                    }),
                distinctUntilChanged(),
                first(
                    (earth: boolean): boolean => {
                        return earth;
                    }))
            .subscribe(
                undefined,
                undefined,
                (): void => { this.stop(); });

        if (this._earthSubscription.closed) {
            this._earthSubscription = null;
        }
    }

    public dispose(): void {
        this.stop();
        this._subscriptions.unsubscribe();
    }

    public setDirection(direction: EdgeDirection): void {
        this._directionSubject$.next(direction);
    }

    public setSpeed(speed: number): void {
        speed = Math.max(0, Math.min(1, speed));
        if (speed === this._speed) {
            return;
        }

        const stateSpeed: number = this._setSpeed(speed);

        if (this._playing) {
            this._stateService.setSpeed(stateSpeed);
        }

        this._speedSubject$.next(this._speed);
    }

    public stop(): void {
        if (!this._playing) {
            return;
        }

        if (!!this._stopSubscription) {
            if (!this._stopSubscription.closed) {
                this._stopSubscription.unsubscribe();
            }

            this._stopSubscription = null;
        }

        if (!!this._earthSubscription) {
            if (!this._earthSubscription.closed) {
                this._earthSubscription.unsubscribe();
            }

            this._earthSubscription = null;
        }

        this._graphModeSubscription.unsubscribe();
        this._graphModeSubscription = null;

        this._cacheSubscription.unsubscribe();
        this._cacheSubscription = null;

        this._playingSubscription.unsubscribe();
        this._playingSubscription = null;

        this._clearSubscription.unsubscribe();
        this._clearSubscription = null;

        this._stateService.setSpeed(1);
        this._stateService.cutNodes();
        this._graphService.setGraphMode(GraphMode.Spatial);

        this._setPlaying(false);
    }

    private _bridge$(node: Node, increasingTime: boolean): Observable<Node> {
        if (increasingTime === undefined) {
            return observableOf(null);
        }

        const boundingBox: ILatLon[] = this._graphCalculator.boundingBoxCorners(node.latLon, 25);

        this._bridging$ = this._graphService.cacheBoundingBox$(boundingBox[0], boundingBox[1]).pipe(
            mergeMap(
                (nodes: Node[]): Observable<Node> => {
                    let nextNode: Node = null;
                    for (const n of nodes) {
                        if (n.sequenceKey === node.sequenceKey ||
                            !n.cameraUuid ||
                            n.cameraUuid !== node.cameraUuid ||
                            n.capturedAt === node.capturedAt ||
                            n.capturedAt > node.capturedAt !== increasingTime) {
                            continue;
                        }

                        const delta: number = Math.abs(n.capturedAt - node.capturedAt);

                        if (delta > 15000) {
                            continue;
                        }

                        if (!nextNode || delta < Math.abs(nextNode.capturedAt - node.capturedAt)) {
                            nextNode = n;
                        }
                    }

                    return !!nextNode ?
                        this._graphService.cacheNode$(nextNode.key) :
                        observableOf(null);
                }),
            finalize(
                (): void => {
                    this._bridging$ = null;
                }),
            publish(),
            refCount());

        return this._bridging$;
    }

    private _mapSpeed(speed: number): number {
        const x: number = 2 * speed - 1;

        return Math.pow(10, x) - 0.2 * x;
    }

    private _mapNodesAhead(stateSpeed: number): number {
        return Math.round(Math.max(10, Math.min(50, 8 + 6 * stateSpeed)));
    }

    private _setPlaying(playing: boolean): void {
        this._playing = playing;
        this._playingSubject$.next(playing);
    }

    private _setSpeed(speed: number): number {
        this._speed = speed;
        const stateSpeed: number = this._mapSpeed(this._speed);
        this._nodesAhead = this._mapNodesAhead(stateSpeed);

        return stateSpeed;
    }
}

export default PlayService;
