/*
Copyright (C) 2021 Kunal Mehta <legoktm@debian.org>

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/>.
 */

use crate::edit::{EditResponse, SaveOptions, Saveable};
#[cfg(feature = "upload")]
#[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
use crate::file::File;
#[cfg(feature = "generators")]
#[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
use crate::generators::{
    categories::Categories, langlinks::LangLinks, templates::Templates,
    Generator,
};
use crate::parsoid::ImmutableWikicode;
use crate::{Bot, Error, Result, Title};
use mwapi_responses::prelude::*;
use mwtimestamp::Timestamp;
use once_cell::sync::OnceCell;
use serde_json::Value;
#[cfg(feature = "generators")]
use std::collections::HashMap;
use std::fmt::Display;
use std::future::Future;
use std::sync::Arc;
use tokio::sync::OnceCell as AsyncOnceCell;
use tracing::info;

/// A `Page` represents a wiki page on a specific wiki (`Bot`). You can get
/// metadata about a page, its contents (in HTML or wikitext) and edit the
/// page.
///
/// Pages are obtained by calling `bot.page("<title>")?`. Each page is `Sync`
/// and designed to easily `Clone`d so it can be sent across multiple threads
/// for concurrent processing.
///
/// Most metadata lookups are internally batched and cached so it might not
/// reflect the live state on the wiki if someone has edited or modified the
/// page in the meantime. To get fresh information create a new `Page`
/// instance.
///
/// Saving a page will respect `{{nobots}}` (if not disabled), wait as needed
/// for the configured rate limit and automatically implement edit conflict
/// detection.
#[derive(Debug, Clone)]
pub struct Page {
    pub(crate) bot: Bot,
    pub(crate) title: Title,
    pub(crate) title_text: OnceCell<String>,
    pub(crate) info: Arc<AsyncOnceCell<InfoResponseItem>>,
    pub(crate) baserevid: OnceCell<u64>,
}

#[doc(hidden)]
#[non_exhaustive]
#[query(prop = "info", inprop = "associatedpage|protection|url")]
pub struct InfoResponse {}

impl Page {
    /// Get the title of the page
    pub fn title(&self) -> &str {
        self.title_text.get_or_init(|| {
            let codec = &self.bot.config.codec;
            codec.to_pretty(&self.title)
        })
    }

    /// Get a reference to the underlying [`mwtitle::Title`](https://docs.rs/mwtitle/latest/mwtitle/struct.Title.html)
    pub fn as_title(&self) -> &Title {
        &self.title
    }

    /// Get the namespace ID of the page
    pub fn namespace(&self) -> i32 {
        self.title.namespace()
    }

    /// Whether this page refers to a file
    pub fn is_file(&self) -> bool {
        self.title.is_file()
    }

    /// If it is a file, get a `File` instance
    #[cfg(feature = "upload")]
    #[cfg_attr(docsrs, doc(cfg(feature = "upload")))]
    pub fn as_file(&self) -> Option<File> {
        if self.is_file() {
            Some(File::new(self))
        } else {
            None
        }
    }

    /// Whether this page refers to a category
    pub fn is_category(&self) -> bool {
        self.title.is_category()
    }

    /// Load basic page information
    async fn info(&self) -> Result<&InfoResponseItem> {
        self.info
            .get_or_try_init(|| async {
                let mut resp: InfoResponse = mwapi_responses::query_api(
                    &self.bot.api,
                    [("titles", self.title())],
                )
                .await?;
                let info = resp
                    .query
                    .pages
                    .pop()
                    .expect("API response returned 0 pages");
                if let Some(revid) = info.lastrevid {
                    let _ = self.baserevid.set(revid);
                }
                Ok(info)
            })
            .await
    }

    /// Whether the page exists or not
    pub async fn exists(&self) -> Result<bool> {
        Ok(!self.info().await?.missing)
    }

    /// Get the page's internal database ID, if it exists
    pub async fn id(&self) -> Result<Option<u32>> {
        Ok(self.info().await?.pageid)
    }

    /// Get the canonical URL for this page
    pub async fn url(&self) -> Result<&str> {
        Ok(&self.info().await?.canonicalurl)
    }

    /// Whether the page is a redirect or not
    pub async fn is_redirect(&self) -> Result<bool> {
        Ok(self.info().await?.redirect)
    }

