/* -*- Mode: JS2; indent-tabs-mode: nil; js2-basic-offset: 4 -*- */
/* vim: set et ts=4 sw=4: */
/*
 * Copyright (c) 2019 Marcus Lundblad
 *
 * GNOME Maps is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by the
 * Free Software Foundation; either version 2 of the License, or (at your
 * option) any later version.
 *
 * GNOME Maps is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with GNOME Maps; if not, see <http://www.gnu.org/licenses/>.
 *
 * Author: Marcus Lundblad <ml@update.uu.se>
 */

/**
 * This module implements a transit routing plugin for the Swedish national
 * Resrobot transit journey planning API.
 *
 * API docs for Resrobot can be found at:
 * https://www.trafiklab.se/api/resrobot-reseplanerare/dokumentation/sokresa
 */

const Champlain = imports.gi.Champlain;
const GLib = imports.gi.GLib;
const Soup = imports.gi.Soup;

const Application = imports.application;
const GraphHopperTransit = imports.graphHopperTransit;
const HTTP = imports.http;
const HVT = imports.hvt;
const TransitPlan = imports.transitPlan;
const TransitTweaks = imports.transitTweaks;
const Utils = imports.utils;

const BASE_URL = 'https://api.resrobot.se';
const API_VERSION = 'v2';

// Timezone for timestamps returned by this provider
const NATIVE_TIMEZONE = 'Europe/Stockholm';

const ISO_8601_DURATION_REGEXP = new RegExp(/P((\d+)D)?T((\d+)H)?((\d+)M)?/);

const Products = {
    EXPRESS_TRAIN:  2,
    REGIONAL_TRAIN: 4,
    EXPRESS_BUS:    8,
    LOCAL_TRAIN:    16,
    SUBWAY:         32,
    TRAM:           64,
    BUS:            128,
    FERRY:          256,
    TAXI:           512
};

const LegType = {
    WALK:     'WALK',
    TRANSIT:  'JNY',
    TRANSFER: 'TRSF'
};

const CatCode = {
    EXPRESS_TRAIN:  1,
    REGIONAL_TRAIN: 2,
    EXPRESS_BUS:    3,
    LOCAL_TRAIN:    4,
    SUBWAY:         5,
    TRAM:           6,
    BUS:            7,
    FERRY:          8,
    TAXI:           9
};

const MAX_NUM_NEARBY_STOPS = 5;
const NEARBY_STOPS_SEARCH_RADIUS = 500;

// ignore walking legs at the beginning/end when below this distance
const DISTANCE_THREASHOLD_TO_IGNORE = 50;

// search radius to search for walk-only journeys
const WALK_SEARCH_RADIUS = 2000;

// maximum distance for walk-only journey
const MAX_WALK_ONLY_DISTANCE = 2500;

