use gettextrs::*;
use adw::prelude::*;
use libsecret::prelude::{RetrievableExtManual, RetrievableExt};
use relm4::{actions::{RelmAction, RelmActionGroup}, gtk, loading_widgets::LoadingWidgets, prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt};

pub mod icons {
    include!(concat!(env!("OUT_DIR"), "/icons.rs"));
}

pub mod components {
    pub(crate) mod smart_summary;
    #[cfg(feature = "smart-summary")]
    pub(crate) use smart_summary::{
        SmartSummaryButton, Output as SmartSummaryOutput, Input as SmartSummaryInput
    };

    pub(crate) mod post_editor;
    pub(crate) use post_editor::{
        PostEditor, Input as PostEditorInput
    };

    pub(crate) mod tag_pill;
    // pub(crate) use tag_pill::{TagPill, TagPillDelete}

    pub mod signin;
    pub use signin::{SignIn, Output as SignInOutput, Error as SignInError};

    pub mod preferences;
    pub use preferences::Preferences;
}

use components::{post_editor::Post, PostEditorInput};
use soup::prelude::SessionExt;

pub mod secrets;
pub mod micropub;
pub mod util;
pub const APPLICATION_ID: &str = "xyz.vikanezrimaya.kittybox.Bowl";
pub const CLIENT_ID_STR: &str = "https://kittybox.fireburn.ru/bowl/";

#[derive(Debug)]
pub struct App {
    secret_schema: libsecret::Schema,
    http: soup::Session,
    submit_busy_guard: Option<gtk::gio::ApplicationBusyGuard>,

    // TODO: make this support multiple users
    micropub: Option<micropub::Client>,

    signin: AsyncController<components::SignIn>,
    post_editor: Controller<components::PostEditor<micropub::Error>>,
}

impl App {
    async fn authorize(schema: &libsecret::Schema, http: soup::Session, data: Box<components::SignInOutput>) -> Result<micropub::Client, glib::Error> {
        let mut attributes = std::collections::HashMap::new();
        let me = data.me.to_string();
        let _micropub = data.micropub.to_string();
        let scope = data.scope.to_string();
        attributes.insert(secrets::ME, me.as_str());
        attributes.insert(secrets::TOKEN_KIND, secrets::ACCESS_TOKEN);
        attributes.insert(secrets::MICROPUB, _micropub.as_str());
        attributes.insert(secrets::SCOPE, scope.as_str());
        let exp = data.expires_in
            .as_ref()
            .map(std::time::Duration::as_secs)
            .as_ref()
            .map(u64::to_string);
        if let Some(expires_in) = exp.as_deref() {
            attributes.insert(secrets::EXPIRES_IN, expires_in);
        }

        match libsecret::password_store_future(
            Some(schema),
            attributes.clone(),
            Some(libsecret::COLLECTION_DEFAULT),
            &gettext!("Micropub access token for {}", &data.me),
            &data.access_token
        ).await {
            Ok(()) => {},
            Err(err) => {
                log::error!("Failed to store access token to the secret store: {}", err);
                return Err(err)
            },
        }
        if let Some(refresh_token) = data.refresh_token.as_deref() {
            attributes.insert(secrets::TOKEN_KIND, secrets::REFRESH_TOKEN);
            attributes.remove(secrets::EXPIRES_IN);
            match libsecret::password_store_future(
                Some(schema),
                attributes,
                Some(libsecret::COLLECTION_DEFAULT),
                &format!("Micropub refresh token for {}", &data.me),
                refresh_token
            ).await {
                Ok(()) => {},
                Err(err) => {
                    log::error!("Failed to store refresh token to the secret store: {}", err);
                    return Err(err)
                },
            }
        }

        Ok(micropub::Client::new(
            http.clone(), data.micropub.clone(), data.access_token.clone(), me
        ))
    }

    async fn refresh_token(schema: &libsecret::Schema, http: soup::Session, me: String) -> Result<Option<micropub::Client>, glib::Error> {
        let mut retrievables = libsecret::password_search_future(Some(schema), {
            let mut attrs = std::collections::HashMap::default();
            attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::REFRESH_TOKEN);
            attrs.insert(crate::secrets::ME, &me);
            attrs
        }, libsecret::SearchFlags::ALL).await?;

