use crate::{SyncOAuthPendingLogin, SyncValidationError};
use reqwest::Url;
use reqwest::blocking::Client;
use reqwest::redirect;
use serde_json::Value;
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
use std::time::Duration;

pub const FEDI_OAUTH_SCOPE: &str = "read:accounts";

const CLIENT_NAME: &str = "Cosimo Sync";
const CLIENT_WEBSITE: &str = "https://modulus.isonomia.net/cosimo.cgi";
const CLIENT_TIMEOUT: Duration = Duration::from_secs(30);

pub trait SyncOAuthProvider: fmt::Debug + Send + Sync {
    fn register_application_with_redirect_uri(
        &self,
        instance_url: &str,
        redirect_uri: &str,
    ) -> Result<FediOAuthApplication, FediOAuthError>;

    fn exchange_code_for_owner_identity(
        &self,
        pending: &SyncOAuthPendingLogin,
        authorization_code: &str,
    ) -> Result<String, FediOAuthError>;
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FediOAuthApplication {
    instance_url: String,
    client_id: String,
    client_secret: String,
    authorization_url: String,
}

impl FediOAuthApplication {
    pub fn from_parts_with_redirect_uri(
        instance_url: &str,
        client_id: &str,
        client_secret: &str,
        redirect_uri: &str,
    ) -> Result<Self, FediOAuthError> {
        let instance_url = normalize_fedi_instance_url(instance_url)?;
        let client_id = required_oauth_text("client_id", client_id)?;
        let client_secret = required_oauth_text("client_secret", client_secret)?;
        let redirect_uri = required_oauth_text("redirect_uri", redirect_uri)?;
        let authorization_url = build_authorization_url(&instance_url, &client_id, &redirect_uri)?;
        Ok(Self {
            instance_url,
            client_id,
            client_secret,
            authorization_url,
        })
    }

    pub fn instance_url(&self) -> &str {
        &self.instance_url
    }

    pub fn client_id(&self) -> &str {
        &self.client_id
    }

    pub fn client_secret(&self) -> &str {
        &self.client_secret
    }

    pub fn authorization_url(&self) -> &str {
        &self.authorization_url
    }
}

#[derive(Debug, Default)]
pub struct MastodonOAuthProvider;

impl SyncOAuthProvider for MastodonOAuthProvider {
    fn register_application_with_redirect_uri(
        &self,
        instance_url: &str,
        redirect_uri: &str,
    ) -> Result<FediOAuthApplication, FediOAuthError> {
        let instance_url = normalize_fedi_instance_url(instance_url)?;
        let redirect_uri = required_oauth_text("redirect_uri", redirect_uri)?;
        let client = oauth_http_client(&instance_url)?;
        let response = client
            .post(endpoint_url(&instance_url, "/api/v1/apps")?)
            .form(&[
                ("client_name", CLIENT_NAME),
                ("redirect_uris", &redirect_uri),
                ("scopes", FEDI_OAUTH_SCOPE),
                ("website", CLIENT_WEBSITE),
            ])
            .send()
            .map_err(FediOAuthError::from)?;
        let body = checked_response_text("application registration", response)?;
        let json = parse_json_object("application registration", &body)?;
        let client_id = required_json_text(&json, "client_id")?;
        let client_secret = required_json_text(&json, "client_secret")?;
        FediOAuthApplication::from_parts_with_redirect_uri(
            &instance_url,
            &client_id,
            &client_secret,
            &redirect_uri,
        )
    }