var Resrobot = class Resrobot {
    constructor(params) {
        this._session = new Soup.Session({ user_agent : 'gnome-maps/' + pkg.version });
        this._plan = Application.routingDelegator.transitRouter.plan;
        this._query = Application.routeQuery;
        this._key = params.key;
        this._tz = GLib.TimeZone.new(NATIVE_TIMEZONE);
        this._tweaks = new TransitTweaks.TransitTweaks({ name: 'resrobot' });

        if (!this._key)
            throw new Error('missing key');
    }

    fetchFirstResults() {
        let filledPoints = this._query.filledPoints;

        this._extendPrevious = false;
        this._viaId = null;

        if (filledPoints.length > 3) {
            Utils.debug('This plugin supports at most one via location');
            this._plan.reset();
            this._plan.requestFailed();
            this._query.reset();
        } else if (filledPoints.length === 2) {
            this._fetchResults();
        } else {
            let lat = filledPoints[1].place.location.latitude;
            let lon = filledPoints[1].place.location.longitude;

            this._fetchNearbyStops(lat, lon, MAX_NUM_NEARBY_STOPS,
                                   NEARBY_STOPS_SEARCH_RADIUS,
                                   () => this._fetchResults());
        }
    }

    fetchMoreResults() {
        this._extendPrevious = true;

        if ((!this._scrF && !this._query.arriveBy) ||
            (!this._scrB && this._query.arriveBy))
            this._noRouteFound();
        else
            this._fetchResults();
    }

    _fetchNearbyStops(lat, lon, num, radius, callback) {
        let query = new HTTP.Query(this._getNearbyStopsQueryParams(lat, lon,
                                                                   num, radius));
        let uri = new Soup.URI(BASE_URL + '/' + API_VERSION +
                               '/location.nearbystops?' + query.toString());
        let request = new Soup.Message({ method: 'GET', uri: uri });

        this._session.queue_message(request, (obj, message) => {
            if (message.status_code !== Soup.Status.OK) {
                Utils.debug('Failed to get nearby stops: ' + message.status_code);
                this._noRouteFound();
            } else {
                try {
                    let result = JSON.parse(message.response_body.data);
                    let stopLocations = result.StopLocation;

                    Utils.debug('nearby stops: ' + JSON.stringify(result, null, 2));

                    if (stopLocations && stopLocations.length > 0) {
                        let stopLocation = stopLocations[0];

                        this._viaId = stopLocation.id;
                        callback();
                    } else {
                        Utils.debug('No nearby stops found');
                        this._noRouteFound();
                    }
                } catch (e) {
                    Utils.debug('Error parsing result: ' + e);
                    this._plan.reset();
                    this._plan.requestFailed();
                }
            }
        });
    }

    _fetchResults() {
        let query = new HTTP.Query(this._getQueryParams());
        let uri = new Soup.URI(BASE_URL + '/' + API_VERSION + '/trip?' +
                               query.toString());
        let request = new Soup.Message({ method: 'GET', uri: uri });

        this._session.queue_message(request, (obj, message) => {
            if (message.status_code !== Soup.Status.OK) {
                Utils.debug('Failed to get trip: ' + message.status_code);
                /* No routes found. If this is the first search
                 * (not "load more") and the distance is short
                 * enough, generate a walk-only itinerary
                 */
                let [start, end, distance] =
                    this._getAsTheCrowFliesPointsAndDistanceForQuery();

                if (!this._extendPrevious &&
                    distance <= WALK_SEARCH_RADIUS) {
                    GraphHopperTransit.fetchWalkingRoute([start, end], (route) => {
                        if (route && route.distance <= MAX_WALK_ONLY_DISTANCE) {
                            let walkingItinerary =
                                this._createWalkingOnlyItinerary(start,
                                                                 end,
                                                                 route);
                            this._plan.updateWithNewItineraries([walkingItinerary]);
                        } else {
                            this._noRouteFound();
                        }
                    });
                } else {
                    this._noRouteFound();
                }
            } else {
                try {
                    let result = JSON.parse(message.response_body.data);

                    Utils.debug('result: ' + JSON.stringify(result, null, 2));
                    if (result.Trip) {
                        let itineraries = this._createItineraries(result.Trip);

                        // store the back and forward references from the result
                        this._scrB = result.scrB;
                        this._scrF = result.scrF;
                        this._tweaks.applyTweaks(itineraries, () => {
                            this._processItineraries(itineraries)
                        });
                    } else {
                        this._noRouteFound();
                    }
                } catch (e) {
                    Utils.debug('Error parsing result: ' + e);
                    this._plan.reset();
                    this._plan.requestFailed();
                }
            }
        });
    }

    /* get total "as the crow flies" start, and end points, and distance for
     * the query
     */
    _getAsTheCrowFliesPointsAndDistanceForQuery() {
        let start = this._query.filledPoints[0];
        let end = this._query.filledPoints.last();
        let startLoc = start.place.location;
        let endLoc = end.place.location;

        return [start, end, endLoc.get_distance_from(startLoc) * 1000];
    }

    _processItineraries(itineraries) {
        /* if this is the first request, and the distance is short enough,
         * add an additional walking-only itinerary at the beginning
         */
        let [start, end, distance] =
            this._getAsTheCrowFliesPointsAndDistanceForQuery();

        if (!this._extendPrevious && distance <= WALK_SEARCH_RADIUS) {
            GraphHopperTransit.fetchWalkingRoute([start, end], (route) => {
                if (route && route.distance <= MAX_WALK_ONLY_DISTANCE) {
                    let walkingItinerary =
                        this._createWalkingOnlyItinerary(start, end, route);

                    itineraries.unshift(walkingItinerary);
                }
                GraphHopperTransit.addWalkingToItineraries(itineraries,
                    () => this._plan.updateWithNewItineraries(itineraries,
                                                              this._query.arriveBy,
                                                              this._extendPrevious));
            });
        } else {
            GraphHopperTransit.addWalkingToItineraries(itineraries,
                () => this._plan.updateWithNewItineraries(itineraries,
                                                          this._query.arriveBy,
                                                          this._extendPrevious));
        }
    }

    _createWalkingOnlyItinerary(start, end, route) {
        let walkingLeg = GraphHopperTransit.createWalkingLeg(start, end,
                                                             start.place.name,
                                                             end.place.name,
                                                             route);
        let duration = route.duration;
        /* if the query has no date, just use a fake, since only the time
         * is relevant for displaying in this case
         */
        let date = this._query.date || '2019-01-01';
        let time = this._query.time + ':00';

        let [timestamp, tzOffset] =
            this._query.time ? this._parseTime(time, date) :
                               this._getTimestampAndTzOffsetNow();

        if (this._query.arriveBy) {
            walkingLeg.arrival = timestamp;
            walkingLeg.departure = timestamp - route.time;
        } else {
            walkingLeg.departure = timestamp;
            walkingLeg.arrival = timestamp + route.time;
        }

        walkingLeg.agencyTimezoneOffset = tzOffset;

        let walkingItinerary =
            new TransitPlan.Itinerary({ legs: [walkingLeg]} );

        walkingItinerary.adjustTimings();

        return walkingItinerary;
    }

    _reset() {
        if (this._query.latest)
            this._query.latest.place = null;
        else
            this._plan.reset();
    }

    /* Indicate that no routes where found, either shows the "No route found"
     * message, or in case of loading additional (later/earlier) results,
     * indicate no such where found, so that the sidebar can disable the
     * "load more" functionallity as appropriate.
     */
    _noRouteFound() {
        if (this._extendPrevious) {
            this._plan.noMoreResults();
        } else {
            this._reset();
            this._plan.noRouteFound();
        }
    }

    _createItineraries(trips) {
        return trips.map((trip) => this._createItinerary(trip));
    }

    _createItinerary(trip) {
        let legs = this._createLegs(trip.LegList.Leg);
        let duration = this._parseDuration(trip.duration);
        let origin = trip.LegList.Leg[0].Origin;
        let destination = trip.LegList.Leg.last().Destination;
        let [startTime,] = this._parseTime(origin.time, origin.date);
        let [endTime,] = this._parseTime(destination.time, destination.date);

        return new TransitPlan.Itinerary({ duration:  duration,
                                           departure: startTime,
                                           arrival:   endTime,
                                           legs:      legs,
                                           duration:  duration });
    }

    /**
     * Parse a time and date string into a timestamp into an array with
     * an absolute timestamp in ms since Unix epoch and a timezone offset
     * for the provider's native timezone at the given time and date
     */
    _parseTime(time, date) {
        let timeText = '%sT%s'.format(date, time);
        let dateTime = GLib.DateTime.new_from_iso8601(timeText, this._tz);

        return [dateTime.to_unix() * 1000, dateTime.get_utc_offset() / 1000];
    }

    /**
     * Get absolute timestamp for "now" in ms and timezone offset in the
     * native timezone of the provider's native timezone @ "now"
     */
    _getTimestampAndTzOffsetNow() {
        let dateTime = GLib.DateTime.new_now(this._tz);

        return [dateTime.to_unix() * 1000, dateTime.get_utc_offset() / 1000];
    }

    /**
     * Parse a subset of ISO 8601 duration expressions.
     * Handle hour and minute parts
     */
    _parseDuration(duration) {
        let match = duration.match(ISO_8601_DURATION_REGEXP);

        if (match) {
            let [,,d,,h,,min] = match;

            return (d || 0) * 86400 + (h || 0) * 3600 + (min || 0) * 60;
        } else {
            Utils.debug('Unknown duration: ' + duration);

            return -1;
        }
    }

    _createLegs(legs) {
        let result = legs.map((leg, index, legs) => this._createLeg(leg, index, legs));

        if (this._canLegBeIgnored(result[0]))
            result.shift();

        if (this._canLegBeIgnored(result.last()))
            result.splice(-1);

        return result;
    }

    /* determines if a leg can ignored at the start or end, to catch the
     * case when the user probably meant to search for a trip from a transit
     * stop anyway
     */
    _canLegBeIgnored(leg) {
        if (!leg.isTransit) {
            /* check that the distance is below the threashold and also that
             * the duration is below 1 min, since the API in some occasions
             * apparently gives distance 0, even though a walking leg has
             * longer duration, and spans a distance in coordinates.
             */
            return leg.distance <= DISTANCE_THREASHOLD_TO_IGNORE &&
                   leg.duration <= 60;
        } else {
            return false;
        }
    }

    _createLeg(leg, index, legs) {
        let isTransit;

        if (leg.type === LegType.TRANSIT)
            isTransit = true;
        else if (leg.type === LegType.WALK || leg.type === LegType.TRANSFER)
            isTransit = false;
        else
            throw new Error('Unknown leg type: ' + leg.type);

        let origin = leg.Origin;
        let destination = leg.Destination;
        let product = leg.Product;

        if (!origin)
            throw new Error('Missing Origin element');
        if (!destination)
            throw new Error('Missing Destination element');
        if (!product && isTransit)
            throw new Error('Missing Product element for transit leg');

        let first = index === 0;
        let last = index === legs.length - 1;
        /* for walking legs in the beginning or end, use the name from the
         * query, so we get the names of the place the user searched for in
         * the results, when starting/ending at a transitstop, use the stop
         * name
         */
        let from =
            first && !isTransit ? this._query.filledPoints[0].place.name :
                                  origin.name;
        let to =
            last && !isTransit ? this._query.filledPoints.last().place.name :
                                 destination.name;
        let [departure, tzOffset] = this._parseTime(origin.time, origin.date);
        let [arrival,] = this._parseTime(destination.time, destination.date);
        let route = isTransit ? product.num : null;
        let routeType =
            isTransit ? this._getHVTCodeFromCatCode(product.catCode) : null;
        let agencyName = isTransit ? product.operator : null;
        let agencyUrl = isTransit ? product.operatorUrl : null;
        let polyline = this._createPolylineForLeg(leg);
        let duration = leg.duration ? this._parseDuration(leg.duration) : null;

        let result = new TransitPlan.Leg({ departure:            departure,
                                           arrival:              arrival,
                                           from:                 from,
                                           to:                   to,
                                           headsign:             leg.direction,
                                           fromCoordinate:       [origin.lat,
                                                                  origin.lon],
                                           toCoordinate:         [destination.lat,
                                                                  destination.lon],
                                           route:                route,
                                           routeType:            routeType,
                                           polyline:             polyline,
                                           isTransit:            isTransit,
                                           distance:             leg.dist,
                                           duration:             duration,
                                           agencyName:           agencyName,
                                           agencyUrl:            agencyUrl,
                                           agencyTimezoneOffset: tzOffset,
                                           tripShortName:        route });

        if (isTransit)
            result.intermediateStops = this._createIntermediateStops(leg);

        return result;
    }

    _createPolylineForLeg(leg) {
        let polyline;

        if (leg.Stops && leg.Stops.Stop) {
            polyline = [];

            leg.Stops.Stop.forEach((stop) => {
                polyline.push(new Champlain.Coordinate({ latitude:  stop.lat,
                                                         longitude: stop.lon }));
            });
        } else {
            polyline =
                [new Champlain.Coordinate({ latitude:  leg.Origin.lat,
                                            longitude: leg.Origin.lon }),
                 new Champlain.Coordinate({ latitude:  leg.Destination.lat,
                                            longitude: leg.Destination.lon })];
        }

        return polyline;
    }

    _createIntermediateStops(leg) {
        let result = [];

        if (!leg.Stops && !leg.Stops.Stop)
            throw new Error('Missing Stops element');

        leg.Stops.Stop.forEach((stop, index) => {
            if (index !== 0)
                result.push(this._createIntermediateStop(stop));
        });

        return result;
    }

    _createIntermediateStop(stop) {
        let [departure, departureTzOffset] = [,];
        let [arrival, arrivalTzOffset] = [,];

        if (stop.depTime && stop.depDate)
            [departure, departureTzOffset] = this._parseTime(stop.depTime, stop.depDate);
        if (stop.arrTime && stop.arrDate)
            [arrival, arrivalTzOffset] = this._parseTime(stop.arrTime, stop.arrDate);

        if (!arrival)
            arrival = departure;
        if (!departure)
            departure = arrival;

        return new TransitPlan.Stop({ name:                 stop.name,
                                      arrival:              arrival,
                                      departure:            departure,
                                      agencyTimezoneOffset: departureTzOffset || arrivalTzOffset,
                                      coordinate: [stop.lat, stop.lon] });
    }

    _getHVTCodeFromCatCode(code) {
        switch (parseInt(code)) {
            case CatCode.EXPRESS_TRAIN:
                return HVT.HIGH_SPEED_RAIL_SERVICE;
            case CatCode.REGIONAL_TRAIN:
                return HVT.REGIONAL_RAIL_SERVICE;
            case CatCode.EXPRESS_BUS:
                return HVT.EXPRESS_BUS_SERVICE;
            case CatCode.LOCAL_TRAIN:
                return HVT.SUBURBAN_RAILWAY_SERVICE;
            case CatCode.SUBWAY:
                return HVT.METRO_SERVICE;
            case CatCode.TRAM:
                return HVT.TRAM_SERVICE;
            case CatCode.BUS:
                return HVT.BUS_SERVICE;
            case CatCode.FERRY:
                return HVT.WATER_TRANSPORT_SERVICE;
            case CatCode.TAXI:
                return HVT.COMMUNAL_TAXI_SERVICE;
            default:
                Utils.debug('Unknown catCode: ' + code);
                return HVT.MISCELLANEOUS_SERVICE;
        }
    }

    _getQueryParams() {
        let points = this._query.filledPoints;
        let originLocation = points[0].place.location;
        let destLocation = points.last().place.location;
        let transitOptions = this._query.transitOptions;
        let params = { key:             this._key,
                       originCoordLat:  originLocation.latitude,
                       originCoordLong: originLocation.longitude,
                       destCoordLat:    destLocation.latitude,
                       destCoordLong:   destLocation.longitude,
                       format:          'json' };

        if (!transitOptions.showAllTransitTypes)
            params.products = this._getAllowedProductsForQuery();

        if (this._viaId)
            params.viaId = this._viaId;

        if (this._extendPrevious) {
            params.context = this._query.arriveBy ? this._scrB : this._scrF;
        } else  {
            if (this._query.arriveBy)
                params.searchForArrival = 1;

            if (this._query.time)
                params.time = this._query.time;

            if (this._query.date)
                params.date = this._query.date;
        }

        return params;
    }

    _getNearbyStopsQueryParams(lat, lon, num, radius) {
        let params = { key:             this._key,
                       originCoordLat:  lat,
                       originCoordLong: lon,
                       maxNo:           num,
                       r:               radius,
                       format:          'json' };

        return params;
    }

    _getAllowedProductsForQuery() {
        let products = 0;

        this._query.transitOptions.transitTypes.forEach((type) => {
            products += this._productCodeForTransitType(type);
        });

        return products;
    }

    _productCodeForTransitType(type) {
        switch (type) {
            case TransitPlan.RouteType.BUS:
                return Products.BUS + Products.EXPRESS_BUS + Products.TAXI;
            case TransitPlan.RouteType.TRAM:
                return Products.TRAM;
            case TransitPlan.RouteType.TRAIN:
                return Products.EXPRESS_TRAIN + Products.LOCAL_TRAIN;
            case TransitPlan.RouteType.SUBWAY:
                return Products.SUBWAY;
            case TransitPlan.RouteType.FERRY:
                return Products.FERRY;
            default:
                return 0;
        }
    }
}
