#![doc = include_str!("../README.md")]

mod error;
use chrono::{DateTime, Utc};
use error::*;

use async_trait::async_trait;
use rcore::{
    IntermediateLocation, Journey, JourneysOptions, JourneysResponse, Leg, Line, Location, Mode,
    Operator, Place, Product, ProductsSelection, Provider, Requester, RequesterBuilder,
    ReqwestRequester, ReqwestRequesterBuilder, Station, Stop,
};
use url::Url;

use std::borrow::Cow;
use std::collections::HashSet;
use std::str::FromStr;

pub const TRANSITOUS_URL: &str = "https://api.transitous.org/";

#[derive(Clone)]
pub struct MotisClient {
    configuration: motis::apis::configuration::Configuration,
}

impl MotisClient {
    pub fn new(url: Url, requester: ReqwestRequesterBuilder) -> Self {
        Self {
            configuration: Self::configuration(url, requester),
        }
    }

    fn configuration(
        url: Url,
        requester: ReqwestRequesterBuilder,
    ) -> motis::apis::configuration::Configuration {
        motis::apis::configuration::Configuration {
            // XXX: `url.to_string()` does not contain the trailing `/`, but it is required by the OpenAPI generated code.
            base_path: url.to_string() + "/",
            user_agent: Some(format!(
                "{} {} ({})",
                env!("CARGO_PKG_NAME"),
                env!("CARGO_PKG_VERSION"),
                env!("CARGO_PKG_REPOSITORY")
            )),
            client: requester.build().inner().clone(),
            ..Default::default()
        }
    }
}

#[cfg_attr(feature = "rt-multi-thread", async_trait)]
#[cfg_attr(not(feature = "rt-multi-thread"), async_trait(?Send))]
impl Provider<ReqwestRequester> for MotisClient {
    type Error = Error;

    async fn journeys(
        &self,
        from: rcore::Place,
        to: rcore::Place,
        opts: rcore::JourneysOptions,
    ) -> Result<
        rcore::JourneysResponse,
        rcore::Error<<ReqwestRequester as Requester>::Error, Self::Error>,
    > {
        let from_id = match from {
            Place::Station(s) => s.id,
            Place::Location(Location::Address {
                address: _,
                latitude,
                longitude,
            }) => format!("{},{},0", latitude, longitude),
            Place::Location(Location::Point {
                id: _,
                name: _,
                poi: _,
                latitude,
                longitude,
            }) => format!("{},{},0", latitude, longitude),
        };
        let to_id = match to {
            Place::Station(s) => s.id,
            Place::Location(Location::Address {
                address: _,
                latitude,
                longitude,
            }) => format!("{},{},0", latitude, longitude),
            Place::Location(Location::Point {
                id: _,
                name: _,
                poi: _,
                latitude,
                longitude,
            }) => format!("{},{},0", latitude, longitude),
        };

        let time = opts
            .departure
            .or(opts.arrival)
            .map(|d| d.with_timezone(&chrono::Utc).format("%FT%TZ").to_string());
        let cursor = opts.earlier_than.or(opts.later_than);

        let transfers = match opts.transfers {
            rcore::TransferOptions::Unlimited => None,
            rcore::TransferOptions::Limited(i) => Some(i as i32),
        };

        let result = motis::apis::routing_api::plan(
            &self.configuration,
            &from_id,
            &to_id,
            true, /* detailed transfers */ /* TODO: While we currently don't need detailed transfer information (e.g. steps), there is currently a bug in MOTIS where setting this to false returns the wrong scheduled time for transfers. */
            None, /* via */
            None, /* via minimum stay */
            time,
            transfers,
            None, /* max hours */
            Some(opts.transfer_time.num_minutes() as i32),
            None, /* additional transfer time */
            None, /* transfer time factor */
            None, /* max matching distance */
            None, /* pedestrial profile */
            None, /* pedestrial speed */
            None, /* cycling speed */
            None, /* elevation cost */
            None, /* use routed transfers */
            None, /* join interlined legs */
            Some(railway_products_to_motis_mode(opts.products)),
            None, /* direct modes */
            None, /* pre transit modes */
            None, /* post transit modes */
            None, /* direct rental form factors */
            None, /* pre transit rental form factors */
            None, /* post transit rental form factors */
            None, /* direct rental propulsion types */
            None, /* pre transit rental propulsion types */
            None, /* post transit rental propulsion types */
            None, /* direct rental providers */
            None, /* direct rental provider groups */
            None, /* pre transit rental providers */
            None, /* pre transit rental provider groups */
            None, /* post transit rental providers */
            None, /* post transit rental provider groups */
            None, /* ignonre direct rental return contraints */
            None, /* ignonre pre transit rental return contraints */
            None, /* ignonre post transit rental return contraints */
            Some(opts.results as i32),
            None, /* max itineraries */
            cursor.as_deref(),
            None, /* timetable view */
            Some(opts.arrival.is_some()),
            None, /* search window */
            None, /* bike transport */
            None, /* car transport */
            None, /* max pre transit time */
            None, /* max post transit time */
            None, /* max direct time */
            None, /* fastest direct factor */
            None, /* timeout */
            None, /* passengers */
            None, /* luggage */
            None, /* slow direct */
            None, /* fastest slow direct factor */
            None, /* with fares */
            None, /* with scheduled skipped stops */
            opts.language.map(|l| vec![l]),
            None, /* algorithm */
        )
        .await
        .map_err(|e| {
            rcore::Error::<<ReqwestRequester as Requester>::Error, Self::Error>::Provider(
                crate::Error::Plan(e),
            )
        })?;

        Ok(plan_response_to_journeys_response(result))
    }