    fn exchange_code_for_owner_identity(
        &self,
        pending: &SyncOAuthPendingLogin,
        authorization_code: &str,
    ) -> Result<String, FediOAuthError> {
        let authorization_code = required_oauth_text("authorization_code", authorization_code)?;
        let client = oauth_http_client(pending.instance_url())?;
        let response = client
            .post(endpoint_url(pending.instance_url(), "/oauth/token")?)
            .form(&[
                ("grant_type", "authorization_code"),
                ("client_id", pending.client_id()),
                ("client_secret", pending.client_secret()),
                ("redirect_uri", pending.redirect_uri()),
                ("scope", FEDI_OAUTH_SCOPE),
                ("code", &authorization_code),
            ])
            .send()
            .map_err(FediOAuthError::from)?;
        let body = checked_response_text("token exchange", response)?;
        let json = parse_json_object("token exchange", &body)?;
        let access_token = required_json_text(&json, "access_token")?;

        let owner_identity =
            match verify_owner_identity(&client, pending.instance_url(), &access_token) {
                Ok(owner_identity) => owner_identity,
                Err(error) => {
                    let _ = revoke_oauth_access_token(&client, pending, &access_token);
                    return Err(error);
                }
            };
        revoke_oauth_access_token(&client, pending, &access_token)?;
        Ok(owner_identity)
    }
}

fn verify_owner_identity(
    client: &Client,
    instance_url: &str,
    access_token: &str,
) -> Result<String, FediOAuthError> {
    let response = client
        .get(endpoint_url(
            instance_url,
            "/api/v1/accounts/verify_credentials",
        )?)
        .bearer_auth(access_token)
        .send()
        .map_err(FediOAuthError::from)?;
    let body = checked_response_text("credential verification", response)?;
    let json = parse_json_object("credential verification", &body)?;
    let acct = required_json_text(&json, "acct")?;
    owner_identity_from_mastodon_account(instance_url, &acct)
}

fn revoke_oauth_access_token(
    client: &Client,
    pending: &SyncOAuthPendingLogin,
    access_token: &str,
) -> Result<(), FediOAuthError> {
    let response = client
        .post(endpoint_url(pending.instance_url(), "/oauth/revoke")?)
        .form(&oauth_revoke_form(pending, access_token))
        .send()
        .map_err(FediOAuthError::from)?;
    checked_response_text("token revocation", response)?;
    Ok(())
}

fn oauth_revoke_form<'a>(
    pending: &'a SyncOAuthPendingLogin,
    access_token: &'a str,
) -> [(&'static str, &'a str); 3] {
    [
        ("client_id", pending.client_id()),
        ("client_secret", pending.client_secret()),
        ("token", access_token),
    ]
}

pub fn normalize_fedi_instance_url(value: &str) -> Result<String, FediOAuthError> {
    let value = value.trim();
    if value.is_empty() || value.contains('\r') || value.contains('\n') {
        return Err(FediOAuthError::InvalidInstanceUrl(
            "instance URL must be a single non-empty line".to_string(),
        ));
    }
    let with_scheme = if value.contains("://") {
        value.to_string()
    } else {
        format!("https://{value}")
    };
    let mut url = Url::parse(&with_scheme)
        .map_err(|error| FediOAuthError::InvalidInstanceUrl(error.to_string()))?;
    if url.scheme() != "https" {
        return Err(FediOAuthError::InvalidInstanceUrl(
            "sync OAuth instances must use https".to_string(),
        ));
    }
    if url.username() != "" || url.password().is_some() {
        return Err(FediOAuthError::InvalidInstanceUrl(
            "instance URL must not contain user information".to_string(),
        ));
    }
    if url.host_str().is_none() {
        return Err(FediOAuthError::InvalidInstanceUrl(
            "instance URL is missing a host".to_string(),
        ));
    }
    reject_unsafe_instance_host(url.host_str().unwrap())?;
    if url.query().is_some() || url.fragment().is_some() {
        return Err(FediOAuthError::InvalidInstanceUrl(
            "instance URL must not contain a query string or fragment".to_string(),
        ));
    }
    if !url.path().is_empty() && url.path() != "/" {
        return Err(FediOAuthError::InvalidInstanceUrl(
            "instance URL must point at the instance root".to_string(),
        ));
    }
    url.set_path("");
    Ok(url.to_string().trim_end_matches('/').to_string())
}

fn reject_unsafe_instance_host(host: &str) -> Result<(), FediOAuthError> {
    let normalized_host = host.trim_end_matches('.').to_ascii_lowercase();
    if normalized_host == "localhost" || normalized_host.ends_with(".localhost") {
        return Err(FediOAuthError::InvalidInstanceUrl(
            "instance host must not be localhost".to_string(),
        ));
    }
    let ip_host = normalized_host
        .strip_prefix('[')
        .and_then(|host| host.strip_suffix(']'))
        .unwrap_or(&normalized_host);
    if let Ok(ip_address) = ip_host.parse::<IpAddr>()
        && !is_public_oauth_ip(ip_address)
    {
        return Err(FediOAuthError::InvalidInstanceUrl(
            "instance host must not be a private, loopback, link-local, or reserved IP address"
                .to_string(),
        ));
    }
    Ok(())
}

fn is_public_oauth_ip(ip_address: IpAddr) -> bool {
    match ip_address {
        IpAddr::V4(address) => is_public_oauth_ipv4(address),
        IpAddr::V6(address) => is_public_oauth_ipv6(address),
    }
}