    /// The associated page for this page (subject page for a talk page or
    /// talk page for a subject page)
    pub async fn associated_page(&self) -> Result<Page> {
        self.bot.page(&self.info().await?.associatedpage)
    }

    /// Get the "touched" timestamp, if the page exists.
    ///
    /// From the [MediaWiki documentation](https://www.mediawiki.org/wiki/Manual:Page_table#page_touched):
    /// > This timestamp is updated whenever the page changes in a way requiring it to be re-rendered,
    /// > invalidating caches. Aside from editing, this includes permission changes, creation or deletion
    /// > of linked pages, and alteration of contained templates.
    pub async fn touched(&self) -> Result<Option<Timestamp>> {
        Ok(self.info().await?.touched)
    }

    /// Get the ID of the latest revision, if the page exists.
    pub async fn latest_revision_id(&self) -> Result<Option<u64>> {
        Ok(self.info().await?.lastrevid)
    }

    /// If this page is a redirect, get the `Page` it targets
    pub async fn redirect_target(&self) -> Result<Option<Page>> {
        // Optimize if we already know it's not a redirect
        if self.info.initialized() && !self.is_redirect().await? {
            return Ok(None);
        }
        // Do an API request to resolve the redirect
        let mut resp: InfoResponse = mwapi_responses::query_api(
            &self.bot.api,
            [("titles", self.title()), ("redirects", "1")],
        )
        .await?;
        match resp.title_map().get(self.title()) {
            Some(redirect) => {
                let page = self.bot.page(redirect)?;
                page.info
                    .set(
                        resp.query
                            .pages
                            .pop()
                            .expect("API response returned 0 pages"),
                    )
                    // unwrap: Safe because we just created the page
                    .unwrap();
                Ok(Some(page))
            }
            None => Ok(None),
        }
    }

    /// Get the inter-language links of the latest revision, if the page exists.
    #[cfg(feature = "generators")]
    #[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
    pub async fn language_links(
        &self,
    ) -> Result<Option<HashMap<String, String>>> {
        if self.info.initialized() && !self.exists().await? {
            return Ok(None);
        }
        let mut gen =
            LangLinks::new(vec![self.title().to_string()]).generate(&self.bot);
        let page = gen.recv().await.unwrap()?;
        debug_assert_eq!(page.title, self.title());
        debug_assert!(gen.recv().await.is_none());
        if page.missing || page.invalid {
            return Ok(None);
        }
        let links = page
            .langlinks
            .into_iter()
            .map(|item| (item.lang, item.title))
            .collect();
        Ok(Some(links))
    }

    /// Get the categories of the latest revision, if the page exists.
    #[cfg(feature = "generators")]
    #[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
    pub async fn categories(&self) -> Result<Option<Vec<String>>> {
        if self.info.initialized() && !self.exists().await? {
            return Ok(None);
        }
        let mut gen =
            Categories::new(vec![self.title().to_string()]).generate(&self.bot);
        let mut found = Vec::new();

        while let Some(page) = gen.recv().await {
            found.push(page?.title().to_string());
        }
        Ok(Some(found))
    }

    /// Get the templates used in the latest revision, if the page exists.
    #[cfg(feature = "generators")]
    #[cfg_attr(docsrs, doc(cfg(feature = "generators")))]
    pub async fn templates(
        &self,
        only: Option<Vec<String>>,
    ) -> Result<Option<Vec<String>>> {
        if self.info.initialized() && !self.exists().await? {
            return Ok(None);
        }
        let mut gen = Templates::new(vec![self.title().to_string()])
            .with_templates(only)
            .generate(&self.bot);
        let mut found = Vec::new();

        while let Some(page) = gen.recv().await {
            found.push(page?.title().to_string());
        }
        Ok(Some(found))
    }

    /// Get Parsoid HTML for self.baserevid if it's set, or the latest revision otherwise
    pub async fn html(&self) -> Result<ImmutableWikicode> {
        match self.baserevid.get() {
            None => {
                let resp = self.bot.parsoid.get(self.title()).await?;
                // Keep track of revision id for saving in the future
                if let Some(revid) = &resp.revision_id() {
                    let _ = self.baserevid.set(*revid);
                }
                Ok(resp)
            }
            Some(revid) => {
                Ok(self.bot.parsoid.get_revision(self.title(), *revid).await?)
            }
        }
    }