    async fn locations(
        &self,
        opts: rcore::LocationsOptions,
    ) -> Result<
        rcore::LocationsResponse,
        rcore::Error<<ReqwestRequester as Requester>::Error, Self::Error>,
    > {
        let matches = motis::apis::geocode_api::geocode(
            &self.configuration,
            &opts.query,
            opts.language.map(|l| vec![l]),
            None, // Location type
            None, // Place of querying user
            None, // Bias for returning results close to user.
        )
        .await
        .map_err(|e| {
            rcore::Error::<<ReqwestRequester as Requester>::Error, Self::Error>::Provider(
                crate::Error::Geocoding(e),
            )
        })?;

        Ok(matches.into_iter().map(match_to_place).collect())
    }

    async fn station_board(
        &self,
        _place: Place,
        _kind: rcore::StationBoardKind,
        _opts: rcore::StationBoardOptions,
    ) -> Result<
        rcore::StationBoardResponse,
        rcore::Error<<ReqwestRequester as Requester>::Error, Self::Error>,
    > {
        Err(rcore::Error::NotImplemented)
    }

    // Note: This method is not guaranteed to find the same journey. But I think this is the best we can do as search.ch does not provide a refresh-API.
    async fn refresh_journey(
        &self,
        journey: &Journey,
        _opts: rcore::RefreshJourneyOptions,
    ) -> Result<
        rcore::RefreshJourneyResponse,
        rcore::Error<<ReqwestRequester as Requester>::Error, Self::Error>,
    > {
        // Note: Each journey should have at least one leg.
        let from = &journey.legs[0].origin;
        let to = &journey.legs[journey.legs.len() - 1].destination;
        let jopts = JourneysOptions {
            // In most cases, the journey to refresh is likely the first one. Relax this requirement a bit.
            results: 3,
            departure: journey.legs[0].planned_departure,
            ..Default::default()
        };
        self.journeys(from.clone(), to.clone(), jopts)
            .await?
            .journeys
            .into_iter()
            .find(|j| j.id == journey.id)
            .clone()
            .ok_or(rcore::Error::Provider(Error::RefreshJourneyNotFound))
    }
}

fn plan_response_to_journeys_response(r: motis::models::Plan200Response) -> JourneysResponse {
    JourneysResponse {
        earlier_ref: Some(r.previous_page_cursor),
        later_ref: Some(r.next_page_cursor),
        journeys: r
            .itineraries
            .into_iter()
            .map(itinerary_to_journey)
            .collect(),
    }
}