fn is_public_oauth_ipv4(address: Ipv4Addr) -> bool {
    if address.is_private()
        || address.is_loopback()
        || address.is_link_local()
        || address.is_broadcast()
        || address.is_documentation()
        || address.is_unspecified()
        || address.is_multicast()
    {
        return false;
    }
    let octets = address.octets();
    if octets[0] == 0 || octets[0] >= 240 {
        return false;
    }
    if octets[0] == 100 && (64..=127).contains(&octets[1]) {
        return false;
    }
    if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
        return false;
    }
    if octets[0] == 198 && matches!(octets[1], 18 | 19) {
        return false;
    }
    true
}

fn is_public_oauth_ipv6(address: Ipv6Addr) -> bool {
    if let Some(mapped_ipv4) = address.to_ipv4_mapped() {
        return is_public_oauth_ipv4(mapped_ipv4);
    }
    if address.is_unspecified() || address.is_loopback() || address.is_multicast() {
        return false;
    }
    let segments = address.segments();
    let first = segments[0];
    if (first & 0xfe00) == 0xfc00 {
        return false;
    }
    if (first & 0xffc0) == 0xfe80 {
        return false;
    }
    if first == 0x2001 && segments[1] == 0x0db8 {
        return false;
    }
    true
}

pub fn owner_identity_from_mastodon_account(
    instance_url: &str,
    acct: &str,
) -> Result<String, FediOAuthError> {
    let instance_url = normalize_fedi_instance_url(instance_url)?;
    let mut acct = required_oauth_text("acct", acct)?;
    while let Some(stripped) = acct.strip_prefix('@') {
        acct = stripped.to_string();
    }
    let qualified = if acct.contains('@') {
        acct
    } else {
        format!("{acct}@{}", instance_host(&instance_url)?)
    };
    required_oauth_text("owner_identity", &format!("acct:{qualified}"))
}

fn build_authorization_url(
    instance_url: &str,
    client_id: &str,
    redirect_uri: &str,
) -> Result<String, FediOAuthError> {
    let mut url = endpoint_url(instance_url, "/oauth/authorize")?;
    url.query_pairs_mut()
        .append_pair("client_id", client_id)
        .append_pair("redirect_uri", redirect_uri)
        .append_pair("response_type", "code")
        .append_pair("scope", FEDI_OAUTH_SCOPE);
    Ok(url.to_string())
}

fn endpoint_url(instance_url: &str, path: &str) -> Result<Url, FediOAuthError> {
    let mut url = Url::parse(instance_url)
        .map_err(|error| FediOAuthError::InvalidInstanceUrl(error.to_string()))?;
    url.set_path(path);
    url.set_query(None);
    url.set_fragment(None);
    Ok(url)
}

fn instance_host(instance_url: &str) -> Result<String, FediOAuthError> {
    Url::parse(instance_url)
        .ok()
        .and_then(|url| url.host_str().map(str::to_string))
        .ok_or_else(|| FediOAuthError::InvalidInstanceUrl("instance URL is missing a host".into()))
}

fn oauth_http_client(instance_url: &str) -> Result<Client, FediOAuthError> {
    let (host, addrs) = resolve_public_instance_addrs(instance_url)?;
    Client::builder()
        .timeout(CLIENT_TIMEOUT)
        .redirect(redirect::Policy::none())
        .https_only(true)
        .user_agent(format!("Cosimo/{}", env!("CARGO_PKG_VERSION")))
        .resolve_to_addrs(&host, &addrs)
        .build()
        .map_err(FediOAuthError::from)
}

fn resolve_public_instance_addrs(
    instance_url: &str,
) -> Result<(String, Vec<SocketAddr>), FediOAuthError> {
    let instance_url = normalize_fedi_instance_url(instance_url)?;
    let url = Url::parse(&instance_url)
        .map_err(|error| FediOAuthError::InvalidInstanceUrl(error.to_string()))?;
    let host = url
        .host_str()
        .ok_or_else(|| FediOAuthError::InvalidInstanceUrl("instance URL is missing a host".into()))?
        .to_string();
    let port = url.port_or_known_default().ok_or_else(|| {
        FediOAuthError::InvalidInstanceUrl("instance URL is missing a port".into())
    })?;
    let addrs = (host.as_str(), port)
        .to_socket_addrs()
        .map_err(|error| FediOAuthError::Network(format!("could not resolve {host}: {error}")))?
        .collect::<Vec<_>>();
    if addrs.is_empty() {
        return Err(FediOAuthError::Network(format!(
            "could not resolve {host}: no addresses returned"
        )));
    }
    if let Some(address) = addrs
        .iter()
        .find(|address| !is_public_oauth_ip(address.ip()))
    {
        return Err(FediOAuthError::InvalidInstanceUrl(format!(
            "instance host resolved to a private, loopback, link-local, or reserved IP address: {}",
            address.ip()
        )));
    }
    Ok((host, addrs))
}