        if retrievables.is_empty() {
            Ok(None)
        } else {
            retrievables.sort_by_key(|s| s.created());
            let iterable = retrievables.iter().rev();
            for retrievable in iterable {
                let attrs = retrievable.attributes();
                let attrs_ref: std::collections::HashMap<&str, &str> = attrs
                    .iter()
                    .map(|(k, v)| (k.as_str(), v.as_str()))
                    .collect();

                let token = retrievable.retrieve_secret_future().await?
                    .unwrap()
                    .text()
                    .unwrap()
                    .to_string();

                let url = glib::Uri::parse(me.as_str(), glib::UriFlags::SCHEME_NORMALIZE)?;

                let (metadata, micropub_uri) = match crate::components::signin::get_metadata(http.clone(), url).await {
                    Ok(res) => res,
                    Err(err) => {
                        tracing::warn!("failed to fetch metadata to refresh an expired token: {}", err);
                        return Ok(None)
                    }
                };

                let grant = kittybox_indieauth::GrantRequest::RefreshToken {
                    refresh_token: token, client_id: CLIENT_ID_STR.parse().unwrap(), scope: None
                };

                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(grant).unwrap().into_bytes()
                    ))
                );

                match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await {
                    Ok(body) if msg.status() == soup::Status::Ok => {
                        match serde_json::from_slice::<kittybox_indieauth::GrantResponse>(&body) {
                            Ok(kittybox_indieauth::GrantResponse::ProfileUrl(_)) => unreachable!(),
                            Ok(kittybox_indieauth::GrantResponse::AccessToken {
                                me,
                                token_type: _,
                                scope,
                                access_token,
                                state: _,
                                expires_in,
                                profile,
                                refresh_token
                            }) => {
                                if refresh_token.is_some() {
                                    // Get rid of the old refresh token.
                                    let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await;                                    
                                };
                                let micropub = Self::authorize(
                                    schema, http, Box::new(components::SignInOutput {
                                        me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE).unwrap(),
                                        scope: scope.unwrap_or_else(components::SignIn::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,
                                    })
                                ).await?;

                                return Ok(Some(micropub))
                            },
                            Err(err) => {
                                tracing::warn!("failed to refresh token for {}: failed to parse grant response: {}", me, err);
                                return Ok(None)
                            },
                        }
                    },
                    Ok(body) => {
                        match serde_json::from_slice::<kittybox_indieauth::Error>(&body) {
                            Ok(err) => {
                                tracing::warn!("failed to refresh token for {}: token endpoint error: {}", me, err);
                                continue;
                            },
                            Err(err) => {
                                tracing::warn!("failed to refresh token for {}: error parsing token endpoint error: {}", me, err);
                                tracing::warn!("token endpoint response verbatim follows:\n{}", String::from_utf8_lossy(&body));
                                return Ok(None)
                            }
                        }
                    },
                    Err(err) => return Err(err),
                };

            }
            unreachable!()
        }
    }

    async fn revoke_token(http: soup::Session, me: String, token: String) -> Result<Option<()>, components::SignInError> {
        let url = glib::Uri::parse(
            me.as_str(),
            glib::UriFlags::SCHEME_NORMALIZE
        )?;

        let (metadata, _) = crate::components::signin::get_metadata(http.clone(), url).await?;

        let endpoint = match metadata.revocation_endpoint {
            Some(endpoint) => match metadata.revocation_endpoint_auth_methods_supported {
                Some(methods) => if methods.iter().any(|i| matches!(i, kittybox_indieauth::RevocationEndpointAuthMethod::None)) {
                    glib::Uri::parse(endpoint.as_str(), glib::UriFlags::NONE).unwrap()
                } else {
                    tracing::warn!("couldn't revoke token: revocation endpoint doesn't support unauthenticated requests\n(Note: this is a violation of the IndieAuth specification)");
                    return Ok(None)
                },
                None => {
                    tracing::warn!("couldn't revoke token: revocation endpoint doesn't support unauthenticated requests\n(Note: this is a violation of the IndieAuth specification)");
                    return Ok(None)
                }
            },
            None => {
                tracing::warn!("couldn't revoke token: revocation endpoint not found");
                return Ok(None)
            }
        };
        let msg = soup::Message::from_uri("POST", &endpoint);
        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(
                    kittybox_indieauth::TokenRevocationRequest { token }
                ).unwrap().into_bytes()
            ))
        );

        match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await {
            Ok(_) if msg.status() == soup::Status::Ok => {
                Ok(Some(()))
            },
            Ok(body) => {
                tracing::warn!("couldn't revoke token: revocation endpoint returned non-200: {:?}", msg.status());
                match serde_json::from_slice::<kittybox_indieauth::Error>(&body) {
                    Ok(err) => tracing::warn!("revocation endpoint returned an error: {}", err),
                    Err(_) => tracing::warn!("couldn't parse revocation endpoint error, response verbatim follows:\n{}", String::from_utf8_lossy(&body))
                }
                Ok(None)
            },
            Err(err) => {
                tracing::warn!("couldn't revoke token: error contacting revocation endpoint: {:?}", err);
                Err(err.into())
            }
        }
    }

    async fn get_login_state(schema: &libsecret::Schema, http: soup::Session) -> Result<Option<micropub::Client>, glib::Error> {
        let mut retrievables = libsecret::password_search_future(Some(schema), {
            let mut attrs = std::collections::HashMap::default();
            attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::ACCESS_TOKEN);
            attrs
        }, libsecret::SearchFlags::ALL).await?;

        if retrievables.is_empty() {
            Ok(None)
        } else {
            retrievables.sort_by_key(|s| s.created());
            let iterable = retrievables.iter().rev();
            for retrievable in iterable {
                let attrs = retrievable.attributes();
                let attrs_ref = attrs
                    .iter()
                    .map(|(k, v)| (k.as_ref(), v.as_ref()))
                    .collect();
                let micropub_uri = match attrs
                    .get(crate::secrets::MICROPUB)
                    .and_then(|v| glib::Uri::parse(
                        v, glib::UriFlags::NONE
                    ).ok()) {
                        Some(uri) => uri,
                        None => {
                            let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await;
                            continue
                        },
                    };

                let me = attrs.get(crate::secrets::ME).unwrap().to_string();
                let micropub = crate::micropub::Client::new(
                    http.clone(),
                    micropub_uri,
                    retrievable.retrieve_secret_future().await?
                        .unwrap()
                        .text()
                        .unwrap()
                        .to_string(),
                    me.clone()
                );

                // Skip the token if we can't access ?q=config
                if let Err(micropub::Error::Micropub(err)) = micropub.config().await {
                    tracing::warn!("Micropub token seems to be invalid. Let's try refreshing.");
                    if err.error == kittybox_util::micropub::ErrorKind::NotAuthorized {
                        // Token may have expired. See if we have a refresh token and renew.
                        let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await;

                        match Self::refresh_token(
                            schema, http.clone(),
                            me.clone()
                        ).await {
                            Ok(None) => continue,
                            Err(err) => {
                                tracing::warn!("error refreshing Micropub token for {}: {}", &me, err);
                                continue
                            },
                            Ok(Some(micropub)) => return Ok(Some(micropub))
                        }
                    }
                }

                return Ok(Some(micropub))
            }

            Ok(None)
        }
    }

    fn about() -> adw::AboutDialog {
        adw::AboutDialog::builder()
            .application_name(gettext("Bowl for Kittybox"))
            .developer_name("Vika Shleina")
            .version(env!("CARGO_PKG_VERSION"))
            .website("https://kittybox.fireburn.ru/bowl/")
            .developers(vec!["Vika https://fireburn.ru"])
            .license_type(gtk::License::Agpl30Only)
            .copyright("© 2024 Vika Shleina")
            .build()
    }
}