fn railway_products_to_motis_mode(p: ProductsSelection) -> Vec<motis::models::Mode> {
    HashSet::from(p)
        .into_iter()
        .flat_map(railway_mode_to_motis_mode)
        .collect::<HashSet<_>>()
        .into_iter()
        .collect()
}

fn railway_mode_to_motis_mode(p: rcore::Mode) -> Vec<motis::models::Mode> {
    use motis::models::Mode as MMode;
    match p {
        Mode::HighSpeedTrain => vec![
            MMode::HighspeedRail,
            MMode::LongDistance,
            MMode::RegionalFastRail,
        ],
        Mode::RegionalTrain => vec![MMode::RegionalRail],
        Mode::SuburbanTrain => vec![MMode::Metro],
        Mode::Subway => vec![MMode::Subway],
        Mode::Tram => vec![MMode::Tram],
        Mode::Bus => vec![MMode::Bus, MMode::Coach],
        Mode::Ferry => vec![MMode::Ferry],
        Mode::Cablecar => vec![],
        Mode::OnDemand => vec![],
        Mode::Unknown => vec![
            MMode::Walk,
            MMode::Bike,
            MMode::Car,
            MMode::CarParking,
            MMode::Rental,
            MMode::Transit,
            MMode::Airplane,
            MMode::Rail,
            MMode::NightRail,
            MMode::Other,
        ],
    }
}

fn motis_mode_to_mode(m: &motis::models::Mode) -> Mode {
    match m {
        motis::models::Mode::Walk => Mode::Unknown,
        motis::models::Mode::Bike => Mode::Unknown,
        motis::models::Mode::Car => Mode::Unknown,
        motis::models::Mode::CarParking => Mode::Unknown,
        motis::models::Mode::Rental => Mode::Unknown,
        motis::models::Mode::Transit => Mode::Unknown,
        motis::models::Mode::Tram => Mode::Tram,
        motis::models::Mode::Subway => Mode::Subway,
        motis::models::Mode::Ferry => Mode::Ferry,
        motis::models::Mode::Airplane => Mode::Unknown,
        motis::models::Mode::Metro => Mode::SuburbanTrain,
        motis::models::Mode::Bus => Mode::Bus,
        motis::models::Mode::Coach => Mode::Bus,
        motis::models::Mode::Rail => Mode::Unknown,
        motis::models::Mode::HighspeedRail => Mode::HighSpeedTrain,
        motis::models::Mode::LongDistance => Mode::HighSpeedTrain,
        motis::models::Mode::NightRail => Mode::Unknown,
        motis::models::Mode::RegionalFastRail => Mode::HighSpeedTrain,
        motis::models::Mode::RegionalRail => Mode::RegionalTrain,
        motis::models::Mode::CarDropoff => Mode::Unknown,
        motis::models::Mode::Odm => Mode::OnDemand,
        motis::models::Mode::RideSharing => Mode::OnDemand,
        motis::models::Mode::Flex => Mode::Unknown,
        motis::models::Mode::Suburban => Mode::SuburbanTrain,
        motis::models::Mode::CableCar => Mode::Cablecar,
        motis::models::Mode::Funicular => Mode::Unknown,
        motis::models::Mode::AerialLift => Mode::Unknown,
        motis::models::Mode::ArealLift => Mode::Unknown,
        motis::models::Mode::Other => Mode::Unknown,
    }
}

fn itinerary_to_journey(i: motis::models::Itinerary) -> Journey {
    let mut legs: Vec<_> = i
        .legs
        .into_iter()
        // When searching via latitude/longitude or when searching for a city returns a latitude/longitude pair, Motis starts and ends with walks, where the origin and destination are named "START" and "END".
        // While this may make sense when explicitly searching for latitude/longitude, I don't think this makes sense when searching for a city, as this should instead be interpreted "anywhere in the city" in my opinion.
        // As Railway does not yet support searching for latitude/longitude explicitly either way, remove such legs from itineraries.
        // XXX: Reconsider the removal when Railway supports latitude/longitude searches.
        .filter(|l| l.from.name != "START" && l.to.name != "END")
        .map(motis_leg_to_leg)
        .collect();

    for i in 0..(legs.len() - 1) {
        let next_leg_departure = legs[i + 1].planned_departure.or(legs[i + 1].departure);
        let leg = &mut legs[i];

        if leg
            .planned_arrival
            .or(leg.arrival)
            .is_some_and(|a| next_leg_departure.is_some_and(|d| d < a))
        {
            leg.reachable = false;
        }
    }

    Journey {
        id: legs
            .iter()
            .flat_map(|l| l.trip_id.as_ref())
            .map(|s| &s[..])
            .collect(),
        legs,
        price: None,
    }
}

