/* -*- Mode: JS2; indent-tabs-mode: nil; js2-basic-offset: 4 -*- */
/* vim: set et ts=4 sw=4: */
/*
 * Copyright (c) 2024 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@dfupdate.se>
 */

import gettext from 'gettext';

import Gdk from 'gi://Gdk';
import Gio from 'gi://Gio';
import GWeather from 'gi://GWeather';
import GLib from 'gi://GLib';
import Shumate from 'gi://Shumate';
import Soup from 'gi://Soup';

import {Arrival} from './transit/arrival.js';
import {Departure} from './transit/departure.js';
import * as Epaf from './epaf.js';
import * as HVT from './transit/hvt.js';
import {Itinerary} from './transit/itinerary.js';
import {Leg} from './transit/leg.js';
import {Location} from './location.js';
import {Place} from './place.js';
import {Query} from './http.js';
import {Route} from './transit/route.js';
import {RouteType} from './transit/routeType.js';
import {TransitPlace} from './transitPlace.js';
import {TurnPoint} from './route.js';
import * as Time from './time.js';
import {Stop} from './transit/stop.js';
import * as Utils from './utils.js';

const _ = gettext.gettext;

// number of stoptimes to request from the stoptimes endpoint
const NUM_STOP_TIMES = 10;

/* maximum matching distance for plan requests
 * using the same value as the default in the MOTIS web UI
 */
const MAX_MATCHING_DISTANCE = 250;

/**
 * Implements the MOTIS /plan endpoint.
 *
 * Currently using the v5 endpoint version.
 * Requires MOTIS 2.5.0, or later.
 */

export class Motis {
    constructor({ query, plan, ...params }) {
        this._requestCancellable = null
        this._baseUrl = GLib.getenv('MOTIS_BASE_URL') ?? params?.baseUrl;
        this._plan = plan;
        this._query = query;
        this._session =
            new Soup.Session({ user_agent : 'gnome-maps/' + pkg.version });
        this._cachedEncodedPolylines = {};

        if (!this._baseUrl)
            throw new Error('must specify baseUrl as an argument');
    }

    cancelCurrentRequest() {
        if (this._requestCancellable) {
            this._requestCancellable.cancel();
            this._requestCancellable = null;
        }
    }

    fetchFirstResults() {
        // for now, don't handle via locations
        if (this._query.filledPoints.length > 2) {
            this._plan.noRouteFound();
            return;
        }

        this._fetch();
    }

    fetchMoreResults() {
        this._fetch(true);
    }