    /// Get Parsoid HTML for the specified revision
    pub async fn revision_html(&self, revid: u64) -> Result<ImmutableWikicode> {
        Ok(self.bot.parsoid.get_revision(self.title(), revid).await?)
    }

    /// Get wikitext for self.baserevid if it's set, or the latest revision otherwise
    pub async fn wikitext(&self) -> Result<String> {
        let mut params: Vec<(&'static str, String)> = vec![
            ("action", "query".to_string()),
            ("titles", self.title().to_string()),
            ("prop", "revisions".to_string()),
            ("rvprop", "content|ids".to_string()),
            ("rvslots", "main".to_string()),
        ];
        if let Some(revid) = self.baserevid.get() {
            params.push(("rvstartid", revid.to_string()));
            params.push(("rvendid", revid.to_string()));
        }
        let resp = self.bot.api.get_value(&params).await?;
        let page = resp["query"]["pages"][0].as_object().unwrap();
        if page.contains_key("missing") {
            Err(Error::PageDoesNotExist(self.title().to_string()))
        } else {
            match page.get("revisions") {
                Some(revisions) => {
                    let revision = &revisions[0];
                    let _ =
                        self.baserevid.set(revision["revid"].as_u64().unwrap());
                    Ok(revision["slots"]["main"]["content"]
                        .as_str()
                        .unwrap()
                        .to_string())
                }
                None => {
                    // Most likely invalid title, either way revision
                    // doesn't exist
                    Err(Error::PageDoesNotExist(self.title().to_string()))
                }
            }
        }
    }

    /// Save the page using the specified HTML
    pub async fn save<S: Into<Saveable>>(
        self,
        edit: S,
        opts: &SaveOptions,
    ) -> Result<(Page, EditResponse)> {
        let mut exists: Option<bool> = None;
        if self.bot.config.respect_nobots {
            // Check {{nobots}} using existing wikicode
            match self.html().await {
                Ok(html) => {
                    exists = Some(true);
                    self.nobot_check(html)?;
                }
                Err(Error::PageDoesNotExist(_)) => {
                    exists = Some(false);
                }
                Err(error) => {
                    return Err(error);
                }
            }
        } else if self.info.initialized() {
            exists = Some(self.exists().await?);
        }

        let edit = edit.into();
        let wikitext = match edit {
            Saveable::Html(html) => {
                self.bot.parsoid.transform_to_wikitext(&html).await?
            }
            Saveable::Wikitext(wikitext) => wikitext,
        };

        let mut params: Vec<(&'static str, String)> = vec![
            ("action", "edit".to_string()),
            ("title", self.title().to_string()),
            ("text", wikitext),
            ("summary", opts.summary.to_string()),
        ];

        // Edit conflict detection
        if let Some(revid) = self.baserevid.get() {
            params.push(("baserevid", revid.to_string()));
        }
        // Even more basic edit conflict detection if we already have it
        match exists {
            Some(true) => {
                // Exists, don't create a new page
                params.push(("nocreate", "1".to_string()));
            }
            Some(false) => {
                // Missing, only create a new page
                params.push(("createonly", "1".to_string()));
            }
            None => {} // May or may not exist
        }

        if let Some(section) = &opts.section {
            params.push(("section", section.to_string()));
        }
        if opts.mark_as_bot.unwrap_or(self.bot.config.mark_as_bot) {
            params.push(("bot", "1".to_string()));
        }
        if !opts.tags.is_empty() {
            params.push(("tags", opts.tags.join("|")));
        }
        if let Some(minor) = opts.minor {
            if minor {
                params.push(("minor", "1".to_string()));
            } else {
                params.push(("notminor", "1".to_string()));
            }
        }

        let resp: Value = self
            .with_save_lock(async {
                info!("Saving [[{}]]", self.title());
                self.bot.api.post_with_token("csrf", &params).await
            })
            .await?;

        self.page_from_response(resp)
    }