fn motis_leg_to_leg(l: motis::models::Leg) -> Leg {
    Leg {
        origin: motis_place_to_place(&l.from),
        destination: motis_place_to_place(&l.to),
        departure: DateTime::<Utc>::from_str(&l.start_time)
            .ok()
            .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)),
        planned_departure: DateTime::<Utc>::from_str(&l.scheduled_start_time)
            .ok()
            .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)),
        arrival: DateTime::<Utc>::from_str(&l.end_time)
            .ok()
            .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)),
        planned_arrival: DateTime::<Utc>::from_str(&l.scheduled_end_time)
            .ok()
            .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)),
        reachable: true,
        trip_id: l.trip_id,
        line: if l.mode != motis::models::Mode::Walk {
            Some(Line {
                name: l.route_short_name.clone(),
                fahrt_nr: None,
                mode: motis_mode_to_mode(&l.mode),
                product: Product {
                    mode: motis_mode_to_mode(&l.mode),
                    name: l
                        .route_short_name
                        .clone()
                        .and_then(|n| n.split(' ').next().map(|s| s.to_owned()))
                        .map(Cow::Owned)
                        .unwrap_or(Cow::Borrowed("")),
                    short: l
                        .route_short_name
                        .clone()
                        .and_then(|n| n.split(' ').next().map(|s| s.to_owned()))
                        .map(Cow::Owned)
                        .unwrap_or(Cow::Borrowed("")),
                },
                operator: l
                    .agency_id
                    .and_then(|id| l.agency_name.map(|name| (id, name)))
                    .map(|(id, name)| Operator { id, name }),
                product_name: l
                    .route_short_name
                    .and_then(|n| n.split(' ').next().map(|s| s.to_owned())),
            })
        } else {
            None
        },
        direction: l.headsign,
        arrival_platform: l.to.track.clone(),
        planned_arrival_platform: l.to.scheduled_track.clone(),
        departure_platform: l.from.track.clone(),
        planned_departure_platform: l.from.scheduled_track.clone(),
        frequency: None,
        cancelled: false,
        intermediate_locations: std::iter::once(motis_place_to_intermediate_destination(*l.from))
            .chain(
                l.intermediate_stops
                    .unwrap_or_default()
                    .into_iter()
                    .map(motis_place_to_intermediate_destination),
            )
            .chain(std::iter::once(motis_place_to_intermediate_destination(
                *l.to,
            )))
            .collect(),
        load_factor: None,
        remarks: vec![],
        walking: l.mode == motis::models::Mode::Walk,
        transfer: false,
        distance: l.distance.map(|d| d as u64),
        #[cfg(feature = "polylines")]
        polyline: None,
    }
}

fn motis_place_to_intermediate_destination(p: motis::models::Place) -> IntermediateLocation {
    IntermediateLocation::Stop(Stop {
        place: motis_place_to_place(&p),
        departure: p
            .departure
            .and_then(|d| DateTime::<Utc>::from_str(&d).ok())
            .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)),
        planned_departure: p
            .scheduled_departure
            .and_then(|d| DateTime::<Utc>::from_str(&d).ok())
            .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)),
        arrival: p
            .arrival
            .and_then(|d| DateTime::<Utc>::from_str(&d).ok())
            .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)),
        planned_arrival: p
            .scheduled_arrival
            .and_then(|d| DateTime::<Utc>::from_str(&d).ok())
            .map(|d| d.with_timezone(&chrono_tz::Tz::UTC)),
        arrival_platform: p.track.clone(),
        planned_arrival_platform: p.scheduled_track.clone(),
        departure_platform: p.track,
        planned_departure_platform: p.scheduled_track,
        cancelled: false,
        remarks: vec![],
    })
}

