//! a rust crate for the google play api
//!
//! to get started, get an oauth token, you one by logging in into the
//! [embedded setup page](https://accounts.google.com/EmbeddedSetup), opening the dev tools and
//! then checking the network tab / application tab for the returned cookie. the token can only be
//! used once, and the resulting aas token doesn't seem to expire
//!
//! after getting the oauth token, you can convert it with the crate by running it as a binary like so:
//! ```bash
//! TOKEN="..." EMAIL="..." cargo run --features binary
//! ```
//!
//! or alternatively, convert it manually using [`OAuthRequest`]
//!
//! after you get an aas token, parse your device props using [`PropsMap::parse_aurora_config`]
//! then construct a client using [`Client::login`] to start sending requests

#![warn(clippy::all, clippy::pedantic, clippy::perf, clippy::nursery)]
#![allow(clippy::struct_field_names)]
use crate::{
    api::{
        endpoint::Requester,
        login::{AuthToken, checkin, fetch_auth, toc, upload_device},
    },
    error::{
        CategoryError, DetailsError, DownloadsError, IntoGoogleError, LoginError, PropsError,
        ReviewsError, SearchError, SearchSuggestionError,
    },
    proto::{Item, Review, SearchSuggestEntry},
};
pub use api::{
    oauth::OAuthRequest,
    properties::{DeviceProperties, PropsMap},
};
use async_stream::try_stream;
use futures_core::stream::Stream;

mod api;
mod consts;
pub mod error;
pub mod proto;