#[derive(Debug)]
#[doc(hidden)]
pub enum Input {
    SubmitButtonPressed,
    Authorize(Box<components::SignInOutput>),
    SignOut,
    PostEditor(Option<Post>),
}

relm4::new_action_group!(AppActionGroup, "app");
relm4::new_stateless_action!(AboutAction, AppActionGroup, "about");
relm4::new_stateless_action!(SignOutAction, AppActionGroup, "sign-out");
relm4::new_stateless_action!(PreferencesAction, AppActionGroup, "preferences");

#[relm4::component(pub async)]
impl AsyncComponent for App {
    /// The type of the messages that this component can receive.
    type Input = Input;
    /// The type of the messages that this component can send.
    type Output = ();
    /// The type of data with which this component will be initialized.
    type Init = ();
    /// The type of the command outputs that this component can receive.
    type CommandOutput = ();

    menu! {
        main_menu: {
            &gettext("Sign out") => SignOutAction,
            &gettext("Preferences") => PreferencesAction,
            &gettext("About") => AboutAction,
        }
    }

    view! {
        #[root]
        root = adw::ApplicationWindow {
            #[iterate]
            add_css_class: [
                #[cfg(debug_assertions)] "devel"
            ],

            #[watch]
            set_title: if model.micropub.is_none() {
                Some(gettext("Bowl - Sign in with your website"))
            } else {
                Some(gettext("Bowl"))
            }.as_deref(),

            adw::ToolbarView {
                add_top_bar = &adw::HeaderBar {
                    pack_end = &gtk::MenuButton {
                        #[wrap(Some)]
                        set_popover = &gtk::PopoverMenu::from_model(Some(&main_menu)) {},
                        #[watch]
                        set_visible: model.micropub.is_some(),
                        set_icon_name: "menu",
                    },
                    pack_end = &gtk::Button {
                        set_icon_name: "document-send-symbolic",
                        set_tooltip: &gettext("Publish"),
                        #[watch]
                        set_visible: model.micropub.is_some(),
                        #[watch]
                        set_sensitive: model.submit_busy_guard.is_none(),
                        connect_clicked => Self::Input::SubmitButtonPressed,
                    },
                },

                #[transition = "Crossfade"]
                match model.micropub.as_ref() {
                    Some(_) => model.post_editor.widget().clone(),
                    None => model.signin.widget().clone(),
                }
            },
        },
    }