    /// Reverses edits to revision IDs `from` through `to`.
    /// If `to` is passed None, only one revision specified in `from` will be undone.
    pub async fn undo(
        self,
        from: u64,
        to: Option<u64>,
        opts: &SaveOptions,
    ) -> Result<(Page, EditResponse)> {
        if self.bot.config.respect_nobots {
            let html = self.html().await?;
            self.nobot_check(html)?;
        } else if self.info.initialized() && self.exists().await? {
            return Err(Error::PageDoesNotExist(self.title().to_string()));
        }

        let mut params: Vec<(&'static str, String)> = vec![
            ("action", "edit".to_string()),
            ("title", self.title().to_string()),
            ("undo", from.to_string()),
            ("summary", opts.summary.to_string()),
            ("nocreate", "1".to_string()), // undo means that the target page must exist.
        ];

        // Edit conflict detection
        if let Some(revid) = self.baserevid.get() {
            params.push(("baserevid", revid.to_string()));
        }

        if let Some(to) = to {
            params.push(("undoafter", to.to_string()));
        }
        if opts.mark_as_bot.unwrap_or(self.bot.config.mark_as_bot) {
            params.push(("bot", "1".to_string()));
        }
        if !opts.tags.is_empty() {
            params.push(("tags", opts.tags.join("|")));
        }
        if let Some(minor) = opts.minor {
            if minor {
                params.push(("minor", "1".to_string()));
            } else {
                params.push(("notminor", "1".to_string()));
            }
        }

        let resp: Value = self
            .with_save_lock(async {
                info!("Undoing edit [[{}]]", self.title());
                self.bot.api.post_with_token("csrf", &params).await
            })
            .await?;

        self.page_from_response(resp)
    }

    /// From the response, return a Page containing the edited content.
    fn page_from_response(self, resp: Value) -> Result<(Page, EditResponse)> {
        match resp["edit"]["result"].as_str() {
            Some("Success") => {
                let edit_response: EditResponse =
                    serde_json::from_value(resp["edit"].clone())?;
                if !edit_response.nochange {
                    let page = Page {
                        bot: self.bot,
                        title: self.title,
                        title_text: self.title_text,
                        info: Default::default(),
                        baserevid: OnceCell::from(
                            edit_response.newrevid.unwrap(),
                        ),
                    };
                    Ok((page, edit_response))
                } else {
                    Ok((self, edit_response))
                }
            }
            // Some legacy code might return "result": "Failure" but the
            // structure is totally unspecified, so we're best off just
            // passing the entire blob into the error in the hope it
            // contains some clue.
            _ => Err(Error::UnknownSaveFailure(resp)),
        }
    }

    /// Check {{nobots}} using existing wikicode
    fn nobot_check(&self, html: ImmutableWikicode) -> Result<()> {
        let username = self
            .bot
            .config
            .username
            .clone()
            .unwrap_or_else(|| "unknown".to_string());
        if !crate::utils::nobots(&html, &username)? {
            return Err(Error::Nobots);
        }
        Ok(())
    }

    async fn with_save_lock<F: Future<Output = T>, T>(&self, action: F) -> T {
        // Get the save timer lock. Will be released once we're finished saving
        let _save_lock = if let Some(save_timer) = &self.bot.state.save_timer {
            let mut save_lock = save_timer.lock().await;
            // TODO: would be nice if we could output a sleep message here, but we
            // don't actually know whether we need to sleep or not.
            save_lock.tick().await;
            Some(save_lock)
        } else {
            None
        };

        action.await
    }
}

impl Display for Page {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.title())
    }
}

#[cfg(test)]
mod tests {
    use std::time::{Duration, SystemTime};

    use crate::tests::{has_userright, is_authenticated, testwp};
    use crate::Error;

    use super::*;

    #[tokio::test]
    async fn test_exists() {
        let bot = testwp().await;
        let page = bot.page("Main Page").unwrap();
        assert!(page.exists().await.unwrap());
        let page2 = bot.page("DoesNotExistPlease").unwrap();
        assert!(!page2.exists().await.unwrap());
    }

    #[tokio::test]
    async fn test_title() {
        let bot = testwp().await;
        // Note the trailing space
        let page = bot.page("Main Page ").unwrap();
        assert_eq!(page.title(), "Main Page");
        assert_eq!(page.as_title().dbkey(), "Main_Page");
    }

    #[tokio::test]
    async fn test_get_redirect_target() {
        let bot = testwp().await;
        let redir = bot.page("Mwbot-rs/Redirect").unwrap();
        let target = redir.redirect_target().await.unwrap().unwrap();
        // "Redirect" points to "Main Page"
        assert_eq!(target.title(), "Main Page");
        // "Main Page" is not a redirect
        assert!(target.redirect_target().await.unwrap().is_none());
    }

