de.schildbach.pte.VrsProvider.java Source code

Java tutorial

Introduction

Here is the source code for de.schildbach.pte.VrsProvider.java

Source

/*
 * Copyright 2015 the original author or authors.
 *
 * This program 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 3 of the License, or
 * (at your option) any later version.
 *
 * This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package de.schildbach.pte;

import static com.google.common.base.Preconditions.checkNotNull;

import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Currency;
import java.util.Date;
import java.util.EnumSet;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.annotation.Nullable;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Strings;

import de.schildbach.pte.dto.Departure;
import de.schildbach.pte.dto.Fare;
import de.schildbach.pte.dto.Line;
import de.schildbach.pte.dto.LineDestination;
import de.schildbach.pte.dto.Location;
import de.schildbach.pte.dto.LocationType;
import de.schildbach.pte.dto.NearbyLocationsResult;
import de.schildbach.pte.dto.Point;
import de.schildbach.pte.dto.Position;
import de.schildbach.pte.dto.Product;
import de.schildbach.pte.dto.QueryDeparturesResult;
import de.schildbach.pte.dto.QueryTripsContext;
import de.schildbach.pte.dto.QueryTripsResult;
import de.schildbach.pte.dto.ResultHeader;
import de.schildbach.pte.dto.StationDepartures;
import de.schildbach.pte.dto.Stop;
import de.schildbach.pte.dto.Style;
import de.schildbach.pte.dto.SuggestLocationsResult;
import de.schildbach.pte.dto.SuggestedLocation;
import de.schildbach.pte.dto.Trip;
import de.schildbach.pte.dto.Trip.Leg;

import okhttp3.HttpUrl;

/**
 * @author Michael Dyrna
 */
public class VrsProvider extends AbstractNetworkProvider {

    private static final Logger log = LoggerFactory.getLogger(VrsProvider.class);

    @SuppressWarnings("serial")
    private static class Context implements QueryTripsContext {
        private boolean canQueryLater = true;
        private boolean canQueryEarlier = true;
        private Date lastDeparture = null;
        private Date firstArrival = null;
        public Location from;
        public Location via;
        public Location to;
        public Set<Product> products;

        private Context() {
        }

        @Override
        public boolean canQueryLater() {
            return this.canQueryLater;
        }

        @Override
        public boolean canQueryEarlier() {
            return this.canQueryEarlier;
        }

        public void departure(Date departure) {
            if (this.lastDeparture == null || this.lastDeparture.compareTo(departure) < 0) {
                this.lastDeparture = departure;
            }
        }

        public void arrival(Date arrival) {
            if (this.firstArrival == null || this.firstArrival.compareTo(arrival) > 0) {
                this.firstArrival = arrival;
            }
        }

        public Date getLastDeparture() {
            return this.lastDeparture;
        }

        public Date getFirstArrival() {
            return this.firstArrival;
        }

        public void disableEarlier() {
            this.canQueryEarlier = false;
        }

        public void disableLater() {
            this.canQueryLater = false;
        }
    }

    private static class LocationWithPosition {
        public LocationWithPosition(Location location, Position position) {
            this.location = location;
            this.position = position;
        }

        public Location location;
        public Position position;
    }

    // valid host names: www.vrsinfo.de, android.vrsinfo.de, ios.vrsinfo.de, ekap.vrsinfo.de (only SSL
    // encrypted with
    // client certificate)
    // performance comparison March 2015 showed www.vrsinfo.de to be fastest for trips
    protected static final HttpUrl API_BASE = HttpUrl.parse("http://android.vrsinfo.de/index.php");
    protected static final String SERVER_PRODUCT = "vrs";

    @SuppressWarnings("serial")
    protected static final List<Pattern> NAME_WITH_POSITION_PATTERNS = new ArrayList<Pattern>() {
        {
            // Bonn Hauptbahnhof (ZOB) - Bussteig F2
            // Beuel Bf - D
            add(Pattern.compile("(.*) - (.*)"));
            // Breslauer Platz/Hbf (U) Gleis 2
            add(Pattern.compile("(.*) Gleis (.*)"));
            // Bonn Hauptbahnhof (Stadtbahn) (Bahnsteig H)
            add(Pattern.compile("(.*) \\(Bahnsteig ([^)]*)\\)"));
            // Dren Bf (Bussteig D/E)
            add(Pattern.compile("(.*) \\(Bussteig ([^)]*)\\)"));
            // Venloer Str./Grtel (Gleis 1)
            add(Pattern.compile("(?:(.*) )?\\(Gleis ([^)]*)\\)"));
            // Aachen alle Buslinien
            add(Pattern.compile("(.*) \\(H\\.(\\d+).*\\)"));
            // Neumarkt Bussteig B
            add(Pattern.compile("(.*) Bussteig (.*)"));
        }
    };
    protected static final Pattern nrwTarifPattern = Pattern.compile("([\\d]+,\\d\\d)");

    protected static final Map<String, Style> STYLES = new HashMap<>();