fn checked_response_text(
    endpoint: &'static str,
    response: reqwest::blocking::Response,
) -> Result<String, FediOAuthError> {
    let status = response.status();
    let body = response.text().map_err(FediOAuthError::from)?;
    if !status.is_success() {
        return Err(FediOAuthError::Remote {
            endpoint,
            status: status.as_u16(),
            body,
        });
    }
    Ok(body)
}

fn parse_json_object(endpoint: &'static str, text: &str) -> Result<Value, FediOAuthError> {
    serde_json::from_str::<Value>(text).map_err(|error| FediOAuthError::Json {
        endpoint,
        message: error.to_string(),
        body_summary: response_body_summary(text),
    })
}

fn response_body_summary(text: &str) -> String {
    let mut summary = text
        .chars()
        .map(|character| {
            if character.is_control() || character.is_whitespace() {
                ' '
            } else {
                character
            }
        })
        .collect::<String>();
    summary = summary.trim().to_string();
    if summary.is_empty() {
        return "empty response body".to_string();
    }
    const MAX_SUMMARY_CHARS: usize = 160;
    if summary.chars().count() > MAX_SUMMARY_CHARS {
        let mut truncated = summary.chars().take(MAX_SUMMARY_CHARS).collect::<String>();
        truncated.push_str("...");
        return truncated;
    }
    summary
}

fn required_json_text(json: &Value, field: &'static str) -> Result<String, FediOAuthError> {
    json.get(field)
        .and_then(Value::as_str)
        .map(|value| value.to_string())
        .ok_or(FediOAuthError::MissingJsonField(field))
        .and_then(|value| required_oauth_text(field, &value))
}

fn required_oauth_text(field: &'static str, value: &str) -> Result<String, FediOAuthError> {
    let value = value.trim();
    if value.is_empty() {
        return Err(FediOAuthError::Validation(
            SyncValidationError::EmptyValue { field },
        ));
    }
    if value.contains('\r') || value.contains('\n') {
        return Err(FediOAuthError::Validation(
            SyncValidationError::MultilineValue { field },
        ));
    }
    Ok(value.to_string())
}

#[derive(Debug)]
pub enum FediOAuthError {
    InvalidInstanceUrl(String),
    Network(String),
    Remote {
        endpoint: &'static str,
        status: u16,
        body: String,
    },
    Json {
        endpoint: &'static str,
        message: String,
        body_summary: String,
    },
    MissingJsonField(&'static str),
    Validation(SyncValidationError),
}

impl fmt::Display for FediOAuthError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidInstanceUrl(message) => {
                write!(f, "invalid federated instance URL: {message}")
            }
            Self::Network(message) => write!(f, "federated OAuth request failed: {message}"),
            Self::Remote {
                endpoint,
                status,
                body,
            } => write!(
                f,
                "federated OAuth {endpoint} failed with HTTP {status}: {}",
                body.trim()
            ),
            Self::Json {
                endpoint,
                message,
                body_summary,
            } => write!(
                f,
                "federated OAuth {endpoint} returned a response that was not valid JSON: {message}. Response summary: {body_summary}"
            ),
            Self::MissingJsonField(field) => {
                write!(f, "federated OAuth response is missing {field}")
            }
            Self::Validation(error) => error.fmt(f),
        }
    }
}

impl std::error::Error for FediOAuthError {}