    #[tokio::test]
    async fn test_get_content() {
        let bot = testwp().await;
        let page = bot.page("Main Page").unwrap();
        let html = page.html().await.unwrap().into_mutable();
        assert_eq!(html.title().unwrap(), "Main Page".to_string());
        assert_eq!(
            html.select_first("b").unwrap().text_contents(),
            "test wiki".to_string()
        );
        let wikitext = page.wikitext().await.unwrap();
        assert!(wikitext.contains("'''test wiki'''"));
    }

    #[tokio::test]
    async fn test_set_baserevid() {
        let bot = testwp().await;
        let page = bot.page("Main Page").unwrap();
        assert!(page.baserevid.get().is_none());
        page.info().await.unwrap();
        assert!(page.baserevid.get().is_some());
    }

    #[tokio::test]
    async fn test_missing_page() {
        let bot = testwp().await;
        let page = bot.page("DoesNotExistPlease").unwrap();
        let err = page.html().await.unwrap_err();
        match err {
            Error::PageDoesNotExist(page) => {
                assert_eq!(&page, "DoesNotExistPlease")
            }
            err => {
                panic!("Unexpected error: {err:?}")
            }
        }
        let err2 = page.wikitext().await.unwrap_err();
        match err2 {
            Error::PageDoesNotExist(page) => {
                assert_eq!(&page, "DoesNotExistPlease")
            }
            err => {
                panic!("Unexpected error: {err:?}")
            }
        }
    }

    #[tokio::test]
    async fn test_save() {
        if !is_authenticated() {
            return;
        }

        let bot = testwp().await;
        let wikitext = format!(
            "It has been {} seconds since the epoch.",
            SystemTime::now()
                .duration_since(SystemTime::UNIX_EPOCH)
                .unwrap()
                .as_secs()
        );
        let mut retries = 0;
        loop {
            let page = bot.page("mwbot-rs/Save").unwrap();
            let resp = page
                .save(
                    wikitext.to_string(),
                    &SaveOptions::summary("Test suite edit"),
                )
                .await;
            match resp {
                Ok(resp) => {
                    assert_eq!(&resp.1.title, "Mwbot-rs/Save");
                    return;
                }
                Err(Error::EditConflict) => {
                    if retries > 5 {
                        panic!("hit more than 5 edit conflicts");
                    }
                    retries += 1;
                    tokio::time::sleep(Duration::from_secs(5)).await;
                    continue;
                }
                Err(ref err) => {
                    dbg!(&resp);
                    panic!("{}", err);
                }
            }
        }
    }

    #[tokio::test]
    #[ignore = "T391120"]
    async fn test_undo() {
        if !is_authenticated() {
            return;
        }

        let bot = testwp().await;
        let page = bot.page("mwbot-rs/Undo").unwrap();
        let wikitext = format!(
            "It has been {} seconds since the epoch.",
            SystemTime::now()
                .duration_since(SystemTime::UNIX_EPOCH)
                .unwrap()
                .as_secs()
        );
        let (page, _) = page
            .save(wikitext, &SaveOptions::summary("Test suite edit"))
            .await
            .unwrap();

        let revision_id = page.info().await.unwrap().lastrevid.unwrap();

        let (page, _) = page
            .undo(revision_id, None, &SaveOptions::summary("Test suite edit"))
            .await
            .unwrap();
        let undoed_wikitext = page.wikitext().await.unwrap();

        assert_eq!(undoed_wikitext, "This page is used to test undo.");
    }

    #[tokio::test]
    async fn test_protected() {
        if !is_authenticated() {
            return;
        }

        let bot = testwp().await;
        let page = bot.page("mwbot-rs/Protected").unwrap();
        let wikitext = "Wait, I can edit this page?".to_string();
        let error = page
            .save(wikitext, &SaveOptions::summary("Test suite edit"))
            .await
            .unwrap_err();
        dbg!(&error);
        assert!(matches!(error, Error::ProtectedPage));
    }

    #[tokio::test]
    async fn test_spamfilter() {
        let bot = testwp().await;
        if !is_authenticated() || !has_userright(&bot, "sboverride").await {
            return;
        }

        let page = bot.page("mwbot-rs/SpamBlacklist").unwrap();
        let wikitext = "https://bitly.com/12345".to_string();
        let error = page
            .save(wikitext, &SaveOptions::summary("Test suite edit"))
            .await
            .unwrap_err();
        dbg!(&error);
        if let Error::SpamFilter { matches, .. } = error {
            assert_eq!(matches, vec!["bitly.com/1".to_string()])
        } else {
            panic!("{error:?} doesn't match")
        }
    }