    fn init_loading_widgets(root: Self::Root) -> Option<relm4::loading_widgets::LoadingWidgets> {
        root.set_size_request(360, 294);
        root.set_default_size(360, 640);

        let spinner = adw::Spinner::builder()
            .height_request(48)
            .width_request(48)
            .halign(gtk::Align::Center)
            .valign(gtk::Align::Center)
            .build();

        root.set_content(Some(&spinner));
        Some(LoadingWidgets::new(root, spinner))
    }

    /// Initialize the UI and model.
    async fn init(
        _init: Self::Init,
        window: Self::Root,
        sender: AsyncComponentSender<Self>,
    ) -> AsyncComponentParts<Self> {
        let secret_schema = crate::secrets::get_schema();
        let http = soup::Session::builder()
            .user_agent(concat!(env!("CARGO_PKG_NAME"),"/",env!("CARGO_PKG_VERSION")," "))
            .build();

        let state = App::get_login_state(
            &secret_schema, http.clone()
        ).await.unwrap();

        let model = App {
            submit_busy_guard: None,

            http: http.clone(),
            micropub: state,
            secret_schema,

            post_editor: {
                #[cfg(feature = "smart-summary")]
                let init = (http.clone(), None);
                #[cfg(not(feature = "smart-summary"))]
                let init = None;

                components::PostEditor::builder()
                    .launch(init)
                    .forward(sender.input_sender(), Self::Input::PostEditor)
            },
            signin: components::SignIn::builder()
                .launch((glib::Uri::parse(
                    CLIENT_ID_STR, glib::UriFlags::NONE
                ).unwrap(), http))
                .forward(
                    sender.input_sender(),
                    |o| Self::Input::Authorize(Box::new(o))
                )
        };

        let widgets = view_output!();

        let input_sender = sender.input_sender().clone();
        let weak_window = window.downgrade();
        let about_action: RelmAction<AboutAction> = RelmAction::new_stateless(move |_| {
            App::about().present(weak_window.upgrade().as_ref());
        });
        let weak_window = window.downgrade();
        let preferences_action: RelmAction<PreferencesAction> = RelmAction::new_stateless(move |_| {
            // This could be built as an action that sends an input to open preferences.
            //
            // But I find this an acceptable alternative.
            let mut prefs = components::Preferences::builder()
                .launch(())
                .detach();

            prefs.emit(weak_window.upgrade().map(|w| w.upcast()));
            prefs.detach_runtime();
        });
        let sign_out_action: RelmAction<SignOutAction> = RelmAction::new_stateless(move |_| {
            input_sender.emit(Input::SignOut)
        });
        let mut action_group: RelmActionGroup<AppActionGroup> = RelmActionGroup::new();
        action_group.add_action(about_action);
        action_group.add_action(preferences_action);
        action_group.add_action(sign_out_action);
        action_group.register_for_widget(&window);

        AsyncComponentParts { model, widgets }
    }


    async fn update(
        &mut self,
        message: Self::Input,
        _sender: AsyncComponentSender<Self>,
        _root: &Self::Root
    ) {
        match message {
            Input::SignOut => {
                if let Some(micropub) = self.micropub.take() {
                    let _ = libsecret::password_clear_future(
                        Some(&self.secret_schema),
                        Default::default(),
                    ).await;
                    let _ = Self::revoke_token(
                        self.http.clone(), micropub.me, micropub.access_token
                    ).await;
                }
            },
            Input::Authorize(data) => {
                if let Ok(micropub) = Self::authorize(&self.secret_schema, self.http.clone(), data).await {
                    self.micropub = Some(micropub);
                }
            },
            Input::SubmitButtonPressed => {
                if self.micropub.is_some() {
                    self.submit_busy_guard = Some(relm4::main_adw_application().mark_busy());
                    // TODO: too easy to deadlock here, refactor to take a channel
                    self.post_editor.emit(PostEditorInput::Submit);
                };
            },
            Input::PostEditor(None) => {
                self.submit_busy_guard = None;
            }
            Input::PostEditor(Some(post)) => {
                if let Some(micropub) = self.micropub.as_ref() {
                    let mf2 = post.into();
                    log::debug!("Submitting post: {:#}", serde_json::to_string(&mf2).unwrap());
                    match micropub.send_post(mf2).await {
                        Ok(uri) => {
                            self.post_editor.emit(PostEditorInput::SubmitDone(uri));
                        },
                        Err(err) => {
                            log::warn!("Error sending post: {}", err);
                            self.post_editor.emit(PostEditorInput::SubmitError(err));
                        }
                    }
                }
                self.submit_busy_guard = None;
            },
        }
    }
}