    static {
        // Stadtbahn Kln-Bonn
        STYLES.put("T1", new Style(Style.parseColor("#ed1c24"), Style.WHITE));
        STYLES.put("T3", new Style(Style.parseColor("#f680c5"), Style.WHITE));
        STYLES.put("T4", new Style(Style.parseColor("#f24dae"), Style.WHITE));
        STYLES.put("T5", new Style(Style.parseColor("#9c8dce"), Style.WHITE));
        STYLES.put("T7", new Style(Style.parseColor("#f57947"), Style.WHITE));
        STYLES.put("T9", new Style(Style.parseColor("#f5777b"), Style.WHITE));
        STYLES.put("T12", new Style(Style.parseColor("#80cc28"), Style.WHITE));
        STYLES.put("T13", new Style(Style.parseColor("#9e7b65"), Style.WHITE));
        STYLES.put("T15", new Style(Style.parseColor("#4dbd38"), Style.WHITE));
        STYLES.put("T16", new Style(Style.parseColor("#33baab"), Style.WHITE));
        STYLES.put("T18", new Style(Style.parseColor("#05a1e6"), Style.WHITE));
        STYLES.put("T61", new Style(Style.parseColor("#80cc28"), Style.WHITE));
        STYLES.put("T62", new Style(Style.parseColor("#4dbd38"), Style.WHITE));
        STYLES.put("T63", new Style(Style.parseColor("#73d2f6"), Style.WHITE));
        STYLES.put("T65", new Style(Style.parseColor("#b3db18"), Style.WHITE));
        STYLES.put("T66", new Style(Style.parseColor("#ec008c"), Style.WHITE));
        STYLES.put("T67", new Style(Style.parseColor("#f680c5"), Style.WHITE));
        STYLES.put("T68", new Style(Style.parseColor("#ca93d0"), Style.WHITE));

        // Busse Kln
        STYLES.put("BSB40", new Style(Style.parseColor("#FF0000"), Style.WHITE));
        STYLES.put("B106", new Style(Style.parseColor("#0994dd"), Style.WHITE));
        STYLES.put("B120", new Style(Style.parseColor("#24C6E8"), Style.WHITE));
        STYLES.put("B121", new Style(Style.parseColor("#89E82D"), Style.WHITE));
        STYLES.put("B122", new Style(Style.parseColor("#4D44FF"), Style.WHITE));
        STYLES.put("B125", new Style(Style.parseColor("#FF9A2E"), Style.WHITE));
        STYLES.put("B126", new Style(Style.parseColor("#FF8EE5"), Style.WHITE));
        STYLES.put("B127", new Style(Style.parseColor("#D164A4"), Style.WHITE));
        STYLES.put("B130", new Style(Style.parseColor("#5AC0E8"), Style.WHITE));
        STYLES.put("B131", new Style(Style.parseColor("#8cd024"), Style.WHITE));
        STYLES.put("B132", new Style(Style.parseColor("#E8840C"), Style.WHITE));
        STYLES.put("B133", new Style(Style.parseColor("#FF9EEE"), Style.WHITE));
        STYLES.put("B135", new Style(Style.parseColor("#f24caf"), Style.WHITE));
        STYLES.put("B136", new Style(Style.parseColor("#C96C44"), Style.WHITE));
        STYLES.put("B138", new Style(Style.parseColor("#ef269d"), Style.WHITE));
        STYLES.put("B139", new Style(Style.parseColor("#D13D1E"), Style.WHITE));
        STYLES.put("B140", new Style(Style.parseColor("#FFD239"), Style.WHITE));
        STYLES.put("B141", new Style(Style.parseColor("#2CE8D0"), Style.WHITE));
        STYLES.put("B142", new Style(Style.parseColor("#9E54FF"), Style.WHITE));
        STYLES.put("B143", new Style(Style.parseColor("#82E827"), Style.WHITE));
        STYLES.put("B144", new Style(Style.parseColor("#FF8930"), Style.WHITE));
        STYLES.put("B145", new Style(Style.parseColor("#24C6E8"), Style.WHITE));
        STYLES.put("B146", new Style(Style.parseColor("#F25006"), Style.WHITE));
        STYLES.put("B147", new Style(Style.parseColor("#FF8EE5"), Style.WHITE));
        STYLES.put("B149", new Style(Style.parseColor("#176fc1"), Style.WHITE));
        STYLES.put("B150", new Style(Style.parseColor("#f68712"), Style.WHITE));
        STYLES.put("B151", new Style(Style.parseColor("#ECB43A"), Style.WHITE));
        STYLES.put("B152", new Style(Style.parseColor("#FFDE44"), Style.WHITE));
        STYLES.put("B153", new Style(Style.parseColor("#C069FF"), Style.WHITE));
        STYLES.put("B154", new Style(Style.parseColor("#E85D25"), Style.WHITE));
        STYLES.put("B155", new Style(Style.parseColor("#0994dd"), Style.WHITE));
        STYLES.put("B156", new Style(Style.parseColor("#4B69EC"), Style.WHITE));
        STYLES.put("B157", new Style(Style.parseColor("#5CC3F9"), Style.WHITE));
        STYLES.put("B158", new Style(Style.parseColor("#66c530"), Style.WHITE));
        STYLES.put("B159", new Style(Style.parseColor("#FF00CC"), Style.WHITE));
        STYLES.put("B160", new Style(Style.parseColor("#66c530"), Style.WHITE));
        STYLES.put("B161", new Style(Style.parseColor("#33bef3"), Style.WHITE));
        STYLES.put("B162", new Style(Style.parseColor("#f033a3"), Style.WHITE));
        STYLES.put("B163", new Style(Style.parseColor("#00adef"), Style.WHITE));
        STYLES.put("B163/550", new Style(Style.parseColor("#00adef"), Style.WHITE));
        STYLES.put("B164", new Style(Style.parseColor("#885bb4"), Style.WHITE));
        STYLES.put("B164/501", new Style(Style.parseColor("#885bb4"), Style.WHITE));
        STYLES.put("B165", new Style(Style.parseColor("#7b7979"), Style.WHITE));
        STYLES.put("B166", new Style(Style.parseColor("#7b7979"), Style.WHITE));
        STYLES.put("B167", new Style(Style.parseColor("#7b7979"), Style.WHITE));
        STYLES.put("B180", new Style(Style.parseColor("#918f90"), Style.WHITE));
        STYLES.put("B181", new Style(Style.parseColor("#918f90"), Style.WHITE));
        STYLES.put("B182", new Style(Style.parseColor("#918f90"), Style.WHITE));
        STYLES.put("B183", new Style(Style.parseColor("#918f90"), Style.WHITE));
        STYLES.put("B184", new Style(Style.parseColor("#918f90"), Style.WHITE));
        STYLES.put("B185", new Style(Style.parseColor("#D3D2D2"), Style.WHITE));
        STYLES.put("B186", new Style(Style.parseColor("#D3D2D2"), Style.WHITE));
        STYLES.put("B187", new Style(Style.parseColor("#D3D2D2"), Style.WHITE));
        STYLES.put("B188", new Style(Style.parseColor("#918f90"), Style.WHITE));
        STYLES.put("B190", new Style(Style.parseColor("#4D44FF"), Style.WHITE));
        STYLES.put("B191", new Style(Style.parseColor("#00a998"), Style.WHITE));

        // Busse Bonn
        STYLES.put("B16", new Style(Style.parseColor("#33baab"), Style.WHITE));
        STYLES.put("B18", new Style(Style.parseColor("#05a1e6"), Style.WHITE));
        STYLES.put("B61", new Style(Style.parseColor("#80cc28"), Style.WHITE));
        STYLES.put("B62", new Style(Style.parseColor("#4dbd38"), Style.WHITE));
        STYLES.put("B63", new Style(Style.parseColor("#73d2f6"), Style.WHITE));
        STYLES.put("B65", new Style(Style.parseColor("#b3db18"), Style.WHITE));
        STYLES.put("B66", new Style(Style.parseColor("#ec008c"), Style.WHITE));
        STYLES.put("B67", new Style(Style.parseColor("#f680c5"), Style.WHITE));
        STYLES.put("B68", new Style(Style.parseColor("#ca93d0"), Style.WHITE));
        STYLES.put("BSB55", new Style(Style.parseColor("#00919e"), Style.WHITE));
        STYLES.put("BSB60", new Style(Style.parseColor("#8f9867"), Style.WHITE));
        STYLES.put("BSB69", new Style(Style.parseColor("#db5f1f"), Style.WHITE));
        STYLES.put("B529", new Style(Style.parseColor("#2e2383"), Style.WHITE));
        STYLES.put("B537", new Style(Style.parseColor("#2e2383"), Style.WHITE));
        STYLES.put("B541", new Style(Style.parseColor("#2e2383"), Style.WHITE));
        STYLES.put("B551", new Style(Style.parseColor("#2e2383"), Style.WHITE));
        STYLES.put("B600", new Style(Style.parseColor("#817db7"), Style.WHITE));
        STYLES.put("B601", new Style(Style.parseColor("#831b82"), Style.WHITE));
        STYLES.put("B602", new Style(Style.parseColor("#dd6ba6"), Style.WHITE));
        STYLES.put("B603", new Style(Style.parseColor("#e6007d"), Style.WHITE));
        STYLES.put("B604", new Style(Style.parseColor("#009f5d"), Style.WHITE));
        STYLES.put("B605", new Style(Style.parseColor("#007b3b"), Style.WHITE));
        STYLES.put("B606", new Style(Style.parseColor("#9cbf11"), Style.WHITE));
        STYLES.put("B607", new Style(Style.parseColor("#60ad2a"), Style.WHITE));
        STYLES.put("B608", new Style(Style.parseColor("#f8a600"), Style.WHITE));
        STYLES.put("B609", new Style(Style.parseColor("#ef7100"), Style.WHITE));
        STYLES.put("B610", new Style(Style.parseColor("#3ec1f1"), Style.WHITE));
        STYLES.put("B611", new Style(Style.parseColor("#0099db"), Style.WHITE));
        STYLES.put("B612", new Style(Style.parseColor("#ce9d53"), Style.WHITE));
        STYLES.put("B613", new Style(Style.parseColor("#7b3600"), Style.WHITE));
        STYLES.put("B614", new Style(Style.parseColor("#806839"), Style.WHITE));
        STYLES.put("B615", new Style(Style.parseColor("#532700"), Style.WHITE));
        STYLES.put("B630", new Style(Style.parseColor("#c41950"), Style.WHITE));
        STYLES.put("B631", new Style(Style.parseColor("#9b1c44"), Style.WHITE));
        STYLES.put("B633", new Style(Style.parseColor("#88cdc7"), Style.WHITE));
        STYLES.put("B635", new Style(Style.parseColor("#cec800"), Style.WHITE));
        STYLES.put("B636", new Style(Style.parseColor("#af0223"), Style.WHITE));
        STYLES.put("B637", new Style(Style.parseColor("#e3572a"), Style.WHITE));
        STYLES.put("B638", new Style(Style.parseColor("#af5836"), Style.WHITE));
        STYLES.put("B640", new Style(Style.parseColor("#004f81"), Style.WHITE));
        STYLES.put("BT650", new Style(Style.parseColor("#54baa2"), Style.WHITE));
        STYLES.put("BT651", new Style(Style.parseColor("#005738"), Style.WHITE));
        STYLES.put("BT680", new Style(Style.parseColor("#4e6578"), Style.WHITE));
        STYLES.put("B800", new Style(Style.parseColor("#4e6578"), Style.WHITE));
        STYLES.put("B812", new Style(Style.parseColor("#4e6578"), Style.WHITE));
        STYLES.put("B843", new Style(Style.parseColor("#4e6578"), Style.WHITE));
        STYLES.put("B845", new Style(Style.parseColor("#4e6578"), Style.WHITE));
        STYLES.put("B852", new Style(Style.parseColor("#4e6578"), Style.WHITE));
        STYLES.put("B855", new Style(Style.parseColor("#4e6578"), Style.WHITE));
        STYLES.put("B856", new Style(Style.parseColor("#4e6578"), Style.WHITE));
        STYLES.put("B857", new Style(Style.parseColor("#4e6578"), Style.WHITE));

        // andere Busse
        STYLES.put("B250", new Style(Style.parseColor("#8FE84B"), Style.WHITE));
        STYLES.put("B260", new Style(Style.parseColor("#FF8365"), Style.WHITE));
        STYLES.put("B423", new Style(Style.parseColor("#D3D2D2"), Style.WHITE));
        STYLES.put("B434", new Style(Style.parseColor("#14E80B"), Style.WHITE));
        STYLES.put("B436", new Style(Style.parseColor("#BEEC49"), Style.WHITE));
        STYLES.put("B481", new Style(Style.parseColor("#D3D2D2"), Style.WHITE));
        STYLES.put("B504", new Style(Style.parseColor("#8cd024"), Style.WHITE));
        STYLES.put("B505", new Style(Style.parseColor("#0994dd"), Style.WHITE));
        STYLES.put("B885", new Style(Style.parseColor("#40bb6a"), Style.WHITE));
        STYLES.put("B935", new Style(Style.parseColor("#bf7e71"), Style.WHITE));
        STYLES.put("B961", new Style(Style.parseColor("#f140a9"), Style.WHITE));
        STYLES.put("B962", new Style(Style.parseColor("#9c83c9"), Style.WHITE));
        STYLES.put("B963", new Style(Style.parseColor("#f46c68"), Style.WHITE));
        STYLES.put("B965", new Style(Style.parseColor("#FF0000"), Style.WHITE));
        STYLES.put("B970", new Style(Style.parseColor("#f68712"), Style.WHITE));
        STYLES.put("B980", new Style(Style.parseColor("#c38bcc"), Style.WHITE));

        STYLES.put("BN", new Style(Style.parseColor("#000000"), Style.WHITE));
        STYLES.put("BNE1", new Style(Style.parseColor("#993399"), Style.WHITE)); // default

        STYLES.put("S", new Style(Style.parseColor("#f18e00"), Style.WHITE));
        STYLES.put("R", new Style(Style.parseColor("#009d81"), Style.WHITE));
    }