    #[tokio::test]
    async fn test_partialblock() {
        if !is_authenticated() {
            return;
        }
        let bot = testwp().await;
        let page = bot.page("Mwbot-rs/Partially blocked").unwrap();
        let error = page
            .save(
                "I shouldn't be able to edit this".to_string(),
                &SaveOptions::summary("Test suite edit"),
            )
            .await
            .unwrap_err();
        dbg!(&error);
        if let Error::PartiallyBlocked { info, .. } = error {
            assert!(info.starts_with("<strong>Your username or IP address is blocked from doing this"));
        } else {
            panic!("{error:?} doesn't match");
        }
    }

    /// Regression test to verify we don't panic on invalid titles
    /// https://gitlab.com/mwbot-rs/mwbot/-/issues/33
    ///
    /// Mostly moot now that we have proper title validation
    #[tokio::test]
    async fn test_invalidtitle() {
        let bot = testwp().await;
        // Should return an error
        let err = bot.page("<invalid title>").unwrap_err();
        assert!(matches!(err, Error::InvalidTitle(_)));
        let err = bot.page("Special:BlankPage").unwrap_err();
        assert!(matches!(err, Error::InvalidPage));
    }

    #[tokio::test]
    async fn test_editconflict() {
        if !is_authenticated() {
            return;
        }
        let bot = testwp().await;
        let page = bot.page("mwbot-rs/Edit conflict").unwrap();
        // Fake a older baserevid in
        page.baserevid.set(498547).unwrap();
        let err = page
            .save(
                "This should fail",
                &SaveOptions::summary("this should fail"),
            )
            .await
            .unwrap_err();
        dbg!(&err);
        assert!(matches!(err, Error::EditConflict));
    }

    #[tokio::test]
    async fn test_associated_page() {
        let bot = testwp().await;
        let page = bot.page("Main Page").unwrap();
        assert_eq!(
            page.associated_page().await.unwrap().title(),
            "Talk:Main Page"
        );
    }

    #[tokio::test]
    async fn test_nobots() {
        if !is_authenticated() {
            return;
        }
        let bot = testwp().await;
        let page = bot.page("Mwbot-rs/Nobots").unwrap();
        let error = page
            .save(
                "This edit should not go through due to the {{nobots}} template".to_string(),
                &SaveOptions::summary("Test suite edit"),
            )
            .await
            .unwrap_err();
        assert!(matches!(error, Error::Nobots));
    }

    #[tokio::test]
    async fn test_display() {
        let bot = testwp().await;
        let page = bot.page("Main Page").unwrap();
        assert_eq!(format!("{}", page), "Main Page");
    }

    #[tokio::test]
    async fn test_touched() {
        let bot = testwp().await;
        assert!(bot
            .page("Main Page")
            .unwrap()
            .touched()
            .await
            .unwrap()
            .is_some());
    }

    #[tokio::test]
    async fn test_latest_revision_id() {
        let bot = testwp().await;
        assert!(bot
            .page("Main Page")
            .unwrap()
            .latest_revision_id()
            .await
            .unwrap()
            .is_some());
    }

    #[tokio::test]
    async fn test_language_links() {
        let bot = testwp().await;
        assert_eq!(
            bot.page("Mwbot-rs/Langlink")
                .unwrap()
                .language_links()
                .await
                .unwrap()
                .unwrap()
                .into_iter()
                .collect::<Vec<_>>(),
            [("en".to_string(), "Stick style".to_string())]
        );
    }

    #[tokio::test]
    async fn test_categories() {
        let bot = testwp().await;
        assert_eq!(
            bot.page("Mwbot-rs/Categorized")
                .unwrap()
                .categories()
                .await
                .unwrap()
                .unwrap(),
            ["Category:Mwbot-rs"]
        );
    }

    #[tokio::test]
    async fn test_templates() {
        let bot = testwp().await;
        assert!(bot
            .page("Mwbot-rs/Transcluded")
            .unwrap()
            .templates(None)
            .await
            .unwrap()
            .unwrap()
            .contains(&"Main Page".to_string()));
    }
}
