/*
Copyright (C) 2021-2023 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 <https://www.gnu.org/licenses/>.
 */
//! MediaWiki API error types
//!
//! The MediaWiki API is rather dynamic and has quite a few possible errors
//! that you can run into. This module aims to have dedicated types for each
//! possible case as well as a conversion map between the API's error codes
//! and Rust types.
//!
//! The `ApiError` type is serde-deserializable, and can be converted into
//! a specific `Error` type using the API response code.

pub(crate) mod config;
mod upload;

pub use mwapi::ApiError;
use serde::Deserialize;
use serde_json::Value;
use std::io;
use thiserror::Error as ThisError;
pub use upload::UploadWarning;

/// Primary error class
#[non_exhaustive]
#[derive(ThisError, Debug)]
pub enum Error {
    /* Request related errors */
    /// A HTTP error like a 4XX or 5XX status code
    #[error("HTTP error: {0}")]
    HttpError(#[from] reqwest::Error),
    /// Invalid header value, likely if the provided OAuth2 token
    /// or User-agent are invalid
    #[error("Invalid header value: {0}")]
    InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
    /// Error when decoding the JSON response from the API
    #[error("JSON error: {0}")]
    InvalidJson(#[from] serde_json::Error),
    /// Error if unable to get request concurrency lock
    #[error("Unable to get request lock: {0}")]
    LockFailure(#[from] tokio::sync::AcquireError),
    #[error("Invalid title: {0}")]
    InvalidTitle(#[from] mwtitle::Error),
    #[error("I/O error: {0}")]
    IoError(io::Error),
    /// A HTTP error with 429 status code
    #[error("HTTP 429 Too Many Requests")]
    TooManyRequests { retry_after: Option<u64> },

    /// etag header is invalid/missing
    #[error("The etag for this request is missing or invalid")]
    InvalidEtag,

    /* Token issues */
    /// Token invalid or expired
    #[error("Invalid CSRF token")]
    BadToken,
    /// Unable to fetch a CSRF token
    #[error("Unable to get token `{0}`")]
    TokenFailure(String),

    /* Wikitext/markup issues */
    #[error("Heading levels must be between 1 and 6, '{0}' was provided")]
    InvalidHeadingLevel(u32),

    /* User-related issues */
    /// When expected to be logged in but aren't
    #[error("You're not logged in")]
    NotLoggedIn,
    /// When expected to be logged in but aren't
    #[error("You're not logged in as a bot account")]
    NotLoggedInAsBot,
    #[error("Missing permission: {0}")]
    PermissionDenied(String),
    #[error("Blocked sitewide: {info}")]
    Blocked { info: String, details: BlockDetails },
    #[error("Partially blocked: {info}")]
    PartiallyBlocked { info: String, details: BlockDetails },
    #[error("Globally blocked: {0}")]
    GloballyBlocked(String),
    #[error("Globally range blocked: {0}")]
    GloballyRangeBlocked(String),
    #[error("Globally XFF blocked: {0}")]
    GloballyXFFBlocked(String),
    /// When we can't group it into a more specific block
    #[error("Blocked: {0}")]
    UnknownBlock(String),

    /* Login-related issues */
    #[error("You've made too many recent login attempts.")]
    LoginThrottled,
    #[error("Incorrect username or password entered.")]
    WrongPassword,

    /* Page-related issues */
    #[error("The specified title is not a valid page")]
    InvalidPage,
    /// When {{nobots}} matches
    #[error("{{{{nobots}}}} prevents editing this page")]
    Nobots,
    /// Page does not exist
    #[error("Page does not exist: {0}")]
    PageDoesNotExist(String),
    /// Page is protected
    #[error("Page is protected")]
    ProtectedPage,
    /// Edit conflict
    #[error("Edit conflict")]
    EditConflict,
    #[error("Content too big: {0}")]
    ContentTooBig(String),
    /// Tripped the spam filter (aka SpamBlacklist)
    #[error("{info}")]
    SpamFilter { info: String, matches: Vec<String> },
    /// If the edit could not be undone
    #[error("Undo failure: {0}")]
    UndoFailure(String),
    /// Some save failure happened, but we don't know what it is
    #[error("Unknown save failure: {0}")]
    UnknownSaveFailure(Value),

    /* File-related issues */
    #[error("Upload warnings: {0:?}")]
    UploadWarning(Vec<UploadWarning>),

    /* MediaWiki-side issues */
    #[error("maxlag tripped: {info}")]
    Maxlag {
        info: String,
        retry_after: Option<u64>,
    },
    /// When MediaWiki is in readonly mode
    #[error("MediaWiki is readonly: {info}")]
    Readonly {
        info: String,
        retry_after: Option<u64>,
    },
    /// An internal MediaWiki exception
    #[error("Internal MediaWiki exception: {0}")]
    InternalException(ApiError),

    /* Catchall/generic issues */
    /// Any arbitrary error returned by the MediaWiki API
    #[error("API error: {0}")]
    ApiError(ApiError),
    /// An error where we don't know what to do nor have
    /// information to report back
    #[error("Unknown error: {0}")]
    Unknown(String),
}

impl Error {
    /// Whether the issue is related to a specific page
    /// rather than a global issue
    pub fn is_page_related(&self) -> bool {
        matches!(
            self,
            Error::InvalidPage
                | Error::ProtectedPage
                | Error::Nobots
                | Error::PartiallyBlocked { .. }
                | Error::EditConflict
                | Error::SpamFilter { .. }
                | Error::ContentTooBig(_)
                | Error::UndoFailure(_)
        )
    }

    /// Whether the issue is related to a sitewide block
    pub fn is_sitewide_block(&self) -> bool {
        matches!(
            self,
            Error::Blocked { .. }
                | Error::GloballyBlocked(_)
                | Error::GloballyRangeBlocked(_)
                | Error::GloballyXFFBlocked(_)
                // We don't know 100% this is a sitewide block, but let's
                // err on the cautious side
                | Error::UnknownBlock(_)
        )
    }

    /// If the error merits a retry, how long should we wait?
    pub fn retry_after(&self) -> Option<u64> {
        match self {
            Error::TooManyRequests { retry_after, .. } => {
                Some((*retry_after).unwrap_or(1))
            }
            Error::Maxlag { retry_after, .. } => {
                Some((*retry_after).unwrap_or(1))
            }
            Error::Readonly { retry_after, .. } => {
                Some((*retry_after).unwrap_or(1))
            }
            _ => None,
        }
    }
}

#[derive(Deserialize)]
struct SpamFilterData {
    matches: Vec<String>,
}

// Various fields are commented out to keep
// the size of Error down
#[derive(Deserialize, Debug, Clone)]
pub struct BlockDetails {
    // pub blockid: u32,
    pub blockedby: String,
    // pub blockedbyid: u32,
    pub blockreason: String,
    // TODO timestamp type
    // pub blockedtimestamp: String,
    pub blockpartial: bool,
    // pub blocknocreate: bool,
    // pub blockanononly: bool,
    // pub systemblocktype: Option<String>,
}

impl From<ApiError> for Error {
    fn from(apierr: ApiError) -> Self {
        match apierr.code.as_str() {
            "assertuserfailed" => Self::NotLoggedIn,
            "assertbotfailed" => Self::NotLoggedInAsBot,
            "badtoken" => Self::BadToken,
            "blocked" => {
                let details = if let Some(data) = apierr.data {
                    serde_json::from_value::<BlockDetails>(
                        data["blockinfo"].clone(),
                    )
                    .ok()
                } else {
                    None
                };
                match details {
                    Some(details) => {
                        if details.blockpartial {
                            Self::PartiallyBlocked {
                                info: apierr.text,
                                details,
                            }
                        } else {
                            Self::Blocked {
                                info: apierr.text,
                                details,
                            }
                        }
                    }
                    None => Self::UnknownBlock(apierr.text),
                }
            }
            "contenttoobig" => Self::ContentTooBig(apierr.text),
            "editconflict" => Self::EditConflict,
            "globalblocking-ipblocked"
            | "wikimedia-globalblocking-ipblocked"
            | "globalblocking-blockedtext-ip"
            | "wikimedia-globalblocking-blockedtext-ip" => {
                Self::GloballyBlocked(apierr.text)
            }
            "globalblocking-ipblocked-range"
            | "wikimedia-globalblocking-ipblocked-range"
            | "globalblocking-blockedtext-range"
            | "wikimedia-globalblocking-blockedtext-range" => {
                Self::GloballyRangeBlocked(apierr.text)
            }
            "globalblocking-ipblocked-xff"
            | "wikimedia-globalblocking-ipblocked-xff"
            | "globalblocking-blockedtext-xff"
            | "wikimedia-globalblocking-blockedtext-xff" => {
                Self::GloballyXFFBlocked(apierr.text)
            }
            "globalblocking-blockedtext-user"
            | "wikimedia-globalblocking-blockedtext-user" => {
                Self::GloballyBlocked(apierr.text)
            }
            "login-throttled" => Self::LoginThrottled,
            "maxlag" => Self::Maxlag {
                info: apierr.text,
                retry_after: None,
            },
            "protectedpage" => Self::ProtectedPage,
            "readonly" => Self::Readonly {
                info: apierr.text,
                retry_after: None,
            },
            "spamblacklist" => {
                let matches = if let Some(data) = apierr.data {
                    match serde_json::from_value::<SpamFilterData>(
                        data["spamblacklist"].clone(),
                    ) {
                        Ok(data) => data.matches,
                        // Not worth raising an error over this
                        Err(_) => vec![],
                    }
                } else {
                    vec![]
                };
                Self::SpamFilter {
                    info: apierr.text,
                    matches,
                }
            }
            "undofailure" => Self::UndoFailure(apierr.text),
            "wrongpassword" => Self::WrongPassword,
            code => {
                if code.starts_with("internal_api_error_") {
                    Self::InternalException(apierr)
                } else {
                    Self::ApiError(apierr)
                }
            }
        }
    }
}

impl From<mwapi::Error> for Error {
    fn from(value: mwapi::Error) -> Self {
        match value {
            mwapi::Error::HttpError(err) => Error::HttpError(err),
            mwapi::Error::InvalidHeaderValue(err) => {
                Error::InvalidHeaderValue(err)
            }
            mwapi::Error::InvalidJson(err) => Error::InvalidJson(err),
            mwapi::Error::LockFailure(err) => Error::LockFailure(err),
            mwapi::Error::IoError(err) => Error::IoError(err),
            mwapi::Error::TooManyRequests { retry_after } => {
                Error::TooManyRequests { retry_after }
            }
            mwapi::Error::BadToken => Error::BadToken,
            mwapi::Error::TokenFailure(token) => Error::TokenFailure(token),
            mwapi::Error::NotLoggedIn => Error::NotLoggedIn,
            mwapi::Error::NotLoggedInAsBot => Error::NotLoggedInAsBot,
            mwapi::Error::UploadWarning(warnings) => Error::UploadWarning(
                warnings.into_iter().map(UploadWarning::from_key).collect(),
            ),
            mwapi::Error::Maxlag { info, retry_after } => {
                Error::Maxlag { info, retry_after }
            }
            mwapi::Error::Readonly { info, retry_after } => {
                Error::Readonly { info, retry_after }
            }
            mwapi::Error::InternalException(err) => {
                Error::InternalException(err)
            }
            mwapi::Error::ApiError(apierr) => Error::from(apierr),
            mwapi::Error::Unknown(err) => Error::Unknown(err),
            err => Error::Unknown(err.to_string()),
        }
    }
}

impl From<parsoid::Error> for Error {
    fn from(value: parsoid::Error) -> Self {
        match value {
            parsoid::Error::Http(err) => Error::HttpError(err),
            parsoid::Error::InvalidHeaderValue(err) => {
                Error::InvalidHeaderValue(err)
            }
            parsoid::Error::InvalidJson(err) => Error::InvalidJson(err),
            parsoid::Error::LockFailure(err) => Error::LockFailure(err),
            parsoid::Error::PageDoesNotExist(title) => {
                Error::PageDoesNotExist(title)
            }
            parsoid::Error::InvalidEtag => Error::InvalidEtag,
            parsoid::Error::InvalidHeadingLevel(level) => {
                Error::InvalidHeadingLevel(level)
            }
            err => Error::Unknown(err.to_string()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_page_related() {
        assert!(Error::EditConflict.is_page_related());
        assert!(!Error::Unknown("bar".to_string()).is_page_related());
    }

    #[test]
    fn test_from_apierror() {
        let apierr = ApiError {
            code: "assertbotfailed".to_string(),
            text: "Something something".to_string(),
            data: None,
        };
        let err = Error::from(apierr);
        assert!(matches!(err, Error::NotLoggedInAsBot));
    }

    #[test]
    fn test_to_string() {
        let apierr = ApiError {
            code: "errorcode".to_string(),
            text: "Some description".to_string(),
            data: None,
        };
        assert_eq!(&apierr.to_string(), "(code: errorcode): Some description");
    }

    #[test]
    fn test_spamfilter() {
        let apierr = ApiError {
            code: "spamblacklist".to_string(),
            text: "blah blah".to_string(),
            data: Some(serde_json::json!({
                "spamblacklist": {
                    "matches": [
                        "example.org"
                    ]
                }
            })),
        };
        let err = Error::from(apierr);
        if let Error::SpamFilter { matches, .. } = err {
            assert_eq!(matches, vec!["example.org".to_string()]);
        } else {
            panic!("Unexpected error: {err:?}");
        }
    }
}