macro_rules! async_doc {
    ($code:literal) => {
        concat!(
            r#"```
# tokio_test::block_on(async {
# const PROPS: &str = include_str!("../example.properties");
# let mut props_map = gpipipi::PropsMap::parse_aurora_config(PROPS)?;
# let props = props_map.remove("arm").ok_or("No 'arm' device props!")?;
# let aas_token = std::env::var("TOKEN").map_err(|_| "Set 'TOKEN' to an oauth token!")?;
# let email = std::env::var("EMAIL").map_err(|_| "Set 'EMAIL' to the email used for the token!")?;
"#,
            $code,
            "
# Ok::<(), Box<dyn std::error::Error>>(())
# })?;
# Ok::<(), Box<dyn std::error::Error>>(())
```"
        )
    }
}

/// a logged in google play client, used for sending requests
///
/// to create one, see [`Client::login`]
pub struct Client {
    aas_token: String,
    email: String,

    props: DeviceProperties,
    auth_token: AuthToken,

    checkin_token: String,
    config_token: String,
    dfe_cookie: String,
    gsf: i64,
}

impl Client {
    /// creates a new client, logging in using the email and AAS token, acting as the given
    /// device props, **AND accepting Terms of Service if required!!**
    ///
    /// if you have an oauth token, you first need to convert it to aas using [`OAuthRequest`]
    ///
    /// # Examples
    #[doc = async_doc!(r#"let mut props_map = gpipipi::PropsMap::parse_aurora_config(PROPS)?;
let props = props_map.remove("arm").ok_or("No 'arm' device props!")?;
let client = gpipipi::Client::login(email, aas_token, props).await?;"#)]
    ///
    /// # Errors
    /// - `Checkin` - failed to checkin into google
    /// - `Upload` - failed to upload device props to google
    /// - `Auth` - failed to fetch the auth token from google
    /// - `NoDeviceToken` - google returned no `device_checkin_consistency_token`
    /// - `NoAndroidId` - google returned no `android_id`
    /// - `NoConfigToken` - google returned no `upload_device_config_token`
    pub async fn login<A: Into<String>, B: Into<String>>(
        email: A,
        aas_token: B,
        props: DeviceProperties,
    ) -> Result<Self, LoginError> {
        // internal state is all handled here so there's no case where clients can exist
        // with incomplete data

        let email = email.into();
        let aas_token = aas_token.into();

        let checkin_response = checkin(&props).await?;
        let gsf = checkin_response
            .android_id
            .ok_or(LoginError::NoAndroidId())?
            .cast_signed();

        let checkin_token = checkin_response
            .device_checkin_consistency_token
            .ok_or(LoginError::NoDeviceToken())?;

        let upload_response = upload_device(&props, gsf, &checkin_token).await?;
        let config_token = upload_response
            .upload_device_config_token
            .ok_or(LoginError::NoConfigToken())?;

        let auth_token = fetch_auth(&props, &aas_token, &email, gsf).await?;
        let dfe_cookie = toc(&auth_token, gsf, &checkin_token, &props).await?;

        Ok(Self {
            aas_token,
            email,
            props,
            auth_token,
            checkin_token,
            config_token,
            dfe_cookie,
            gsf,
        })
    }

    /// fetches the details for a specific package name, optionally for a specific version code
    ///
    /// # Examples
    #[doc = async_doc!(r#"let client = gpipipi::Client::login(email, aas_token, props).await?;
let details = client.details("com.roblox.client", None).await?;"#)]
    ///
    /// # Errors
    /// - `Props` - internal error for missing required device props
    /// - `Request` - error from the http client
    /// - `Google` - error from google
    pub async fn details<A: AsRef<str>>(
        &self,
        package_name: A,
        maybe_version: Option<i64>,
    ) -> Result<proto::AppDetails, DetailsError> {
        let maybe_version = maybe_version.map(|a| a.to_string());
        let params = maybe_version.as_ref().map_or_else(
            || [("doc", package_name.as_ref()), ("", "")],
            |version| [("doc", package_name.as_ref()), ("vc", version)],
        );

        let request = self.request().await?.params(&params);
        let mut response = request.fdfe_request("details", None).await?;

        let response = response
            .payload
            .take()
            .and_then(|a| a.details_response)
            .and_then(|a| a.item)
            .and_then(|a| a.details)
            .and_then(|a| a.app_details)
            .into_google_error(&response)?;

        Ok(response)
    }

    /// fetches the download urls for a specific package name, optionally for a specific version code
    ///
    /// # Examples
    #[doc = async_doc!(r#"let client = gpipipi::Client::login(email, aas_token, props).await?;
let download_urls = client.downloads("com.roblox.client", Some(2036)).await?;"#)]
    ///
    /// # Errors
    /// - `Props` - internal error for missing required device props
    /// - `Request` - error from the http client
    /// - `VersionFetchError` - no version was provided and fetching the latest got this
    /// - `Google` - error from google
    pub async fn downloads<A: AsRef<str>>(
        &self,
        package_name: A,
        version: Option<i64>,
    ) -> Result<DownloadResponse, DownloadsError> {
        let version = if let Some(version) = version {
            version
        } else {
            self.lastest_version(package_name.as_ref()).await?
        }
        .to_string();

        let headers = [("content-length", "0")];
        let params = [
            ("ot", "1"),
            ("doc", package_name.as_ref()),
            ("vc", &version),
        ];

        let mut request = self.request().await?.headers(&headers).params(&params);
        let mut response = request.fdfe_request("purchase", Some(Vec::new())).await?;

        let delivery_token = response
            .payload
            .take()
            .and_then(|a| a.buy_response)
            .and_then(|a| a.encoded_delivery_token)
            .into_google_error(&response)?;

        let delivery_params = [
            ("doc", package_name.as_ref()),
            ("dtok", &delivery_token),
            ("vc", &version),
            ("ot", "1"),
        ];

        request = request.headers(&[]).params(&delivery_params);

        let mut delivery_response = request.fdfe_request("delivery", None).await?;
        let app_delivery_data = delivery_response
            .payload
            .take()
            .and_then(|a| a.delivery_response)
            .and_then(|a| a.app_delivery_data)
            .into_google_error(&response)?;

        let base = app_delivery_data
            .download_url
            .zip(app_delivery_data.sha1)
            .map(|(url, sha1)| CdnUrl {
                filename: "base".to_string(),
                url,
                sha1,
            })
            .into_google_error(&response)?;

        let splits: Vec<_> = app_delivery_data
            .split_delivery_data
            .into_iter()
            .filter_map(|a| {
                a.name
                    .zip(a.download_url)
                    .zip(a.sha1)
                    .map(|((filename, url), sha1)| CdnUrl {
                        filename,
                        sha1,
                        url,
                    })
            })
            .collect();

        let obbs: Vec<_> = app_delivery_data
            .additional_file
            .into_iter()
            .filter_map(|a| {
                a.file_type
                    .zip(a.sha1)
                    .zip(a.version_code)
                    .zip(a.download_url)
                    .map(|(((ftype, sha1), version), url)| {
                        let prefix = if ftype == 0 { "main" } else { "patch" };
                        CdnUrl {
                            filename: format!("{prefix}.{version}.{}.obb", package_name.as_ref()),
                            url,
                            sha1,
                        }
                    })
            })
            .collect();

        Ok(DownloadResponse { splits, obbs, base })
    }

    /// fetches all categories of a certain type
    ///
    /// # Examples
    #[doc = async_doc!("let client = gpipipi::Client::login(email, aas_token, props).await?;
let categories = client.categories(gpipipi::CategoryType::Game).await?;")]
    ///
    /// # Errors
    /// - `Props` - internal error for missing required device props
    /// - `Request` - error from the http client
    /// - `Google` - error from google
    pub async fn categories(&self, cat_type: CategoryType) -> Result<Vec<Category>, CategoryError> {
        let type_name = format!("{cat_type:?}").to_uppercase();

        let headers = [("User-Agent", consts::LEGACY_USERAGENT)];
        let params = [("c", "3"), ("cat", &type_name)];

        let request = self.request().await?.headers(&headers).params(&params);
        let mut response = request.fdfe_request("categoriesList", None).await?;

        let items = response
            .payload
            .take()
            .and_then(|a| a.list_response)
            .and_then(|a| a.item)
            .and_then(|mut a| a.sub_item.pop())
            .into_google_error(&response)?;

        Ok(items
            .sub_item
            .into_iter()
            .filter_map(|a| {
                a.title
                    .zip(a.annotations.and_then(|a| {
                        a.annotation_link
                            .and_then(|a| a.resolved_link)
                            .and_then(|a| a.browse_url)
                    }))
                    .map(|(title, browse_url)| Category {
                        images: a.image.into_iter().filter_map(|a| a.image_url).collect(),
                        cat_type,
                        browse_url,
                        title,
                    })
            })
            .collect())
    }

    /// fetch search suggestions for a string query
    ///
    /// # Examples
    #[doc = async_doc!(r#"let client = gpipipi::Client::login(email, aas_token, props).await?;
let suggestions = client.search_suggest("robl").await?;"#)]
    ///
    /// # Errors
    /// - `Props` - internal error for missing required device props
    /// - `Request` - error from the http client
    /// - `Google` - error from google
    pub async fn search_suggest<A: AsRef<str>>(
        &self,
        query: A,
    ) -> Result<Vec<SearchSuggestEntry>, SearchSuggestionError> {
        let headers = [("User-Agent", consts::LEGACY_USERAGENT)];
        let params = [
            ("q", query.as_ref()),
            ("ssis", "120"),
            ("sb", "5"),
            ("sst", "2"),
            ("c", "3"),
        ];

        let request = self.request().await?.headers(&headers).params(&params);
        let mut response = request.fdfe_request("searchSuggest", None).await?;

        let response = response
            .payload
            .take()
            .and_then(|a| a.search_suggest_response)
            .map(|a| a.entry)
            .into_google_error(&response)?;

        Ok(response)
    }

    /// fetch search results for a string query, optionally continuing a past page
    ///
    /// # Examples
    #[doc = async_doc!(r#"let client = gpipipi::Client::login(email, aas_token, props).await?;
let single_search = client.search("robl", None).await?;
let single_search2 = client.search("robl", single_search.next_page).await?;"#)]
    ///
    /// # Errors
    /// - `Props` - internal error for missing required device props
    /// - `Request` - error from the http client
    /// - `Google` - error from google
    pub async fn search<A: AsRef<str>>(
        &self,
        query: A,
        next_page_url: Option<NextSearchPage>,
    ) -> Result<SearchResults, SearchError> {
        let (payload, next_page, response) = if let Some(next_page_url) = next_page_url {
            let request = self.request().await?;
            let mut response = request.fdfe_request(&next_page_url.0, None).await?;

            (response.payload.take(), None, response)
        } else {
            let params = [("q", query.as_ref()), ("ksm", "1"), ("c", "3")];

            let request = self.request().await?.params(&params);
            let mut response = request.fdfe_request("search", None).await?;

            (
                response
                    .pre_fetch
                    .take()
                    .and_then(|a| a.response)
                    .and_then(|a| a.payload),
                Some(
                    response
                        .payload
                        .take()
                        .and_then(|a| a.search_response)
                        .and_then(|a| a.next_page_url)
                        .map(NextSearchPage),
                ),
                response,
            )
        };

        let next_page = next_page.unwrap_or_else(|| {
            payload
                .as_ref()
                .and_then(|a| a.list_response.as_ref())
                .and_then(|a| a.item.as_ref())
                .and_then(|a| a.container_metadata.as_ref())
                .and_then(|a| a.next_page_url.clone())
                .map(NextSearchPage)
        });

        let items = payload
            .and_then(|a| a.list_response)
            .and_then(|a| a.item)
            .map(|a| {
                a.sub_item
                    .into_iter()
                    .filter_map(|mut a| a.sub_item.pop())
                    .collect::<Vec<_>>()
            })
            .into_google_error(&response)?;

        Ok(SearchResults { next_page, items })
    }

    /// returns a stream that yields pages of search results, automatically handling pagination
    ///
    /// # Examples
    #[doc = async_doc!(r#"use futures_util::TryStreamExt;
let client = gpipipi::Client::login(email, aas_token, props).await?;

let mut search_stream = client.search_stream("robl");
# let mut count = 0;
while let Some(page) = search_stream.try_next().await? {
    # count += 1;
    # if count >= 3 {
    #   break
    # }

    for item in page {
        println!("Found app: {:?}", item.title);
    }
}"#)]
    ///
    /// # Errors
    /// - `Props` - internal error for missing required device props
    /// - `Request` - error from the http client
    /// - `Google` - error from google
    pub fn search_stream<'a, A: AsRef<str> + 'a>(
        &'a self,
        query: A,
    ) -> impl Stream<Item = Result<Vec<Item>, SearchError>> + 'a {
        Box::pin(try_stream! {
            let mut next_page: Option<NextSearchPage> = None;
            loop {
                let result = self.search(query.as_ref(), next_page).await?;
                yield result.items;

                next_page = result.next_page;
                if next_page.is_none() {
                    break;
                }
            }
        })
    }

    /// fetch reviews for a specific package name, optionally continuing a past page
    ///
    /// note that not setting a filter will cause google to return an error for next pages
    ///
    /// # Examples
    #[doc = async_doc!(r#"let client = gpipipi::Client::login(email, aas_token, props).await?;

let reviews = client
    .reviews("com.roblox.client", Some(&gpipipi::ReviewsFilter::Newest), None)
    .await?;

let reviews_page2 = client
    .reviews("com.roblox.client", Some(&gpipipi::ReviewsFilter::Newest), reviews.next_page)
    .await?;"#)]
    ///
    /// # Errors
    /// - `Props` - internal error for missing required device props
    /// - `Request` - error from the http client
    /// - `Google` - error from google
    pub async fn reviews<A: AsRef<str>>(
        &self,
        package_name: A,
        filter: Option<&ReviewsFilter>,
        next_page_url: Option<NextReviewsPage>,
    ) -> Result<ReviewsResults, ReviewsError> {
        let (reviews_response, next_page, response) = if let Some(next_page_url) = next_page_url {
            let request = self.request().await?;
            let mut response = request.fdfe_request(&next_page_url.0, None).await?;

            let review_response = response.payload.take().and_then(|a| a.review_response);
            let next_page = review_response
                .as_ref()
                .and_then(|a| a.next_page_url.clone())
                .map(NextReviewsPage);

            (review_response, next_page, response)
        } else {
            let params = [
                ("doc", package_name.as_ref()),
                filter.map_or(("", ""), |filter| match filter {
                    ReviewsFilter::All => ("sfilter", "ALL"),
                    ReviewsFilter::Newest => ("sort", "0"),
                    ReviewsFilter::Positive => ("sent", "1"),
                    ReviewsFilter::Critical => ("sent", "2"),
                }),
            ];

            let request = self.request().await?.params(&params);
            let mut response = request.fdfe_request("rev", None).await?;

            let review_response = response.payload.take().and_then(|a| a.review_response);

            let next_page = review_response
                .as_ref()
                .and_then(|a| a.next_page_url.clone())
                .map(NextReviewsPage);

            (review_response, next_page, response)
        };

        let reviews = reviews_response
            .and_then(|a| a.user_reviews_response)
            .map(|a| a.review)
            .into_google_error(&response)?;

        Ok(ReviewsResults { next_page, reviews })
    }

    /// returns a stream that yields pages of reviews, automatically handling pagination
    ///
    /// # Examples
    #[doc = async_doc!(r#"use futures_util::TryStreamExt;
let client = gpipipi::Client::login(email, aas_token, props).await?;

let mut reviews_stream = client
    .reviews_stream("com.roblox.client", Some(gpipipi::ReviewsFilter::Newest));

# let mut count = 0;
while let Some(page) = reviews_stream.try_next().await? {
    # count += 1;
    # if count >= 3 {
    #   break
    # }

    for review in page {
        println!("{:?} stars: {:?}", review.star_rating, review.comment);
    }
}"#)]
    ///
    /// # Errors
    /// - `Props` - internal error for missing required device props
    /// - `Request` - error from the http client
    /// - `Google` - error from google
    pub fn reviews_stream<'a, A: AsRef<str> + 'a>(
        &'a self,
        package_name: A,
        filter: Option<ReviewsFilter>,
    ) -> impl Stream<Item = Result<Vec<Review>, ReviewsError>> + 'a {
        Box::pin(try_stream! {
            let mut next_page: Option<NextReviewsPage> = None;
            loop {
                let result = self.reviews(package_name.as_ref(), filter.as_ref(), next_page).await?;
                yield result.reviews;

                next_page = result.next_page;
                if next_page.is_none() {
                    break;
                }
            }
        })
    }

    async fn lastest_version(&self, package_name: &str) -> Result<i64, DetailsError> {
        let response = self
            .details(package_name, None)
            .await?
            .version_code
            .into_google_error_map(|| "no version was returned by google".to_string())?;

        Ok(response)
    }

    async fn request(&'_ self) -> Result<Requester<'_>, PropsError> {
        Requester::new()
            .checkin_token(&self.checkin_token)
            .config_token(&self.config_token)
            .auth_token(&self.auth_token)
            .dfe_cookie(&self.dfe_cookie)
            .aas_token(&self.aas_token)
            .email(&self.email)
            .props(&self.props)
            .gsf(self.gsf)
            .default_headers()
            .await
    }
}

/// a url for a specific file, with a filename and a sha1 hash
#[derive(Debug)]
pub struct CdnUrl {
    pub filename: String,
    pub sha1: String,
    pub url: String,
}

/// response for an app, contains the base apk and any additional splits and obbs
#[derive(Debug)]
pub struct DownloadResponse {
    pub splits: Vec<CdnUrl>,
    pub obbs: Vec<CdnUrl>,
    pub base: CdnUrl,
}

/// a category type for apps
#[derive(Debug, Clone, Copy)]
pub enum CategoryType {
    Application,
    Game,
    Family,
}

/// a category
#[derive(Debug)]
pub struct Category {
    pub cat_type: CategoryType,
    pub images: Vec<String>,
    pub browse_url: String,
    pub title: String,
}

/// search results for a query, optionally containing an identifier for the next page
#[derive(Debug)]
pub struct SearchResults {
    pub next_page: Option<NextSearchPage>,
    pub items: Vec<Item>,
}

/// unique identifier for a next search page
#[derive(Debug)]
pub struct NextSearchPage(String);

/// filter for reviews
#[derive(Debug)]
pub enum ReviewsFilter {
    All,
    Newest,
    Positive,
    Critical,
}

/// review results for an app, optionally containing an identifier for the next page
#[derive(Debug)]
pub struct ReviewsResults {
    pub next_page: Option<NextReviewsPage>,
    pub reviews: Vec<Review>,
}

/// unique identifier for a next reviews page
#[derive(Debug)]
pub struct NextReviewsPage(String);
