use reqwest::{Client, Request, StatusCode};
use serde::{Deserialize, Deserializer};
use soft_assert::*;
use url::Url;
use zeroize::Zeroize;

pub struct Forgejo {
    url: Url,
    client: Client,
}

mod generated;

#[derive(thiserror::Error, Debug)]
pub enum ForgejoError {
    #[error("url must have a host")]
    HostRequired,
    #[error("scheme must be http or https")]
    HttpRequired,
    #[error(transparent)]
    ReqwestError(#[from] reqwest::Error),
    #[error("API key should be ascii")]
    KeyNotAscii,
    #[error("the response from forgejo was not properly structured")]
    BadStructure(#[from] StructureError),
    #[error("unexpected status code {} {}", .0.as_u16(), .0.canonical_reason().unwrap_or(""))]
    UnexpectedStatusCode(StatusCode),
    #[error("{} {}{}", .0.as_u16(), .0.canonical_reason().unwrap_or(""), .1.as_ref().map(|s| format!(": {s}")).unwrap_or_default())]
    ApiError(StatusCode, Option<String>),
    #[error("the provided authorization was too long to accept")]
    AuthTooLong,
}

#[derive(thiserror::Error, Debug)]
pub enum StructureError {
    #[error("{contents}")]
    Serde {
        e: serde_json::Error,
        contents: String,
    },
    #[error("failed to find header `{0}`")]
    HeaderMissing(&'static str),
    #[error("header was not ascii")]
    HeaderNotAscii,
    #[error("failed to parse header")]
    HeaderParseFailed,
}

/// Method of authentication to connect to the Forgejo host with.
pub enum Auth<'a> {
    /// Application Access Token. Grants access to scope enabled for the
    /// provided token, which may include full access.
    ///
    /// To learn how to create a token, see
    /// [the Codeberg docs on the subject](https://docs.codeberg.org/advanced/access-token/).
    ///
    /// To learn about token scope, see
    /// [the official Forgejo docs](https://forgejo.org/docs/latest/user/token-scope/).
    Token(&'a str),
    /// OAuth2 Token. Grants full access to the user's account, except for
    /// creating application access tokens.
    ///
    /// To learn how to create an OAuth2 token, see
    /// [the official Forgejo docs on the subject](https://forgejo.org/docs/latest/user/oauth2-provider).
    OAuth2(&'a str),
    /// Username, password, and 2-factor auth code (if enabled). Grants full
    /// access to the user's account.
    Password {
        username: &'a str,
        password: &'a str,
        mfa: Option<&'a str>,
    },
    /// No authentication. Only grants access to access public endpoints.
    None,
}

impl Forgejo {
    pub fn new(auth: Auth, url: Url) -> Result<Self, ForgejoError> {
        Self::with_user_agent(auth, url, "forgejo-api-rs")
    }

    pub fn with_user_agent(auth: Auth, url: Url, user_agent: &str) -> Result<Self, ForgejoError> {
        soft_assert!(
            matches!(url.scheme(), "http" | "https"),
            Err(ForgejoError::HttpRequired)
        );

        let mut headers = reqwest::header::HeaderMap::new();
        match auth {
            Auth::Token(token) => {
                let mut header: reqwest::header::HeaderValue = format!("token {token}")
                    .try_into()
                    .map_err(|_| ForgejoError::KeyNotAscii)?;
                header.set_sensitive(true);
                headers.insert("Authorization", header);
            }
            Auth::Password {
                username,
                password,
                mfa,
            } => {
                let unencoded_len = username.len() + password.len() + 1;
                let unpadded_len = unencoded_len
                    .checked_mul(4)
                    .ok_or(ForgejoError::AuthTooLong)?
                    .div_ceil(3);
                // round up to next multiple of 4, to account for padding
                let len = unpadded_len.div_ceil(4) * 4;
                let mut bytes = vec![0; len];

                // panic safety: len cannot be zero
                let mut encoder = base64ct::Encoder::<base64ct::Base64>::new(&mut bytes).unwrap();

                // panic safety: len will always be enough
                encoder.encode(username.as_bytes()).unwrap();
                encoder.encode(b":").unwrap();
                encoder.encode(password.as_bytes()).unwrap();

                let b64 = encoder.finish().unwrap();

                let mut header: reqwest::header::HeaderValue =
                    format!("Basic {b64}").try_into().unwrap(); // panic safety: base64 is always ascii
                header.set_sensitive(true);
                headers.insert("Authorization", header);

                bytes.zeroize();

                if let Some(mfa) = mfa {
                    let mut key_header: reqwest::header::HeaderValue =
                        mfa.try_into().map_err(|_| ForgejoError::KeyNotAscii)?;
                    key_header.set_sensitive(true);
                    headers.insert("X-FORGEJO-OTP", key_header);
                }
            }
            Auth::OAuth2(token) => {
                let mut header: reqwest::header::HeaderValue = format!("Bearer {token}")
                    .try_into()
                    .map_err(|_| ForgejoError::KeyNotAscii)?;
                header.set_sensitive(true);
                headers.insert("Authorization", header);
            }
            Auth::None => (),
        }
        let client = Client::builder()
            .user_agent(user_agent)
            .default_headers(headers)
            .build()?;
        Ok(Self { url, client })
    }

    pub async fn download_release_attachment(
        &self,
        owner: &str,
        repo: &str,
        release: u64,
        attach: u64,
    ) -> Result<bytes::Bytes, ForgejoError> {
        let release = self
            .repo_get_release_attachment(owner, repo, release, attach)
            .await?;
        let mut url = self.url.clone();
        url.path_segments_mut()
            .unwrap()
            .pop_if_empty()
            .extend(["attachments", &release.uuid.unwrap().to_string()]);
        let request = self.client.get(url).build()?;
        Ok(self.execute(request).await?.bytes().await?)
    }

    /// Requests a new OAuth2 access token
    ///
    /// More info at [Forgejo's docs](https://forgejo.org/docs/latest/user/oauth2-provider).
    pub async fn oauth_get_access_token(
        &self,
        body: structs::OAuthTokenRequest<'_>,
    ) -> Result<structs::OAuthToken, ForgejoError> {
        let url = self.url.join("login/oauth/access_token").unwrap();
        let request = self.client.post(url).json(&body).build()?;
        let response = self.execute(request).await?;
        match response.status().as_u16() {
            200 => Ok(response.json().await?),
            _ => Err(ForgejoError::UnexpectedStatusCode(response.status())),
        }
    }

    fn get(&self, path: &str) -> reqwest::RequestBuilder {
        let url = self.url.join("api/v1/").unwrap().join(path).unwrap();
        self.client.get(url)
    }

    fn put(&self, path: &str) -> reqwest::RequestBuilder {
        let url = self.url.join("api/v1/").unwrap().join(path).unwrap();
        self.client.put(url)
    }

    fn post(&self, path: &str) -> reqwest::RequestBuilder {
        let url = self.url.join("api/v1/").unwrap().join(path).unwrap();
        self.client.post(url)
    }

    fn delete(&self, path: &str) -> reqwest::RequestBuilder {
        let url = self.url.join("api/v1/").unwrap().join(path).unwrap();
        self.client.delete(url)
    }

    fn patch(&self, path: &str) -> reqwest::RequestBuilder {
        let url = self.url.join("api/v1/").unwrap().join(path).unwrap();
        self.client.patch(url)
    }

    async fn execute(&self, request: Request) -> Result<reqwest::Response, ForgejoError> {
        let response = self.client.execute(request).await?;
        match response.status() {
            status if status.is_success() => Ok(response),
            status if status.is_client_error() => {
                Err(ForgejoError::ApiError(status, maybe_err(response).await))
            }
            status => Err(ForgejoError::UnexpectedStatusCode(status)),
        }
    }
}

async fn maybe_err(res: reqwest::Response) -> Option<String> {
    res.json::<ErrorMessage>().await.ok().map(|e| e.message)
}

#[derive(serde::Deserialize)]
struct ErrorMessage {
    message: String,
    // intentionally ignored, no need for now
    // url: Url
}

pub mod structs {
    pub use crate::generated::structs::*;

    /// A Request for a new OAuth2 access token
    ///
    /// More info at [Forgejo's docs](https://forgejo.org/docs/latest/user/oauth2-provider).
    #[derive(serde::Serialize)]
    #[serde(tag = "grant_type")]
    pub enum OAuthTokenRequest<'a> {
        /// Request for getting an access code for a confidential app
        ///
        /// The `code` field must have come from sending the user to
        /// `/login/oauth/authorize` in their browser
        #[serde(rename = "authorization_code")]
        Confidential {
            client_id: &'a str,
            client_secret: &'a str,
            code: &'a str,
            redirect_uri: url::Url,
        },
        /// Request for getting an access code for a public app
        ///
        /// The `code` field must have come from sending the user to
        /// `/login/oauth/authorize` in their browser
        #[serde(rename = "authorization_code")]
        Public {
            client_id: &'a str,
            code_verifier: &'a str,
            code: &'a str,
            redirect_uri: url::Url,
        },
        /// Request for refreshing an access code
        #[serde(rename = "refresh_token")]
        Refresh {
            refresh_token: &'a str,
            client_id: &'a str,
            client_secret: &'a str,
        },
    }

    #[derive(serde::Deserialize)]
    pub struct OAuthToken {
        pub access_token: String,
        pub refresh_token: String,
        pub token_type: String,
        /// Number of seconds until the access token expires.
        pub expires_in: u32,
    }
}

// Forgejo can return blank strings for URLs. This handles that by deserializing
// that as `None`
fn none_if_blank_url<'de, D: serde::Deserializer<'de>>(
    deserializer: D,
) -> Result<Option<Url>, D::Error> {
    use serde::de::{Error, Unexpected, Visitor};
    use std::fmt;

    struct EmptyUrlVisitor;

    impl<'de> Visitor<'de> for EmptyUrlVisitor {
        type Value = Option<Url>;

        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
            formatter.write_str("option")
        }

        #[inline]
        fn visit_unit<E>(self) -> Result<Self::Value, E>
        where
            E: Error,
        {
            Ok(None)
        }

        #[inline]
        fn visit_none<E>(self) -> Result<Self::Value, E>
        where
            E: Error,
        {
            Ok(None)
        }

        #[inline]
        fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
        where
            E: Error,
        {
            if s.is_empty() {
                return Ok(None);
            }
            Url::parse(s)
                .map_err(|err| {
                    let err_s = format!("{}", err);
                    Error::invalid_value(Unexpected::Str(s), &err_s.as_str())
                })
                .map(Some)
        }
    }

    deserializer.deserialize_str(EmptyUrlVisitor)
}

#[allow(dead_code)] // not used yet, but it might appear in the future
fn deserialize_ssh_url<'de, D, DE>(deserializer: D) -> Result<Url, DE>
where
    D: Deserializer<'de>,
    DE: serde::de::Error,
{
    let raw_url: String = String::deserialize(deserializer).map_err(DE::custom)?;
    parse_ssh_url(&raw_url).map_err(DE::custom)
}

fn deserialize_optional_ssh_url<'de, D, DE>(deserializer: D) -> Result<Option<Url>, DE>
where
    D: Deserializer<'de>,
    DE: serde::de::Error,
{
    let raw_url: Option<String> = Option::deserialize(deserializer).map_err(DE::custom)?;
    raw_url
        .as_ref()
        .map(parse_ssh_url)
        .map(|res| res.map_err(DE::custom))
        .transpose()
        .or(Ok(None))
}

fn requested_reviewers_ignore_null<'de, D, DE>(
    deserializer: D,
) -> Result<Option<Vec<structs::User>>, DE>
where
    D: Deserializer<'de>,
    DE: serde::de::Error,
{
    let list: Option<Vec<Option<structs::User>>> =
        Option::deserialize(deserializer).map_err(DE::custom)?;
    Ok(list.map(|list| list.into_iter().flatten().collect::<Vec<_>>()))
}

fn parse_ssh_url(raw_url: &String) -> Result<Url, url::ParseError> {
    // in case of a non-standard ssh-port (not 22), the ssh url coming from the forgejo API
    // is actually parseable by the url crate, so try to do that first
    Url::parse(raw_url).or_else(|_| {
        // otherwise the ssh url is not parseable by the url crate and we try again after some
        // pre-processing
        let url = format!("ssh://{url}", url = raw_url.replace(":", "/"));
        Url::parse(url.as_str())
    })
}

#[test]
fn ssh_url_deserialization() {
    #[derive(serde::Deserialize)]
    struct SshUrl {
        #[serde(deserialize_with = "deserialize_ssh_url")]
        url: url::Url,
    }
    let full_url = r#"{ "url": "ssh://git@codeberg.org/Cyborus/forgejo-api" }"#;
    let ssh_url = r#"{ "url": "git@codeberg.org:Cyborus/forgejo-api" }"#;

    let full_url_de =
        serde_json::from_str::<SshUrl>(full_url).expect("failed to deserialize full url");
    let ssh_url_de =
        serde_json::from_str::<SshUrl>(ssh_url).expect("failed to deserialize ssh url");

    let expected = "ssh://git@codeberg.org/Cyborus/forgejo-api";
    assert_eq!(full_url_de.url.as_str(), expected);
    assert_eq!(ssh_url_de.url.as_str(), expected);

    #[derive(serde::Deserialize)]
    struct OptSshUrl {
        #[serde(deserialize_with = "deserialize_optional_ssh_url")]
        url: Option<url::Url>,
    }
    let null_url = r#"{ "url": null }"#;

    let full_url_de = serde_json::from_str::<OptSshUrl>(full_url)
        .expect("failed to deserialize optional full url");
    let ssh_url_de =
        serde_json::from_str::<OptSshUrl>(ssh_url).expect("failed to deserialize optional ssh url");
    let null_url_de =
        serde_json::from_str::<OptSshUrl>(null_url).expect("failed to deserialize null url");

    let expected = Some("ssh://git@codeberg.org/Cyborus/forgejo-api");
    assert_eq!(full_url_de.url.as_ref().map(|u| u.as_ref()), expected);
    assert_eq!(ssh_url_de.url.as_ref().map(|u| u.as_ref()), expected);
    assert!(null_url_de.url.is_none());
}

impl From<structs::DefaultMergeStyle> for structs::MergePullRequestOptionDo {
    fn from(value: structs::DefaultMergeStyle) -> Self {
        match value {
            structs::DefaultMergeStyle::Merge => structs::MergePullRequestOptionDo::Merge,
            structs::DefaultMergeStyle::Rebase => structs::MergePullRequestOptionDo::Rebase,
            structs::DefaultMergeStyle::RebaseMerge => {
                structs::MergePullRequestOptionDo::RebaseMerge
            }
            structs::DefaultMergeStyle::Squash => structs::MergePullRequestOptionDo::Squash,
            structs::DefaultMergeStyle::FastForwardOnly => {
                structs::MergePullRequestOptionDo::FastForwardOnly
            }
        }
    }
}