    fetchStoptimes(place, arrival, extendPrevious, radius, routeType, callback) {
        const query = this._getStoptimesQuery(place, arrival, extendPrevious,
                                              radius, routeType);
        const request = Soup.Message.new('GET', this._baseUrl +
                                                '/api/v6/stoptimes?' +
                                                query.toString());

        request.request_headers.replace('Content-Type', 'application/json');

        this._requestCancellable = new Gio.Cancellable();

        this._session.send_and_read_async(request, GLib.PRIORITY_DEFAULT,
                                          this._requestCancellable,
                                          (source, res) => {
            try {
                if (request.get_status() != Soup.Status.OK) {
                    Utils.debug('Failed stoptimes: ' +
                                request.get_status());

                    callback(null);
                } else {
                    const buffer = this._session.send_and_read_finish(res).get_data();
                    const result = JSON.parse(Utils.getBufferText(buffer));
                    Utils.debug('result: ' + JSON.stringify(result, null, 2));

                    this._nextPageCursor = result.nextPageCursor;

                    callback(this._parseStoptimes(result, arrival));
                }
            } catch (error) {
                if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
                    Utils.debug('Failed to send request: ' + error.msg + ', ' + error.stack);
                    callback(null);
                }
            }
        });
    }

    _fetch(extendPrevious = false) {
        const query = this._getQuery(extendPrevious);
        const request = Soup.Message.new('GET', this._baseUrl + '/api/v5/plan?' +
                                         query.toString());

        // if trying to extend trips, and there was no page cursor, show no results
        const pageCursor =
            this._query.arriveBy ? this._previousCursor : this._nextCursor;

        if (extendPrevious && !pageCursor) {
            this._plan.noMoreResults();
            return;
        }

        request.request_headers.replace('Content-Type', 'application/json');

        this._requestCancellable = new Gio.Cancellable();

        this._session.send_and_read_async(request, GLib.PRIORITY_DEFAULT, this._requestCancellable,
                                          (source, res) => {
            try {
                if (request.get_status() !== Soup.Status.OK) {
                    Utils.debug('Failed to get trip: ' + request.get_status());

                    this._plan.requestFailed();
                } else {
                    const buffer = this._session.send_and_read_finish(res).get_data();
                    const result = JSON.parse(Utils.getBufferText(buffer));
                    Utils.debug('result: ' + JSON.stringify(result, null, 2));

                    this._previousCursor = result.previousPageCursor;
                    this._nextCursor = result.nextPageCursor;

                    this._parseResult(result, extendPrevious);
                }
                this._requestCancellable = null;
            } catch (error) {
                if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
                    Utils.debug('Failed to send request: ' + error.msg + ', ' + error.stack);
                    this._plan.requestFailed();
                }
            }
        });
    }

    _parseResult(result, extendPrevious) {
        try {
            const itineraries = result?.itineraries;
            const direct = result?.direct;

            if (!Array.isArray(itineraries) || !Array.isArray(direct)) {
                Utils.debug('No itineraries in response');
                this._plan.requestFailed();
                return;
            }

            if (extendPrevious && itineraries.length === 0) {
                this._plan.noMoreResults();
                return;
            } else if (!extendPrevious &&
                       itineraries.length === 0 && direct.length === 0) {
                this._plan.noRouteFound();
                return;
            }

            const filledPoints = this._query.filledPoints;
            const startPlace = filledPoints[0].place;
            const destinationPlace = filledPoints.last().place;
            const parsedItineraries =
                itineraries.map((itinerary) =>
                                 this._parseItinerary(itinerary,
                                                      startPlace,
                                                      destinationPlace));
            // don't include direct connections when extending a search
            const parsedDirect = extendPrevious ? [] :
                                 direct.map((itinerary) =>
                                            this._parseItinerary(itinerary,
                                                                 startPlace,
                                                                 destinationPlace));
            const newItineraries = [...parsedDirect, ...parsedItineraries];

            this._plan.updateWithNewItineraries(newItineraries,
                                                this._query.arriveBy,
                                                extendPrevious);
        } catch (error) {
            Utils.debug(`error parsing connections ${error} ${error.stack}`);
            this._plan.requestFailed();
        }
    }

    _parseItinerary(itinerary, startPlace, destinationPlace) {
        const from = itinerary.legs[0].from;
        const to = itinerary.legs.last().to;
        const fromTzId = from.tz;
        const toTzId = to.tz;
        const fromTz = fromTzId ?
                       GLib.TimeZone.new_identifier(fromTzId) :
                       this._getTimezone(from.lat, from.lon);
        const toTz = toTzId ?
                     GLib.TimeZone.new_identifier(toTzId) :
                     this._getTimezone(to.lat, to.lon);
        const departure = Time.parseDateTime(itinerary.startTime, fromTz);
        const arrival = Time.parseDateTime(itinerary.endTime, toTz);

        const legs =
            itinerary.legs.map((leg, index) =>
                               this._parseLeg(leg, index === 0,
                                              index === itinerary.legs.length - 1,
                                              startPlace, destinationPlace));

        return new Itinerary({
            legs:                    legs,
            departure:               departure,
            arrival:                 arrival,
            duration:                itinerary.duration,
            transfers:               itinerary.transfers
        });
    }

    _parseHexColorString(colorString) {
        const color = new Gdk.RGBA();

        return color.parse(`#${colorString}`) ? color : null;
    }

    _parseLeg(leg, isFirst, isLast, startPlace, destinationPlace) {
        const isTransit = leg.mode !== 'WALK';
        const distance =
            leg.distance ?? (!isTransit ?
                             this._getStraightDistance(leg.from, leg.to) :
                             undefined);
        const from = leg.from;
        const to = leg.to;
        const fromTzId = from.tz;
        const toTzId = to.tz;
        const fromTz = fromTzId ?
                       GLib.TimeZone.new_identifier(fromTzId) :
                       this._getTimezone(from.lat, from.lon);
        const toTz = toTzId ?
                     GLib.TimeZone.new_identifier(toTzId) :
                     this._getTimezone(to.lat, to.lon);
        const departure = Time.parseDateTime(leg.startTime, fromTz);
        const scheduledDeparture =
            Time.parseDateTime(leg.scheduledStartTime, fromTz);
        const arrival = Time.parseDateTime(leg.endTime, toTz);
        const scheduledArrival = Time.parseDateTime(leg.scheduledEndTime, toTz);
        const fromName = isFirst && !isTransit ? startPlace.name : leg.from.name;
        const toName = isLast && !isTransit ? destinationPlace.name : leg.to.name;

        const polyline =
            leg?.legGeometry?.points ?
            this._getEncodedPolyline(leg.legGeometry.points,
                                     leg.legGeometry.precision) : undefined;
        const intermediateStops =
            isTransit 
                ? leg.intermediateStops.map(stop => this._parseStop(stop))
                : undefined;
        const steps =
            !isTransit && leg.steps ?
            this._parseSteps(leg.steps, polyline[0], polyline.last()) : undefined;
        const fromLocation = new Location({ latitude:  leg.from.lat,
                                            longitude: leg.from.lon });
        const toLocation = new Location({ latitude:  leg.to.lat,
                                          longitude: leg.to.lon })
        const fromPlace =
            isFirst && !isTransit ? startPlace :
            leg.from.stopId ?
            new TransitPlace({ name:     fromName,
                               location: fromLocation,
                               id:       leg.from.stopId,
                               modes:    new Set(leg.from.modes) }) :
            new Place({ name:     fromName,
                        location: fromLocation,
                        store:    false });
        const toPlace =
            isLast && !isTransit ? destinationPlace :
            leg.to.stopId ?
            new TransitPlace({ name:     toName,
                               location: toLocation,
                               id:       leg.to.stopId,
                               modes:    new Set(leg.to.modes) }) :
            new Place({ name:     toName,
                        location: toLocation,
                        store:    false });
        const route = isTransit ? new Route({ displayName:   leg.displayName,
                                              tripShortName: leg.tripShortName,
                                              routeType:     leg.routeType ??
-                                                            this._getRouteType(leg),
                                              agencyName:    leg.agencyName,
                                              agencyUrl:     leg.agencyUrl,
                                              color:         this._parseHexColorString(leg.routeColor),
                                              textColor:     this._parseHexColorString(leg.routeTextColor),
                                              realtime:      leg.realTime }) :
                                   undefined;

        return new Leg({ isTransit:               isTransit,
                         duration:                leg.duration,
                         distance:                distance,
                         departure:               departure,
                         scheduledDeparture:      scheduledDeparture,
                         arrival:                 arrival,
                         scheduledArrival:        scheduledArrival,
                         from:                    fromPlace,
                         to:                      toPlace,
                         headsign:                leg.headsign,
                         route:                   route,
                         polyline:                polyline,
                         intermediateStops:       intermediateStops,
                         walkingInstructions:     steps ,
                         departureTrack:          from.track,
                         scheduledDepartureTrack: from.scheduledTrack,
                         arrivalTrack:            to.track,
                         scheduledArrivalTrack:   to.scheduledTrack });
    }

    _getStraightDistance(from, to) {
        const fromCoord = new Shumate.Coordinate({ latitude:  from.lat,
                                                   longitude: from.lon });
        const toCoord = new Shumate.Coordinate({ latitude:  to.lat,
                                                 longitude: to.lon });

        return fromCoord.distance(toCoord);
    }

    _getEncodedPolyline(polyline, precision) {
        if (!this._cachedEncodedPolylines[polyline]) {
            // clear cached entries when above threashhold to prevent overgrowing
            if (Object.values(this._cachedEncodedPolylines).length > 100)
                this._cachedEncodedPolylines = {};

            this._cachedEncodedPolylines[polyline] =
                Epaf.decode(polyline, precision);
        }

        return this._cachedEncodedPolylines[polyline];
    }

    _parseStop(stop) {
        const tz = GLib.TimeZone.new_identifier(stop.tz);
        const departure = Time.parseDateTime(stop.departure, tz);
        const scheduledDeparture =
            Time.parseDateTime(stop.scheduledDeparture, tz);
        const arrival = Time.parseDateTime(stop.arrival, tz);
        const scheduledArrival =
            Time.parseDateTime(stop.scheduledArrival, tz);
        const location = new Location({ latitude: stop.lat,
                                        longitude: stop.lon });

        return new Stop({ name:                 stop.name,
                          arrival:              arrival,
                          scheduledArrival:     scheduledArrival,
                          departure:            departure,
                          scheduledDeparture:   scheduledDeparture,
                          location:             location,
                          id:                   stop.stopId,
                          track:                stop.track,
                          scheduledTrack:       stop.scheduledTrack,
                          modes:                new Set(stop.modes) });
    }

    _parseSteps(steps, fromCoordinate, toCoordinate) {
        // create starting turnpoint instruction based on leg
        const startTurnpoint = new TurnPoint({ type:        TurnPoint.Type.START,
                                               distance:    0,
                                               instruction: _("Start"),
                                               coordinate:  fromCoordinate });

        const intermediateTurnpoints = steps.map((step) => {
            const [type, instruction] =
                this._getTurnpointTypeAndInstruction(step);
            const coordinate =
                Epaf.decodeFirstCoordinate(step.polyline.points,
                                           step.polyline.precision);

            return new TurnPoint({ type:        type,
                                   distance:    step.distance,
                                   instruction: instruction,
                                   coordinate:  coordinate });
        });

        // add ending turnPoint
        const endTurnpoint = new TurnPoint({ type:        TurnPoint.Type.END,
                                             distance:    0,
                                             instruction: _("Arrive"),
                                             coordinate:  toCoordinate });

        return [startTurnpoint, ...intermediateTurnpoints, endTurnpoint];
    }

    _parseStoptimes(result, isArrival) {
        return result.stopTimes.map(stopTime =>
                                    this._parseStopTime(stopTime, isArrival));
    }

    _parseStopTime(stopTime, isArrival) {
        const route = new Route({ displayName:   stopTime.displayName,
                                  tripShortName: stopTime.tripShortName,
                                  routeType:     stopTime.routeType ??
-                                                this._getRouteType(stopTime),
                                  agencyName:    stopTime.agencyName,
                                  agencyUrl:     stopTime.agencyUrl,
                                  color:         this._parseHexColorString(stopTime.routeColor),
                                  textColor:     this._parseHexColorString(stopTime.routeTextColor),
                                  realtime:      stopTime.realtime });
        const designation = isArrival ? stopTime.tripFrom.name : stopTime.headsign;
        const track = stopTime.place.track;
        const scheduledTrack = stopTime.place.scheduledTrack;
        const location = new Location({ latitude:  stopTime.place.lat,
                                        longitude: stopTime.place.lon });
        const place = new TransitPlace({ location: location,
                                         name:     stopTime.place.name,
                                         id:       stopTime.place.stopId,
                                         modes:    new Set(stopTime.place.modes) });
        const tz = GLib.TimeZone.new_identifier(stopTime.place.tz);
        const time = Time.parseDateTime(isArrival ? stopTime.place.arrival :
                                                    stopTime.place.departure,
                                        tz);
        const scheduledTime =
            Time.parseDateTime(isArrival ? stopTime.place.scheduledArrival :
                                           stopTime.place.scheduledDeparture,
                               tz);

        return isArrival ? new Arrival({ route:          route,
                                         place:          place,
                                         designation:    designation,
                                         time:           time,
                                         scheduledTime:  scheduledTime,
                                         track:          track,
                                         scheduledTrack: scheduledTrack }) :
                           new Departure({ route:          route,
                                           place:          place,
                                           designation:    designation,
                                           time:           time,
                                           scheduledTime:  scheduledTime,
                                           track:          track,
                                           scheduledTrack: scheduledTrack });

    }

    _getTurnpointTypeAndInstruction(step) {
        switch (step.relativeDirection) {
            case 'DEPART':
            case 'STAIRS':
                return [TurnPoint.Type.STAIRS,
                        step.hasOwnProperty('toLevel') ?
                        _("Take the stairs to level %s").format(step.toLevel) :
                        _("Take the stairs")];
            case 'CONTINUE':
                return [TurnPoint.Type.CONTINUE,
                        step.streetName ?
                        _("Continue on %s").format(step.streetName) :
                        _("Continue")];
            case 'LEFT':
                return [TurnPoint.Type.LEFT,
                        step.streetName ?
                        _("Turn left on %s").format(step.streetName) :
                        _("Turn left")];
            case 'SLIGHTLY_LEFT':
                return [TurnPoint.Type.SLIGHT_LEFT,
                        step.streetName ?
                        _("Turn slightly left on %s").format(step.streetName) :
                        _("Turn slightly left")];
            case 'HARD_LEFT':
                return [TurnPoint.Type.SHARP_LEFT,
                        step.streetName ?
                        _("Turn sharp left on %s").format(step.streetName) :
                        _("Turn sharp left")];
            case 'RIGHT':
                return [TurnPoint.Type.RIGHT,
                        step.streetName ?
                        _("Turn right on %s").format(step.streetName) :
                        _("Turn right")];
            case 'SLIGHT_RIGHT':
                return [TurnPoint.Type.SLIGHT_RIGHT,
                        step.streetName ?
                        _("Turn slightly right on %s").format(step.streetName) :
                        _("Turn slightly right")];
            case 'HARD_RIGHT':
                return [TurnPoint.Type.SHARP_RIGHT,
                        step.streetName ?
                        _("Turn sharp right on %s").format(step.streetName) :
                        _("Turn sharp right")];
            case 'CIRCLE_CLOCKWISE':
            case 'CIRCLE_COUNTERCLOCKWISE': {
                let instruction;

                if (step.exit) {
                    instruction =
                        _("At the roundabout, take exit %s").format(step.exit);
                } else if (step.streetName) {
                    instruction =
                        _("At the roundabout, take exit to %s").format(step.streetName);
                } else {
                    instruction = _("Take the roundabout");
                }

                return [TurnPoint.Type.ROUNDABOUT, instruction];
            }
            case 'ELEVATOR':
                return [TurnPoint.Type.ELEVATOR,
                        step.hasOwnProperty('toLevel') ?
                        _("Take the elevator and get off at %s").format(step.toLevel) :
                        _("Take the elevator")];
            case 'UTRUN_LEFT':
                return [TurnPoint.Type.UTURN_LEFT,
                        step.streetName ?
                        _("Make a left u-turn onto %s").format(step.streetName) :
                        _("Make a left u-turn")];
            case 'UTURN_RIGHT':
                return [TurnPoint.Type.UTURN_RIGHT,
                        step.streetName ?
                        _("Make a right u-turn onto %s").format(step.streetName) :
                        _("Make a right u-turn")];
            default:
                throw 'unknown direction ' + step.relativeDirection;
        }
    }

    _getRouteType(leg) {
        switch (leg.mode) {
            case 'BUS':
                return RouteType.BUS;
            case 'TRAM':
                return RouteType.TRAM;
            case 'SUBWAY':
                return RouteType.SUBWAY;
            case 'FERRY':
                return RouteType.FERRY;
            case 'AIRPLANE':
                return HVT.AIR_SERVICE;
            case 'SUBURBAN':
                return HVT.SUBURBAN_RAILWAY_SERVICE;
            case 'COACH':
                return HVT.COACH_SERVICE;
            case 'RAIL':
                return RouteType.TRAIN;
            case 'HIGHSPEED_RAIL':
                return HVT.HIGH_SPEED_RAIL_SERVICE;
            case 'LONG_DISTANCE':
                return HVT.LONG_DISTANCE_TRAINS;
            case 'NIGHT_RAIL':
                return HVT.SLEEPER_RAIL_SERVICE;
            case 'REGIONAL_FAST_RAIL':
            case 'REGIONAL_RAIL':
                return HVT.REGIONAL_RAIL_SERVICE;
            case 'CABLE_CAR':
                return HVT.CABLE_CAR;
            case 'FUNICULAR':
                return HVT.FUNICULAR_SERVICE;
            case 'AERIAL_LIFT':
                return HVT.TELECABIN_SERVICE;
            case 'OTHER':
                return HVT.MISCELLANEOUS_SERVICE;
            default:
                return undefined;
        }
    }

    _getTimezone(lat, lon) {
        const location = GWeather.Location.new_detached('', null, lat, lon);

        return location.get_timezone();
    }

    _getPlaceParamFromLocation(place) {
        const location = place.location;

        /* return comma-separated tuple of latitude,longitude, and when
         * available, level
         */
        return [location.latitude, location.longitude, place.level].
               filter(e => e).join(',');
    }

    _getQuery(extendPrevious) {
        const from = this._query.filledPoints[0].place;
        const to = this._query.filledPoints.last().place;
        const params = { fromPlace: this._getPlaceParamFromLocation(from),
                         toPlace:   this._getPlaceParamFromLocation(to),
                         arriveBy:  this._query.arriveBy,
                         language:  Utils.getLanguages().toString(),
                         maxMatchingDistance: MAX_MATCHING_DISTANCE };

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

        if (!this._query.transitOptions.showAllTransitTypes)
            params.transitModes = this._getTransitModesParam();

        if (extendPrevious) {
            params.pageCursor =
                this._query.arriveBy ? this._previousCursor : this._nextCursor;
        }

        return new Query(params);
    }

    _getStoptimesQuery(place, arrival, extendPrevious, radius, routeType) {
        const params = { n:        NUM_STOP_TIMES,
                         radius:   radius,
                         language: Utils.getLanguages().toString() };

        if (place instanceof TransitPlace)
            params.stopId = place.id;
        else
            params.center = this._getPlaceParamFromLocation(place);

        if (routeType !== undefined)
            params.mode = this._getTransitModeParam(routeType);

        if (extendPrevious)
            params.pageCursor = this._nextPageCursor;

        if (arrival) {
            params.arriveBy = true;
            params.direction = 'LATER';
            /* avoid using sub-second resoultion when using
             * GLib.DateTime.new_now_utc().format_iso8601()
             * causing MOTIS to give incorrect responses, convert the resolution
             * to even seconds
             */
            params.tims = GLib.DateTime.new_from_unix_utc(GLib.get_real_time() /
                                                          1_000_000);
        }

        return new Query(params);
    }

    _getTimeParam() {
        const filledPoints = this._query.filledPoints;
        const loc = this._query.arriveBy ? filledPoints.last().place.location :
                                           filledPoints[0].place.location;
        const tz = this._getTimezone(loc.latitude, loc.longitude);
        const date = this._query.date ?? GLib.DateTime.new_now_local().format('%F');
        const dateTime =
            GLib.DateTime.new_from_iso8601(`${date}T${this._query.time}:00`,
                                           tz);

        return dateTime.to_utc().format_iso8601();
    }

    _getTransitModeParam(type) {
        switch (type) {
            case RouteType.BUS:
                return 'BUS,COACH';
            case RouteType.TRAM:
                return 'TRAM'
            case RouteType.TRAIN:
                return 'HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,REGIONAL_FAST_RAIL,SUBURBAN';
            case RouteType.SUBWAY:
                return 'SUBWAY';
            case RouteType.FERRY:
                return 'FERRY';
            case RouteType.GONDOLA:
                return 'AERIAL_LIFT';
            case RouteType.FUNICULAR:
                return 'FUNICULAR';
            case HVT.AIR_SERVICE:
                return 'AIRPLANE';
            default:
                throw 'unkown transit type: ' + type;
        }
    }

    _getTransitModesParam() {
        return this._query.transitOptions.transitTypes.map((type) => {
            return this._getTransitModeParam(type);
        }).flat().join(',');
    }
}