impl From<reqwest::Error> for FediOAuthError {
    fn from(error: reqwest::Error) -> Self {
        Self::Network(error.to_string())
    }
}

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

    #[test]
    fn normalizes_fedi_instance_url_for_root_https_instances() {
        assert_eq!(
            normalize_fedi_instance_url("mastodon.social").unwrap(),
            "https://mastodon.social"
        );
        assert_eq!(
            normalize_fedi_instance_url(" https://example.invalid:8443/ ").unwrap(),
            "https://example.invalid:8443"
        );
    }

    #[test]
    fn rejects_non_https_or_non_root_instance_urls() {
        assert!(normalize_fedi_instance_url("http://example.invalid").is_err());
        assert!(normalize_fedi_instance_url("https://example.invalid/users/me").is_err());
        assert!(normalize_fedi_instance_url("https://user@example.invalid").is_err());
    }

    #[test]
    fn rejects_obviously_private_or_local_instance_hosts() {
        for value in [
            "https://localhost",
            "https://server.localhost",
            "https://127.0.0.1",
            "https://10.0.0.1",
            "https://172.16.0.1",
            "https://192.168.1.1",
            "https://169.254.1.1",
            "https://[::1]",
            "https://[fc00::1]",
            "https://[fe80::1]",
        ] {
            assert!(
                normalize_fedi_instance_url(value).is_err(),
                "unsafe instance host should be rejected: {value}"
            );
        }
    }

    #[test]
    fn resolved_instance_addresses_are_checked_and_pinned() {
        let (host, addrs) = resolve_public_instance_addrs("https://93.184.216.34:8443").unwrap();

        assert_eq!(host, "93.184.216.34");
        assert_eq!(addrs, vec!["93.184.216.34:8443".parse().unwrap()]);
    }

    #[test]
    fn resolved_private_instance_addresses_are_rejected() {
        let error = resolve_public_instance_addrs("https://127.0.0.1").unwrap_err();

        assert!(matches!(error, FediOAuthError::InvalidInstanceUrl(_)));
    }

    #[test]
    fn authorization_url_uses_configured_redirect_and_read_accounts_scope() {
        let app = FediOAuthApplication::from_parts_with_redirect_uri(
            "https://example.invalid",
            "client id",
            "client secret",
            "https://sync.example.invalid/auth/fedi/callback",
        )
        .unwrap();

        assert!(
            app.authorization_url()
                .starts_with("https://example.invalid/oauth/authorize?")
        );
        assert!(
            app.authorization_url().contains(
                "redirect_uri=https%3A%2F%2Fsync.example.invalid%2Fauth%2Ffedi%2Fcallback"
            )
        );
        assert!(app.authorization_url().contains("response_type=code"));
        assert!(app.authorization_url().contains("scope=read%3Aaccounts"));
    }

    #[test]
    fn revoke_form_uses_application_credentials_and_access_token() {
        let pending = SyncOAuthPendingLogin::from_parts_with_redirect_uri(
            crate::SyncOAuthPendingLoginParts {
                login_token: "login-token",
                instance_url: "https://example.invalid",
                client_id: "client id",
                client_secret: "client secret",
                redirect_uri: "https://sync.example.invalid/auth/fedi/callback",
                expected_owner_identity: None,
                created_at_unix_seconds: 123,
                accepted_notice_version: None,
                accepted_notice_hash: None,
                accepted_lawful_basis: None,
                accepted_at_unix_seconds: None,
            },
        )
        .unwrap();

        assert_eq!(
            oauth_revoke_form(&pending, "fedi access token"),
            [
                ("client_id", "client id"),
                ("client_secret", "client secret"),
                ("token", "fedi access token"),
            ]
        );
    }

    #[test]
    fn json_parse_errors_name_oauth_endpoint_and_summarize_empty_response() {
        let error = parse_json_object("application registration", "").unwrap_err();

        let message = error.to_string();
        assert!(message.contains("application registration"));
        assert!(message.contains("not valid JSON"));
        assert!(message.contains("empty response body"));
    }

    #[test]
    fn json_parse_errors_summarize_html_response() {
        let error = parse_json_object(
            "token exchange",
            "<!doctype html>\n<title>Bad gateway</title>\n<h1>Bad gateway</h1>",
        )
        .unwrap_err();

        let message = error.to_string();
        assert!(message.contains("token exchange"));
        assert!(message.contains("<!doctype html> <title>Bad gateway</title>"));
        assert!(!message.contains('\n'));
    }

    #[test]
    fn owner_identity_qualifies_local_mastodon_acct_with_instance_host() {
        assert_eq!(
            owner_identity_from_mastodon_account("https://example.invalid", "modulus").unwrap(),
            "acct:modulus@example.invalid"
        );
        assert_eq!(
            owner_identity_from_mastodon_account(
                "https://example.invalid",
                "@modulus@other.invalid"
            )
            .unwrap(),
            "acct:modulus@other.invalid"
        );
    }
}