    public VrsProvider() {
        super(NetworkId.VRS);

        setStyles(STYLES);
    }

    @Override
    protected boolean hasCapability(Capability capability) {
        switch (capability) {
        case DEPARTURES:
            return true;
        case NEARBY_LOCATIONS:
            return true;
        case SUGGEST_LOCATIONS:
            return true;
        case TRIPS:
            return true;
        default:
            return false;
        }
    }

    // only stations supported
    @Override
    public NearbyLocationsResult queryNearbyLocations(EnumSet<LocationType> types /* only STATION supported */,
            Location location, int maxDistance, int maxLocations) throws IOException {
        // g=p means group by product; not used here
        final HttpUrl.Builder url = API_BASE.newBuilder();
        url.addQueryParameter("eID", "tx_vrsinfo_ass2_timetable");
        if (location.hasLocation()) {
            url.addQueryParameter("r",
                    String.format(Locale.ENGLISH, "%.6f,%.6f", location.lat / 1E6, location.lon / 1E6));
        } else if (location.type == LocationType.STATION && location.hasId()) {
            url.addQueryParameter("i", location.id);
        } else {
            throw new IllegalArgumentException("at least one of stationId or lat/lon must be given");
        }
        // c=1 limits the departures at each stop to 1 - actually we don't need any at this point
        url.addQueryParameter("c", "1");
        if (maxLocations > 0) {
            // s=number of stops, artificially limited by server
            url.addQueryParameter("s", Integer.toString(Math.min(16, maxLocations)));
        }

        final CharSequence page = httpClient.get(url.build());

        try {
            final List<Location> locations = new ArrayList<>();
            final JSONObject head = new JSONObject(page.toString());
            final String error = Strings.emptyToNull(head.optString("error", "").trim());
            if (error != null) {
                if (error.equals("Leere Koordinate.") || error.equals("Leere ASS-ID und leere Koordinate"))
                    return new NearbyLocationsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT), locations);
                else if (error.equals("ASS2-Server lieferte leere Antwort."))
                    return new NearbyLocationsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            NearbyLocationsResult.Status.SERVICE_DOWN);
                else
                    throw new IllegalStateException("unknown error: " + error);
            }
            final JSONArray timetable = head.getJSONArray("timetable");
            long serverTime = 0;
            for (int i = 0; i < timetable.length(); i++) {
                final JSONObject entry = timetable.getJSONObject(i);
                final JSONObject stop = entry.getJSONObject("stop");
                final Location loc = parseLocationAndPosition(stop).location;
                int distance = stop.getInt("distance");
                if (maxDistance > 0 && distance > maxDistance) {
                    break; // we rely on the server side sorting by distance
                }
                if (types.contains(loc.type) || types.contains(LocationType.ANY)) {
                    locations.add(loc);
                }
                serverTime = parseDateTime(timetable.getJSONObject(i).getString("generated")).getTime();
            }
            final ResultHeader header = new ResultHeader(NetworkId.VRS, SERVER_PRODUCT, null, null, serverTime,
                    null);
            return new NearbyLocationsResult(header, locations);
        } catch (final JSONException x) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
        } catch (final ParseException e) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, e);
        }
    }

    // VRS does not show LongDistanceTrains departures. Parameter p for product
    // filter is supported, but LongDistanceTrains filter seems to be ignored.
    // TODO equivs not supported; JSON result would support multiple timetables
    @Override
    public QueryDeparturesResult queryDepartures(final String stationId, @Nullable Date time, int maxDepartures,
            boolean equivs) throws IOException {
        checkNotNull(Strings.emptyToNull(stationId));

        // g=p means group by product; not used here
        // d=minutes overwrites c=count and returns departures for the next d minutes
        final HttpUrl.Builder url = API_BASE.newBuilder();
        url.addQueryParameter("eID", "tx_vrsinfo_ass2_timetable");
        url.addQueryParameter("i", stationId);
        url.addQueryParameter("c", Integer.toString(maxDepartures));
        if (time != null) {
            url.addQueryParameter("t", formatDate(time));
        }
        final CharSequence page = httpClient.get(url.build());

        try {
            final JSONObject head = new JSONObject(page.toString());
            final String error = Strings.emptyToNull(head.optString("error", "").trim());
            if (error != null) {
                if (error.equals("ASS2-Server lieferte leere Antwort."))
                    return new QueryDeparturesResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryDeparturesResult.Status.SERVICE_DOWN);
                else if (error.equals("Leere ASS-ID und leere Koordinate"))
                    return new QueryDeparturesResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryDeparturesResult.Status.INVALID_STATION);
                else if (error.equals("Keine Abfahrten gefunden."))
                    return new QueryDeparturesResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryDeparturesResult.Status.INVALID_STATION);
                else
                    throw new IllegalStateException("unknown error: " + error);
            }
            final JSONArray timetable = head.getJSONArray("timetable");
            final ResultHeader header = new ResultHeader(NetworkId.VRS, SERVER_PRODUCT);
            final QueryDeparturesResult result = new QueryDeparturesResult(header);
            // for all stations
            if (timetable.length() == 0) {
                return new QueryDeparturesResult(header, QueryDeparturesResult.Status.INVALID_STATION);
            }
            for (int iStation = 0; iStation < timetable.length(); iStation++) {
                final List<Departure> departures = new ArrayList<Departure>();
                final JSONObject station = timetable.getJSONObject(iStation);
                final Location location = parseLocationAndPosition(station.getJSONObject("stop")).location;
                final JSONArray events = station.getJSONArray("events");
                final List<LineDestination> lines = new ArrayList<LineDestination>();
                // for all departures
                for (int iEvent = 0; iEvent < events.length(); iEvent++) {
                    final JSONObject event = events.getJSONObject(iEvent);
                    Date plannedTime = null;
                    Date predictedTime = null;
                    if (event.has("departureScheduled")) {
                        plannedTime = parseDateTime(event.getString("departureScheduled"));
                        predictedTime = parseDateTime(event.getString("departure"));
                    } else {
                        plannedTime = parseDateTime(event.getString("departure"));
                    }
                    final JSONObject lineObj = event.getJSONObject("line");
                    final Line line = parseLine(lineObj);
                    Position position = null;
                    final JSONObject post = event.optJSONObject("post");
                    if (post != null) {
                        final String postName = post.getString("name");
                        for (Pattern pattern : NAME_WITH_POSITION_PATTERNS) {
                            Matcher matcher = pattern.matcher(postName);
                            if (matcher.matches()) {
                                position = new Position(matcher.group(2));
                                break;
                            }
                        }
                        if (position == null)
                            log.info("Could not extract position from '{}'", postName);
                    }
                    final Location destination = new Location(LocationType.STATION, null /* id */, null /* place */,
                            lineObj.getString("direction"));

                    final LineDestination lineDestination = new LineDestination(line, destination);
                    if (!lines.contains(lineDestination)) {
                        lines.add(lineDestination);
                    }
                    final Departure d = new Departure(plannedTime, predictedTime, line, position, destination, null,
                            null);
                    departures.add(d);
                }

                queryLinesForStation(location.id, lines);

                result.stationDepartures.add(new StationDepartures(location, departures, lines));
            }

            return result;
        } catch (final JSONException x) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
        } catch (final ParseException e) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, e);
        }
    }

    private void queryLinesForStation(String stationId, List<LineDestination> lineDestinations) throws IOException {
        Set<String> lineNumbersAlreadyKnown = new HashSet<String>();
        for (LineDestination lineDestionation : lineDestinations) {
            lineNumbersAlreadyKnown.add(lineDestionation.line.label);
        }
        final HttpUrl.Builder url = API_BASE.newBuilder();
        url.addQueryParameter("eID", "tx_vrsinfo_his_info");
        url.addQueryParameter("i", stationId);

        final CharSequence page = httpClient.get(url.build());

        try {
            final JSONObject head = new JSONObject(page.toString());
            final JSONObject his = head.optJSONObject("his");
            if (his != null) {
                final JSONArray lines = his.optJSONArray("lines");
                if (lines != null) {
                    for (int iLine = 0; iLine < lines.length(); iLine++) {
                        final JSONObject line = lines.getJSONObject(iLine);
                        final String number = processLineNumber(line.getString("number"));
                        if (lineNumbersAlreadyKnown.contains(number)) {
                            continue;
                        }
                        final Product product = productFromLineNumber(number);
                        String direction = null;
                        final JSONArray postings = line.optJSONArray("postings");
                        if (postings != null) {
                            for (int iPosting = 0; iPosting < postings.length(); iPosting++) {
                                final JSONObject posting = (JSONObject) postings.get(iPosting);
                                direction = posting.getString("direction");
                                lineDestinations.add(new LineDestination(
                                        new Line(null /* id */, NetworkId.VRS.toString(), product, number,
                                                lineStyle("vrs", product, number)),
                                        new Location(LocationType.STATION, null /* id */, null /* place */,
                                                direction)));
                            }
                        } else {
                            lineDestinations.add(
                                    new LineDestination(new Line(null /* id */, NetworkId.VRS.toString(), product,
                                            number, lineStyle("vrs", product, number)), null /* direction */));
                        }
                    }
                }
            }
        } catch (final JSONException x) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
        }
        Collections.sort(lineDestinations, new LineDestinationComparator());
    }

    private static class LineDestinationComparator implements Comparator<LineDestination> {
        @Override
        public int compare(LineDestination o1, LineDestination o2) {
            return o1.line.compareTo(o2.line);
        }
    }

    @Override
    public SuggestLocationsResult suggestLocations(final CharSequence constraint) throws IOException {
        // sc = station count
        final int sc = 10;
        // ac = address count
        final int ac = 5;
        // pc = points of interest count
        final int pc = 5;
        // t = sap (stops and/or addresses and/or pois)
        final HttpUrl.Builder url = API_BASE.newBuilder();
        url.addQueryParameter("eID", "tx_vrsinfo_ass2_objects");
        url.addQueryParameter("sc", Integer.toString(sc));
        url.addQueryParameter("ac", Integer.toString(ac));
        url.addQueryParameter("pc", Integer.toString(pc));
        url.addQueryParameter("t", "sap");
        url.addQueryParameter("q", constraint.toString());

        final CharSequence page = httpClient.get(url.build());

        try {
            final List<SuggestedLocation> locations = new ArrayList<>();

            final JSONObject head = new JSONObject(page.toString());
            final String error = Strings.emptyToNull(head.optString("error", "").trim());
            if (error != null) {
                if (error.equals("ASS2-Server lieferte leere Antwort."))
                    return new SuggestLocationsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            SuggestLocationsResult.Status.SERVICE_DOWN);
                else if (error.equals("Leere Suche"))
                    return new SuggestLocationsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT), locations);
                else
                    throw new IllegalStateException("unknown error: " + error);
            }
            final JSONArray stops = head.optJSONArray("stops");
            final JSONArray addresses = head.optJSONArray("addresses");
            final JSONArray pois = head.optJSONArray("pois");

            final int nStops = stops.length();
            for (int iStop = 0; iStop < nStops; iStop++) {
                final JSONObject stop = stops.optJSONObject(iStop);
                final Location location = parseLocationAndPosition(stop).location;
                locations.add(new SuggestedLocation(location, sc + ac + pc - iStop));
            }

            final int nAddresses = addresses.length();
            for (int iAddress = 0; iAddress < nAddresses; iAddress++) {
                final JSONObject address = addresses.optJSONObject(iAddress);
                final Location location = parseLocationAndPosition(address).location;
                locations.add(new SuggestedLocation(location, ac + pc - iAddress));
            }

            final int nPois = pois.length();
            for (int iPoi = 0; iPoi < nPois; iPoi++) {
                final JSONObject poi = pois.optJSONObject(iPoi);
                final Location location = parseLocationAndPosition(poi).location;
                locations.add(new SuggestedLocation(location, pc - iPoi));
            }

            final ResultHeader header = new ResultHeader(NetworkId.VRS, SERVER_PRODUCT);
            return new SuggestLocationsResult(header, locations);
        } catch (final JSONException x) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
        }
    }

    // http://www.vrsinfo.de/index.php?eID=tx_vrsinfo_ass2_router&c=1&f=2071&t=1504&d=2015-02-11T11%3A47%3A20%2B01%3A00
    // c: count (default: 5)
    // f: from (id or lat,lon as float)
    // v: via (id or lat,lon as float)
    // t: to (id or lat,lon as float)
    // a/d: date (default now)
    // vt: via time in minutes - not supported by ffi
    // s: t => allow surcharge
    // p: products as comma separated list
    // o: options:
    // 'v' for showing via stations
    // 'd' for showing walking directions
    // 'p' for showing exact geographical coordinates along the route
    // walkSpeed not supported.
    // accessibility not supported.
    // options not supported.
    @Override
    public QueryTripsResult queryTrips(final Location from, final @Nullable Location via, final Location to,
            Date date, boolean dep, final @Nullable Set<Product> products, final @Nullable Optimize optimize,
            final @Nullable WalkSpeed walkSpeed, final @Nullable Accessibility accessibility,
            @Nullable Set<Option> options) throws IOException {
        // The EXACT_POINTS feature generates an about 50% bigger API response, probably well compressible.
        final boolean EXACT_POINTS = true;
        final List<Location> ambiguousFrom = new ArrayList<>();
        String fromString = generateLocation(from, ambiguousFrom);

        final List<Location> ambiguousVia = new ArrayList<>();
        String viaString = generateLocation(via, ambiguousVia);

        final List<Location> ambiguousTo = new ArrayList<>();
        String toString = generateLocation(to, ambiguousTo);

        if (!ambiguousFrom.isEmpty() || !ambiguousVia.isEmpty() || !ambiguousTo.isEmpty()) {
            return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                    ambiguousFrom.isEmpty() ? null : ambiguousFrom, ambiguousVia.isEmpty() ? null : ambiguousVia,
                    ambiguousTo.isEmpty() ? null : ambiguousTo);
        }

        if (fromString == null) {
            return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                    QueryTripsResult.Status.UNKNOWN_FROM);
        }
        if (via != null && viaString == null) {
            return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                    QueryTripsResult.Status.UNKNOWN_VIA);
        }
        if (toString == null) {
            return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                    QueryTripsResult.Status.UNKNOWN_TO);
        }

        final HttpUrl.Builder url = API_BASE.newBuilder();
        url.addQueryParameter("eID", "tx_vrsinfo_ass2_router");
        url.addQueryParameter("f", fromString);
        url.addQueryParameter("t", toString);
        if (via != null) {
            url.addQueryParameter("v", via.id);
        }
        url.addQueryParameter(dep ? "d" : "a", formatDate(date));
        url.addQueryParameter("s", "t");
        if (!products.equals(Product.ALL))
            url.addQueryParameter("p", generateProducts(products));
        url.addQueryParameter("o", "v" + (EXACT_POINTS ? "p" : ""));

        final CharSequence page = httpClient.get(url.build());

        try {
            final List<Trip> trips = new ArrayList<>();
            final JSONObject head = new JSONObject(page.toString());
            final String error = Strings.emptyToNull(head.optString("error", "").trim());
            if (error != null) {
                if (error.equals("ASS2-Server lieferte leere Antwort."))
                    return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryTripsResult.Status.SERVICE_DOWN);
                else if (error.equals("Zeitberschreitung bei der Verbindung zum ASS2-Server"))
                    return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryTripsResult.Status.SERVICE_DOWN);
                else if (error.equals("Server Error"))
                    return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryTripsResult.Status.SERVICE_DOWN);
                else if (error.equals("Keine Verbindungen gefunden."))
                    return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryTripsResult.Status.NO_TRIPS);
                else if (error.startsWith("Keine Verbindung gefunden."))
                    return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryTripsResult.Status.NO_TRIPS);
                else if (error.equals("Origin invalid."))
                    return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryTripsResult.Status.UNKNOWN_FROM);
                else if (error.equals("Via invalid."))
                    return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryTripsResult.Status.UNKNOWN_VIA);
                else if (error.equals("Destination invalid."))
                    return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryTripsResult.Status.UNKNOWN_TO);
                else if (error.equals("Fehlerhaftes Ziel"))
                    return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryTripsResult.Status.UNKNOWN_TO);
                else if (error.equals("Produkt ungltig."))
                    return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryTripsResult.Status.NO_TRIPS);
                else if (error.equals("Keine Route."))
                    return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
                            QueryTripsResult.Status.NO_TRIPS);
                else
                    throw new IllegalStateException("unknown error: " + error);
            }
            final JSONArray routes = head.getJSONArray("routes");
            final Context context = new Context();
            // for all routes
            for (int iRoute = 0; iRoute < routes.length(); iRoute++) {
                final JSONObject route = routes.getJSONObject(iRoute);
                final JSONArray segments = route.getJSONArray("segments");
                List<Leg> legs = new ArrayList<>();
                Location tripOrigin = null;
                Location tripDestination = null;
                // for all segments
                for (int iSegment = 0; iSegment < segments.length(); iSegment++) {
                    final JSONObject segment = segments.getJSONObject(iSegment);
                    final String type = segment.getString("type");
                    final JSONObject origin = segment.getJSONObject("origin");
                    final LocationWithPosition segmentOriginLocationWithPosition = parseLocationAndPosition(origin);
                    Location segmentOrigin = segmentOriginLocationWithPosition.location;
                    final Position segmentOriginPosition = segmentOriginLocationWithPosition.position;
                    if (iSegment == 0) {
                        // special case: first origin is an address
                        if (from.type == LocationType.ADDRESS) {
                            segmentOrigin = from;
                        }
                        tripOrigin = segmentOrigin;
                    }
                    final JSONObject destination = segment.getJSONObject("destination");
                    final LocationWithPosition segmentDestinationLocationWithPosition = parseLocationAndPosition(
                            destination);
                    Location segmentDestination = segmentDestinationLocationWithPosition.location;
                    final Position segmentDestinationPosition = segmentDestinationLocationWithPosition.position;
                    if (iSegment == segments.length() - 1) {
                        // special case: last destination is an address
                        if (to.type == LocationType.ADDRESS) {
                            segmentDestination = to;
                        }
                        tripDestination = segmentDestination;
                    }
                    List<Stop> intermediateStops = new ArrayList<>();
                    final JSONArray vias = segment.optJSONArray("vias");
                    if (vias != null) {
                        for (int iVia = 0; iVia < vias.length(); iVia++) {
                            final JSONObject viaJsonObject = vias.getJSONObject(iVia);
                            final LocationWithPosition viaLocationWithPosition = parseLocationAndPosition(
                                    viaJsonObject);
                            final Location viaLocation = viaLocationWithPosition.location;
                            final Position viaPosition = viaLocationWithPosition.position;
                            Date arrivalPlanned = null;
                            Date arrivalPredicted = null;
                            if (viaJsonObject.has("arrivalScheduled")) {
                                arrivalPlanned = parseDateTime(viaJsonObject.getString("arrivalScheduled"));
                                arrivalPredicted = (viaJsonObject.has("arrival"))
                                        ? parseDateTime(viaJsonObject.getString("arrival"))
                                        : null;
                            } else if (segment.has("arrival")) {
                                arrivalPlanned = parseDateTime(viaJsonObject.getString("arrival"));
                            }
                            final Stop intermediateStop = new Stop(viaLocation, false /* arrival */, arrivalPlanned,
                                    arrivalPredicted, viaPosition, viaPosition);
                            intermediateStops.add(intermediateStop);
                        }
                    }
                    Date departurePlanned = null;
                    Date departurePredicted = null;
                    if (segment.has("departureScheduled")) {
                        departurePlanned = parseDateTime(segment.getString("departureScheduled"));
                        departurePredicted = (segment.has("departure"))
                                ? parseDateTime(segment.getString("departure"))
                                : null;
                        if (iSegment == 0) {
                            context.departure(departurePredicted);
                        }
                    } else if (segment.has("departure")) {
                        departurePlanned = parseDateTime(segment.getString("departure"));
                        if (iSegment == 0) {
                            context.departure(departurePlanned);
                        }
                    }
                    Date arrivalPlanned = null;
                    Date arrivalPredicted = null;
                    if (segment.has("arrivalScheduled")) {
                        arrivalPlanned = parseDateTime(segment.getString("arrivalScheduled"));
                        arrivalPredicted = (segment.has("arrival")) ? parseDateTime(segment.getString("arrival"))
                                : null;
                        if (iSegment == segments.length() - 1) {
                            context.arrival(arrivalPredicted);
                        }
                    } else if (segment.has("arrival")) {
                        arrivalPlanned = parseDateTime(segment.getString("arrival"));
                        if (iSegment == segments.length() - 1) {
                            context.arrival(arrivalPlanned);
                        }
                    }
                    long traveltime = segment.getLong("traveltime");
                    long distance = segment.optLong("distance", 0);
                    Line line = null;
                    String direction = null;
                    JSONObject lineObject = segment.optJSONObject("line");
                    if (lineObject != null) {
                        line = parseLine(lineObject);
                        direction = lineObject.optString("direction", null);
                    }
                    StringBuilder message = new StringBuilder();
                    JSONArray infos = segment.optJSONArray("infos");
                    if (infos != null) {
                        for (int k = 0; k < infos.length(); k++) {
                            // TODO there can also be a "header" string
                            if (k > 0) {
                                message.append(", ");
                            }
                            message.append(infos.getJSONObject(k).getString("text"));
                        }
                    }

                    List<Point> points = new ArrayList<>();
                    points.add(new Point(segmentOrigin.lat, segmentOrigin.lon));
                    if (EXACT_POINTS && segment.has("polygon")) {
                        parsePolygon(segment.getString("polygon"), points);
                    } else {
                        for (Stop intermediateStop : intermediateStops) {
                            points.add(new Point(intermediateStop.location.lat, intermediateStop.location.lon));
                        }
                    }
                    points.add(new Point(segmentDestination.lat, segmentDestination.lon));
                    if (type.equals("walk")) {
                        if (departurePlanned == null)
                            departurePlanned = legs.get(legs.size() - 1).getArrivalTime();
                        if (arrivalPlanned == null)
                            arrivalPlanned = new Date(departurePlanned.getTime() + traveltime * 1000);

                        legs.add(new Trip.Individual(Trip.Individual.Type.WALK, segmentOrigin, departurePlanned,
                                segmentDestination, arrivalPlanned, points, (int) distance));
                    } else if (type.equals("publicTransport")) {
                        legs.add(new Trip.Public(line,
                                direction != null
                                        ? new Location(LocationType.STATION, null /* id */, null /* place */,
                                                direction)
                                        : null,
                                new Stop(segmentOrigin, true /* departure */, departurePlanned, departurePredicted,
                                        segmentOriginPosition, segmentOriginPosition),
                                new Stop(segmentDestination, false /* departure */, arrivalPlanned,
                                        arrivalPredicted, segmentDestinationPosition, segmentDestinationPosition),
                                intermediateStops, points, Strings.emptyToNull(message.toString())));
                    } else {
                        throw new IllegalStateException("unhandled type: " + type);
                    }
                }
                int changes = route.getInt("changes");
                List<Fare> fares = parseFare(route.optJSONObject("costs"));

                trips.add(new Trip(null /* id */, tripOrigin, tripDestination, legs, fares, null /* capacity */,
                        changes));
            }
            long serverTime = parseDateTime(head.getString("generated")).getTime();
            final ResultHeader header = new ResultHeader(NetworkId.VRS, SERVER_PRODUCT, null, null, serverTime,
                    null);
            context.from = from;
            context.to = to;
            context.via = via;
            context.products = products;
            if (trips.size() == 1) {
                if (dep)
                    context.disableLater();
                else
                    context.disableEarlier();
            }
            return new QueryTripsResult(header, url.build().toString(), from, via, to, context, trips);
        } catch (final JSONException x) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
        } catch (final ParseException e) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, e);
        }
    }

    private static List<Fare> parseFare(final JSONObject costs) throws JSONException {
        List<Fare> fares = new ArrayList<>();
        if (costs != null) {
            final String name = costs.optString("name", null); // e.g. "VRS-Tarif", "NRW-Tarif"
            final String text = costs.optString("text", null); // e.g. "Preisstufe 4 [RegioTicket] 7,70 ",
            // "VRR-Tarif! (Details: www.vrr.de)", "17,30  (2.Kl) / PauschalpreisTickets gltig"
            float price = (float) costs.optDouble("price", 0.0); // e.g. 7.7 or not existent outside VRS
            // long zone = costs.getLong("zone"); // e.g. 2600
            final String level = costs.has("level") ? "Preisstufe " + costs.getString("level") : null; // e.g.
                                                                                                       // "4"

            if (name != null && price != 0.0 && level != null) {
                fares.add(new Fare(name, Fare.Type.ADULT, Currency.getInstance("EUR"), price, level,
                        null /* units */));
            } else if (name != null && name.equals("NRW-Tarif") && text != null) {
                Matcher matcher = nrwTarifPattern.matcher(text);
                if (matcher.find()) {
                    fares.add(new Fare(name, Fare.Type.ADULT, Currency.getInstance("EUR"),
                            Float.parseFloat(matcher.group(0).replace(",", ".")), null /* level */,
                            null /* units */));
                }
            }
        }
        return fares;
    }

    protected static void parsePolygon(final String polygonStr, final List<Point> polygonArr) {
        if (polygonStr != null && !polygonStr.isEmpty()) {
            String pointsArr[] = polygonStr.split("\\s");
            for (String point : pointsArr) {
                String latlon[] = point.split(",");
                polygonArr.add(new Point((int) Math.round(Double.parseDouble(latlon[0]) * 1E6),
                        (int) Math.round(Double.parseDouble(latlon[1]) * 1E6)));
            }
        }
    }

    @Override
    public QueryTripsResult queryMoreTrips(QueryTripsContext context, boolean later) throws IOException {
        Context ctx = (Context) context;
        if (later) {
            return queryTrips(ctx.from, ctx.via, ctx.to, ctx.getLastDeparture(), true, ctx.products, null, null,
                    null, null);
        } else {
            return queryTrips(ctx.from, ctx.via, ctx.to, ctx.getFirstArrival(), false, ctx.products, null, null,
                    null, null);
        }
    }

    @Override
    public Style lineStyle(final @Nullable String network, final @Nullable Product product,
            final @Nullable String label) {
        if (product == Product.BUS && label != null && label.startsWith("SB")) {
            return super.lineStyle(network, product, "SB");
        }

        return super.lineStyle(network, product, label);
    }

    @Override
    public Point[] getArea() throws IOException {
        return new Point[] { new Point(50937531, 6960279) };
    }

    private static Product productFromLineNumber(String number) {
        if (number.startsWith("I") || number.startsWith("E")) {
            return Product.HIGH_SPEED_TRAIN;
        } else if (number.startsWith("R") || number.startsWith("MRB") || number.startsWith("DPN")) {
            return Product.REGIONAL_TRAIN;
        } else if (number.startsWith("S") && !number.startsWith("SB") && !number.startsWith("SEV")) {
            return Product.SUBURBAN_TRAIN;
        } else if (number.startsWith("U")) {
            return Product.SUBWAY;
        } else if (number.length() <= 2 && !number.startsWith("N")) {
            return Product.TRAM;
        } else {
            return Product.BUS;
        }
    }

    private Line parseLine(JSONObject line) throws JSONException {
        final String number = processLineNumber(line.getString("number"));
        final Product productObj = parseProduct(line.getString("product"), number);
        final Style style = lineStyle("vrs", productObj, number);
        return new Line(null /* id */, NetworkId.VRS.toString(), productObj, number, style);
    }

    private static String processLineNumber(final String number) {
        if (number.startsWith("AST ") || number.startsWith("VRM ") || number.startsWith("VRR ")) {
            return number.substring(4);
        } else if (number.startsWith("AST") || number.startsWith("VRM") || number.startsWith("VRR")) {
            return number.substring(3);
        } else if (number.startsWith("TaxiBus ")) {
            return number.substring(8);
        } else if (number.startsWith("TaxiBus")) {
            return number.substring(7);
        } else if (number.equals("Schienen-Ersatz-Verkehr (SEV)")) {
            return "SEV";
        } else {
            return number;
        }
    }

    private static Product parseProduct(String product, String number) {
        if (product.equals("LongDistanceTrains")) {
            return Product.HIGH_SPEED_TRAIN;
        } else if (product.equals("RegionalTrains")) {
            return Product.REGIONAL_TRAIN;
        } else if (product.equals("SuburbanTrains")) {
            return Product.SUBURBAN_TRAIN;
        } else if (product.equals("Underground") || product.equals("LightRail") && number.startsWith("U")) {
            return Product.SUBWAY;
        } else if (product.equals("LightRail")) {
            // note that also the Skytrain (Flughafen Dsseldorf Bahnhof - Flughafen Dsseldorf Terminan
            // and Schwebebahn Wuppertal (line 60) are both returned as product "LightRail".
            return Product.TRAM;
        } else if (product.equals("Bus") || product.equals("CommunityBus")
                || product.equals("RailReplacementServices")) {
            return Product.BUS;
        } else if (product.equals("Boat")) {
            return Product.FERRY;
        } else if (product.equals("OnDemandServices")) {
            return Product.ON_DEMAND;
        } else {
            throw new IllegalArgumentException("unknown product: '" + product + "'");
        }
    }

    private static String generateProducts(Set<Product> products) {
        StringBuilder ret = new StringBuilder();
        Iterator<Product> it = products.iterator();
        while (it.hasNext()) {
            final Product product = it.next();
            final String productStr = generateProduct(product);
            if (ret.length() > 0 && !ret.substring(ret.length() - 1).equals(",") && !productStr.isEmpty()) {
                ret.append(",");
            }
            ret.append(productStr);
        }
        return ret.toString();
    }

    private static String generateProduct(Product product) {
        switch (product) {
        case BUS:
            // can't filter for RailReplacementServices although this value is valid in API responses
            return "Bus,CommunityBus";
        case CABLECAR:
            // no mapping in VRS
            return "";
        case FERRY:
            return "Boat";
        case HIGH_SPEED_TRAIN:
            return "LongDistanceTrains";
        case ON_DEMAND:
            return "OnDemandServices";
        case REGIONAL_TRAIN:
            return "RegionalTrains";
        case SUBURBAN_TRAIN:
            return "SuburbanTrains";
        case SUBWAY:
            return "LightRail,Underground";
        case TRAM:
            return "LightRail";
        default:
            throw new IllegalArgumentException("unknown product: '" + product + "'");
        }
    }

    public static LocationWithPosition parseLocationAndPosition(JSONObject location) throws JSONException {
        final LocationType locationType;
        String id = null;
        String name = null;
        String position = null;
        if (location.has("id")) {
            locationType = LocationType.STATION;
            id = location.getString("id");
            name = location.getString("name");
            for (Pattern pattern : NAME_WITH_POSITION_PATTERNS) {
                Matcher matcher = pattern.matcher(name);
                if (matcher.matches()) {
                    name = matcher.group(1);
                    position = matcher.group(2);
                    break;
                }
            }
        } else if (location.has("street")) {
            locationType = LocationType.ADDRESS;
            name = (location.getString("street") + " " + location.getString("number")).trim();
        } else if (location.has("name")) {
            locationType = LocationType.POI;
            id = location.getString("tempId");
            name = location.getString("name");
        } else if (location.has("x") && location.has("y")) {
            locationType = LocationType.ANY;
        } else {
            throw new IllegalArgumentException("unknown location JSONObject: " + location);
        }
        String place = location.optString("city", null);
        if (place != null) {
            if (location.has("district") && !location.getString("district").isEmpty()) {
                place += "-" + location.getString("district");
            }
        }
        final int lat = (int) Math.round(location.optDouble("x", 0) * 1E6);
        final int lon = (int) Math.round(location.optDouble("y", 0) * 1E6);
        return new LocationWithPosition(new Location(locationType, id, lat, lon, place, name),
                position != null ? new Position(position.substring(position.lastIndexOf(" ") + 1)) : null);
    }

    private String generateLocation(Location loc, List<Location> ambiguous) throws IOException {
        if (loc == null) {
            return null;
        } else if (loc.id != null) {
            return loc.id;
        } else if (loc.lat != 0 && loc.lon != 0) {
            return String.format(Locale.ENGLISH, "%f,%f", loc.lat / 1E6, loc.lon / 1E6);
        } else {
            SuggestLocationsResult suggestLocationsResult = suggestLocations(loc.name);
            final List<Location> suggestedLocations = suggestLocationsResult.getLocations();
            if (suggestedLocations.size() == 1) {
                return suggestedLocations.get(0).id;
            } else {
                ambiguous.addAll(suggestedLocations);
                return null;
            }
        }
    }

    private final static String formatDate(final Date time) {
        final Calendar c = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
        c.setTime(time);
        final int year = c.get(Calendar.YEAR);
        final int month = c.get(Calendar.MONTH) + 1;
        final int day = c.get(Calendar.DAY_OF_MONTH);
        final int hour = c.get(Calendar.HOUR_OF_DAY);
        final int minute = c.get(Calendar.MINUTE);
        final int second = c.get(Calendar.SECOND);
        return String.format(Locale.ENGLISH, "%04d-%02d-%02dT%02d:%02d:%02dZ", year, month, day, hour, minute,
                second);
    }

    private final static Date parseDateTime(final String dateTimeStr) throws ParseException {
        return new SimpleDateFormat("yyyy-MM-dd'T'kk:mm:ssZ")
                .parse(dateTimeStr.substring(0, dateTimeStr.lastIndexOf(':')) + "00");
    }
}