fn motis_place_to_place(p: &motis::models::Place) -> Place {
    if let Some(id) = &p.stop_id {
        Place::Station(Station {
            id: id.clone(),
            name: Some(p.name.clone()),
            location: Some(Location::Point {
                id: Some(id.clone()),
                name: Some(p.name.clone()),
                poi: None,
                latitude: p.lat as f32,
                longitude: p.lon as f32,
            }),
            products: vec![],
        })
    } else {
        Place::Location(Location::Point {
            id: None,
            name: Some(p.name.clone()),
            poi: None,
            latitude: p.lat as f32,
            longitude: p.lon as f32,
        })
    }
}

fn match_to_place(m: motis::models::model_match::Match) -> Place {
    match m.r#type {
        motis::models::location_type::LocationType::Stop => Place::Station(Station {
            id: m.id.clone(),
            name: Some(m.name.clone()),
            location: Some(Location::Point {
                id: Some(m.id),
                name: Some(m.name),
                poi: None,
                latitude: m.lat as f32,
                longitude: m.lon as f32,
            }),
            products: vec![],
        }),
        motis::models::location_type::LocationType::Address => Place::Location(Location::Address {
            address: format!(
                "{} {}, {}",
                m.street.unwrap_or_default(),
                m.house_number.unwrap_or_default(),
                m.zip.unwrap_or_default()
            ),
            latitude: m.lat as f32,
            longitude: m.lon as f32,
        }),
        motis::models::location_type::LocationType::Place => Place::Location(Location::Point {
            id: Some(m.id),
            name: Some(m.name),
            poi: None,
            latitude: m.lat as f32,
            longitude: m.lon as f32,
        }),
    }
}

#[cfg(test)]
mod test {
    use rcore::{JourneysOptions, LocationsOptions, ReqwestRequesterBuilder, Station};

    use super::*;

    pub async fn check_search<S: AsRef<str>>(
        search: S,
        expected: S,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let client = MotisClient::new(
            Url::parse(TRANSITOUS_URL).expect("Failed to parse SPLINE_URL"),
            ReqwestRequesterBuilder::default(),
        );
        let locations = client
            .locations(LocationsOptions {
                query: search.as_ref().to_string(),
                ..Default::default()
            })
            .await?;
        let results = locations
            .into_iter()
            .flat_map(|p| match p {
                Place::Station(s) => s.name,
                Place::Location(Location::Address { address, .. }) => Some(address),
                Place::Location(Location::Point { name, .. }) => name,
            })
            .collect::<Vec<_>>();
        assert!(
            results.iter().find(|s| s == &expected.as_ref()).is_some(),
            "expected {} to be contained in {:#?}",
            expected.as_ref(),
            results
        );
        Ok(())
    }

    pub async fn check_journey<S: AsRef<str>>(
        from: S,
        to: S,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let client = MotisClient::new(
            Url::parse(TRANSITOUS_URL).expect("Failed to parse SPLINE_URL"),
            ReqwestRequesterBuilder::default(),
        );
        let journeys = client
            .journeys(
                Place::Station(Station {
                    id: from.as_ref().to_string(),
                    ..Default::default()
                }),
                Place::Station(Station {
                    id: to.as_ref().to_string(),
                    ..Default::default()
                }),
                JourneysOptions::default(),
            )
            .await?;
        assert!(
            !journeys.journeys.is_empty(),
            "expected journey from {} to {} to exist",
            from.as_ref(),
            to.as_ref()
        );
        Ok(())
    }
    #[tokio::test]
    async fn search_munich() -> Result<(), Box<dyn std::error::Error>> {
        check_search("München Hb", "München Hbf").await
    }

    #[tokio::test]
    async fn journey_munich_nuremberg() -> Result<(), Box<dyn std::error::Error>> {
        check_journey("de-DELFI_de:09162:100", "de-DELFI_de:09564:510").await
    }
}
