use gettextrs::*;
use std::cell::RefCell;

use adw::prelude::*;
use kittybox_indieauth::{AuthorizationRequest, AuthorizationResponse, Error as IndieauthError, GrantResponse, Metadata};
use relm4::prelude::*;
use soup::prelude::{ServerExt, ServerExtManual, SessionExt};

const REDIRECT_URI: &str = "http://localhost:60000/callback";

#[derive(Debug)]
pub struct Output {
    pub me: glib::Uri,
    pub micropub: glib::Uri,
    pub userinfo: Option<glib::Uri>,
    pub scope: kittybox_indieauth::Scopes,

    pub access_token: String,
    pub refresh_token: Option<String>,
    pub expires_in: Option<std::time::Duration>,
    pub profile: Option<kittybox_indieauth::Profile>,
}

#[derive(Debug)]
pub struct SignIn {
    client_id: glib::Uri,
    me_buffer: gtk::EntryBuffer,

    http: soup::Session,
    busy_guard: Option<gio::ApplicationBusyGuard>,
    callback_server: Option<soup::Server>,

    state: kittybox_indieauth::State,
    code_verifier: kittybox_indieauth::PKCEVerifier,
    micropub_uri: Option<glib::Uri>,
    metadata: Option<Metadata>
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("glib error: {0}")]
    Glib(#[from] glib::Error),
    #[error("indieauth error: {0}")]
    IndieAuth(#[from] IndieauthError),
    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),
    #[error("error parsing query string: {0}")]
    QueryDecode(#[from] serde_urlencoded::de::Error),
    #[error("error parsing your homepage: {0}")]
    Mf2(#[from] microformats::Error),

    #[error("Your website doesn't support IndieAuth.")]
    MetadataNotFound,
    #[error("Your website doesn't support Micropub.")]
    MicropubLinkNotFound,
    #[error("Not a HTTP[S] URL!")]
    WrongScheme,
    #[error("Your IndieAuth metadata endpoint returned HTTP status {0:?}")]
    MetadataEndpointFailed(soup::Status),
}

#[doc(hidden)]
#[derive(Debug)]
#[allow(private_interfaces)]
pub enum Input {
    Start,
    Callback(Result<AuthorizationResponse, Error>),
}

pub async fn get_metadata(http: soup::Session, url: glib::Uri) -> Result<(Metadata, glib::Uri), Error> {
    // Fire off a speculative request at the well-known URI. This could
    // improve UX by parallelizing how we query the user's website.
    //
    // Note that exposing the metadata at the .well-known URI is
    // RECOMMENDED though optional according to IndieAuth specification
    // ยง 4.1.1, so we could use that if it's there to speed up the
    // process.
    let metadata = relm4::spawn_local(
        SignIn::well_known_metadata(http.clone(), url.clone())
    );
    let msg = soup::Message::from_uri("GET", &url);
    let body = http.send_future(&msg, glib::Priority::DEFAULT).await?;

    let mf2 = microformats::from_reader(
        std::io::BufReader::new(body.into_read()),
        url.to_string().parse().unwrap()
    )?;

    let rels = mf2.rels.by_rels();
    let metadata_url = if let Some(url) = rels
        .get("indieauth-metadata")
        .map(Vec::as_slice)
        .and_then(<[_]>::first)
    {
        glib::Uri::parse(url.as_str(), glib::UriFlags::NONE).unwrap()
    } else {
        // I intentionally refuse to support older IndieAuth versions.
        // The new versions are superior by providing more features that
        // were previously proprietary extensions, and are more clearer in
        // general.
        return Err(Error::MetadataNotFound)
    };

    let micropub_uri = if let Some(url) = rels
        .get("micropub")
        .map(Vec::as_slice)
        .and_then(<[_]>::first)
    {
        glib::Uri::parse(url.as_str(), glib::UriFlags::NONE).unwrap()
    } else {
        return Err(Error::MicropubLinkNotFound)
    };

    if let Ok(Some(metadata)) = metadata.await {
        Ok((metadata, micropub_uri))
    } else {
        let msg = soup::Message::from_uri("GET", &metadata_url);
        msg.request_headers().unwrap().append("Accept", "application/json");
        match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await {
            Ok(body) if msg.status() == soup::Status::Ok => {
                let metadata = serde_json::from_slice(&body)?;
                Ok((metadata, micropub_uri))
            },
            Ok(_) => Err(Error::MetadataEndpointFailed(msg.status())),
            Err(err) => Err(err.into()),
        }
    }
}

fn callback_handler(sender: AsyncComponentSender<SignIn>) -> impl Fn(&soup::Server, &soup::ServerMessage, &str, std::collections::HashMap<&str, &str>) {
    move |server, msg, _, _| {
        let server = ObjectExt::downgrade(server);
        let sender = sender.clone();
        let url = msg.uri().unwrap();
        let q = url.query().unwrap_or_default();
        match serde_urlencoded::from_str::<AuthorizationResponse>(q.as_str()) {
            Ok(response) => {
                // We're using RefCells here because of a deficiency in GLib types.
                //
                // The `finished` signal is only supposed to be fired once for a given
                // SoupServerMessage, but the type system doesn't know about this.
                //
                // Thus we're forced to put our moved value into a RefCell<Option<T>>,
                // from which it can only be taken once. Taking it twice results in a
                // panic because of the `Option::unwrap()`.
                let response = RefCell::new(Some(response));
                msg.set_status(200, soup::Status::phrase(200).as_deref());
                msg.set_response(
                    Some("text/plain; charset=\"utf-8\""),
                    soup::MemoryUse::Static,
                    gettext("Thank you! This window can now be closed.").as_bytes()
                );
                msg.connect_finished(move |_| {
                    sender.input(Input::Callback(Ok(response.take().unwrap())));
                    if let Some(server) = server.upgrade() {
                        log::info!("Stopping callback receiver server...");
                        soup::prelude::ServerExt::disconnect(&server);
                    }
                });
            },
            Err(err) => {
                msg.set_status(400, soup::Status::phrase(400).as_deref());
                if let Ok(err) = serde_urlencoded::from_str::<IndieauthError>(q.as_str()) {
                    let err = RefCell::new(Some(err));
                    msg.connect_finished(move |_| {
                        sender.input(Input::Callback(Err(err.take().unwrap().into())));
                        if let Some(server) = server.upgrade() {
                            log::info!("Stopping callback receiver server...");
                            soup::prelude::ServerExt::disconnect(&server);
                        }
                    });
                } else {
                    let err = RefCell::new(Some(err));
                    msg.connect_finished(move |_| {
                        sender.input(Input::Callback(Err(err.take().unwrap().into())));
                        if let Some(server) = server.upgrade() {
                            log::info!("Stopping callback receiver server...");
                            soup::prelude::ServerExt::disconnect(&server);
                        }
                    });
                }
            }
        };
    }
}

impl SignIn {
    const WELL_KNOWN_METADATA_ENDPOINT_PATH: &str = "/.well-known/oauth-authorization-server";

    pub fn scopes() -> kittybox_indieauth::Scopes {
        kittybox_indieauth::Scopes::new(vec![
            kittybox_indieauth::Scope::Profile,
            kittybox_indieauth::Scope::Create,
            kittybox_indieauth::Scope::Media
        ])
    }

    fn bail_out(&mut self, widgets: &mut <Self as AsyncComponent>::Widgets, sender: AsyncComponentSender<Self>, err: Error) {
        widgets.toasts.add_toast(adw::Toast::builder()
            .title(err.to_string())
            .priority(adw::ToastPriority::High)
            .build()
        );
        // Reset all the state for the component for security reasons.
        self.busy_guard = None;
        self.callback_server = None;
        self.metadata = None;
        self.micropub_uri = None;
        self.state = kittybox_indieauth::State::new();
        self.code_verifier = kittybox_indieauth::PKCEVerifier::new();
        self.update_view(widgets, sender);
    }

    async fn well_known_metadata(http: soup::Session, url: glib::Uri) -> Option<Metadata> {
        let well_known = url.parse_relative(
            Self::WELL_KNOWN_METADATA_ENDPOINT_PATH,
            glib::UriFlags::NONE
        ).unwrap();
        // Speculatively check for metadata at the well-known path
        let msg = soup::Message::from_uri("GET", &well_known);
        msg.request_headers().unwrap().append("Accept", "application/json");
        match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await {
            Ok(body) if msg.status() == soup::Status::Ok => {
                match serde_json::from_slice(&body) {
                    Ok(metadata) => {
                        log::info!("Speculative metadata request successful: {:#?}", metadata);
                        Some(metadata)
                    },
                    Err(err) => {
                        log::warn!("Parsing OAuth2 metadata from {} failed: {}", well_known, err);
                        None
                    }
                }
            },
            Ok(_) => {
                log::warn!("Speculative request to {} returned {:?} ({})", well_known, msg.status(), msg.reason_phrase().unwrap());

                None
            },
            Err(err) => {
                log::warn!("Speculative request to {} failed: {}", well_known, err);

                None
            }
        }
    }
}

#[relm4::component(pub async)]
impl AsyncComponent for SignIn {
    type CommandOutput = ();
    #[doc(hidden)]
    type Input = Input;
    type Output = Output;
    /// Client ID for authorizing.
    type Init = (glib::Uri, soup::Session);

    view! {
        #[root]
        #[name = "toasts"]
        adw::ToastOverlay {
            adw::Clamp {
                set_maximum_size: 360,

                gtk::Box {
                    set_orientation: gtk::Orientation::Vertical,
                    set_spacing: 16,
                    set_margin_all: 16,

                    gtk::Label {
                        add_css_class: "title-1",
                        set_text: &gettext("Sign in"),
                        set_justify: gtk::Justification::Center,
                    },

                    gtk::Label {
                        set_text: &gettext("Please sign in with your website to use Bowl.\nYour website needs to support IndieAuth and Micropub for this app to work."),
                        set_wrap: true,
                        set_halign: gtk::Align::BaselineCenter,
                        set_valign: gtk::Align::BaselineCenter,
                        set_justify: gtk::Justification::Center,
                    },

                    gtk::Entry {
                        set_buffer: &model.me_buffer,
                        set_placeholder_text: Some("https://example.com/"),
                        connect_activate => Self::Input::Start,
                        #[watch] set_sensitive: model.callback_server.is_none() && model.busy_guard.is_none(),
                    },

                    gtk::Button {
                        set_hexpand: false,
                        set_halign: gtk::Align::Center,
                        #[wrap(Some)]
                        set_child = &gtk::Box {
                            set_halign: gtk::Align::Center,
                            set_orientation: gtk::Orientation::Horizontal,
                            set_spacing: 5,
                            gtk::Spinner {
                                set_spinning: true,
                                #[watch]
                                set_visible: model.busy_guard.is_some() || model.callback_server.is_some(),
                            },
                            gtk::Label {
                                #[watch]
                                set_text: &if model.busy_guard.is_some() {
                                    gettext("Talking to your website...")
                                } else if model.callback_server.is_some() {
                                    gettext("Waiting for authorization...")
                                } else {
                                    gettext("Sign in")
                                },
                            }
                        },
                        connect_clicked => Self::Input::Start,
                        #[watch] set_sensitive: model.callback_server.is_none() && model.busy_guard.is_none(),
                    },
                }
            }
        }
    }

    fn init(
        init: Self::Init,
        root: Self::Root,
        sender: AsyncComponentSender<Self>,
    ) -> impl std::future::Future<Output = AsyncComponentParts<Self>> {
        let model = Self {
            client_id: init.0,
            me_buffer: Default::default(),

            http: init.1,
            callback_server: None,
            busy_guard: None,

            state: kittybox_indieauth::State::new(),
            code_verifier: kittybox_indieauth::PKCEVerifier::new(),
            micropub_uri: None,
            metadata: None,
        };

        let widgets = view_output!();

        std::future::ready(AsyncComponentParts { model, widgets })
    }

    async fn update_with_view(&mut self, widgets: &mut Self::Widgets, msg: Self::Input, sender: AsyncComponentSender<Self>, _root: &Self::Root) {
        match msg {
            Input::Start => {
                self.busy_guard = Some(relm4::main_adw_application().mark_busy());
                self.update_view(widgets, sender.clone());

                let mut url: String = self.me_buffer.text().into();
                // Normalize bare domains by assuming `https://`
                match glib::Uri::peek_scheme(url.as_str()) {
                    None => {
                        self.me_buffer.insert_text(0, "https://");
                        url = self.me_buffer.text().into();
                    },
                    Some(scheme) => {
                        if scheme != "https" && scheme != "http" {
                            return self.bail_out(
                                widgets, sender,
                                Error::WrongScheme
                            );
                        }
                    },
                }
                let url = match glib::Uri::parse(url.as_str(), glib::UriFlags::SCHEME_NORMALIZE) {
                    Ok(url) => url,
                    Err(err) => {
                        return self.bail_out(
                            widgets, sender,
                            err.into()
                        );
                    },
                };

                let (metadata, micropub_uri) = match get_metadata(self.http.clone(), url.clone()).await {
                    Ok((metadata, micropub_uri)) => (metadata, micropub_uri),
                    Err(err) => return self.bail_out(widgets, sender, err)
                };

                let auth_request = AuthorizationRequest {
                    response_type: kittybox_indieauth::ResponseType::Code,
                    client_id: self.client_id.to_str().parse().unwrap(),
                    redirect_uri: REDIRECT_URI.parse().unwrap(),
                    state: self.state.clone(),
                    code_challenge: kittybox_indieauth::PKCEChallenge::new(
                        &self.code_verifier, kittybox_indieauth::PKCEMethod::S256
                    ),
                    scope: Some(Self::scopes()),
                    me: Some(url.to_str().parse().unwrap())
                };

                let auth_url = {
                    let mut url = metadata.authorization_endpoint.clone();
                    url.query_pairs_mut().extend_pairs(auth_request.as_query_pairs());

                    url
                };

                self.metadata = Some(metadata);
                self.micropub_uri = Some(micropub_uri);
                self.callback_server = Some({
                    let server = soup::Server::builder().build();
                    server.add_handler(None, callback_handler(sender.clone()));
                    match server.listen_local(60000, soup::ServerListenOptions::empty()) {
                        Ok(()) => server,
                        Err(err) => return self.bail_out(widgets, sender, err.into())
                    }
                });

                if let Err(err) = gtk::UriLauncher::new(auth_url.as_str()).launch_future(
                    None::<&adw::ApplicationWindow>
                ).await {
                    return self.bail_out(widgets, sender, err.into())
                };

                self.busy_guard = None;
                self.update_view(widgets, sender);
            },
            Input::Callback(Ok(res)) => {
                // Immediately drop the event if we didn't take a server.
                if self.callback_server.take().is_none() { return; }
                self.busy_guard = Some(relm4::main_adw_application().mark_busy());
                let metadata = self.metadata.take().unwrap();
                let micropub_uri = self.micropub_uri.take().unwrap();

                if res.state != self.state {
                    return self.bail_out(widgets, sender, IndieauthError {
                        kind: kittybox_indieauth::ErrorKind::InvalidRequest,
                        msg: Some(gettext("state doesn't match what we remember, ceremony aborted")),
                        error_uri: None,
                    }.into())
                }

                if res.iss != metadata.issuer {
                    return self.bail_out(widgets, sender, IndieauthError {
                        kind: kittybox_indieauth::ErrorKind::InvalidRequest,
                        msg: Some(gettext("issuer doesn't match what we remember, ceremony aborted")),
                        error_uri: None,
                    }.into())
                }

                let code = res.code;
                let token_grant = kittybox_indieauth::GrantRequest::AuthorizationCode {
                    code,
                    client_id: self.client_id.to_string().parse().unwrap(),
                    redirect_uri: REDIRECT_URI.parse().unwrap(),
                    code_verifier: std::mem::replace(
                        &mut self.code_verifier,
                        kittybox_indieauth::PKCEVerifier::new()
                    )
                };

                let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE).unwrap();
                let msg = soup::Message::from_uri("POST", &url);
                let headers = msg.request_headers().unwrap();
                headers.append("Accept", "application/json");
                msg.set_request_body_from_bytes(
                    Some("application/x-www-form-urlencoded"),
                    Some(&glib::Bytes::from_owned(
                        serde_urlencoded::to_string(token_grant).unwrap().into_bytes()
                    ))
                );
                match self.http.send_and_read_future(&msg, glib::Priority::DEFAULT).await {
                    Ok(body) if msg.status() == soup::Status::Ok => {
                        match serde_json::from_slice::<GrantResponse>(&body) {
                            Ok(GrantResponse::ProfileUrl(_)) => unreachable!(),
                            Ok(GrantResponse::AccessToken {
                                me,
                                token_type: _,
                                scope,
                                access_token,
                                state: _,
                                expires_in,
                                profile,
                                refresh_token
                            }) => {
                                let _ = sender.output(Output {
                                    me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE).unwrap(),
                                    scope: scope.unwrap_or_else(Self::scopes),
                                    micropub: micropub_uri,
                                    userinfo: metadata.userinfo_endpoint
                                        .map(|u| glib::Uri::parse(
                                            u.as_str(), glib::UriFlags::NONE
                                        ).unwrap()),
                                    access_token,
                                    refresh_token,
                                    expires_in: expires_in.map(std::time::Duration::from_secs),
                                    profile,
                                });
                                self.busy_guard = None;
                                self.update_view(widgets, sender);
                            },
                            Err(err) => self.bail_out(widgets, sender, err.into()),
                        }
                    },
                    Ok(body) => {
                        match serde_json::from_slice::<IndieauthError>(&body) {
                            Ok(err) => self.bail_out(widgets, sender, err.into()),
                            Err(err) => self.bail_out(widgets, sender, err.into())
                        }
                    },
                    Err(err) => self.bail_out(widgets, sender, err.into())
                }
            },
            Input::Callback(Err(err)) => {
                self.bail_out(widgets, sender, err)
            },
        }
    }

    fn shutdown(&mut self, _: &mut Self::Widgets, _: relm4::Sender<Self::Output>) {
        if let Some(server) = self.callback_server.as_ref() {
            soup::prelude::ServerExt::disconnect(server);
        }
    }
}