// SPDX-FileCopyrightText: 2023 Gleb Smirnov <glebsmirnov0708@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later

use adw::prelude::*;
use adw::subclass::prelude::*;
use textpieces_core::{Parameter, ParameterType, ParameterValue};

use std::fs;
use std::time::Duration;

use gettextrs::gettext;
use gtk::{gdk, gio, glib};

use crate::application::Application;
use crate::config::{APP_ID, VERSION};
use crate::utils::file_filters;

mod imp {
    use super::*;

    use std::cell::{Cell, OnceCell, RefCell};
    use std::iter;

    use crate::utils::{spawn_async_local, to_entries, GActions, TemplateFunctions};
    use crate::widgets::action_search::ActionSearch;
    use crate::widgets::editor::Editor;
    use crate::widgets::{DragOverlay, Preferences};

    use anyhow::anyhow;
    use futures::channel::oneshot;
    use futures::{select, FutureExt};
    use gettextrs::pgettext;
    use glib::clone;
    use tracing::warn;

    /// Private part of [`Window`].
    ///
    /// [`Window`]: super::Window
    #[derive(Default, Debug, gtk::CompositeTemplate, glib::Properties)]
    #[properties(wrapper_type = super::Window)]
    #[template(resource = "/io/gitlab/liferooter/TextPieces/ui/window.ui")]
    pub struct Window {
        /// Editor widget.
        #[template_child]
        editor: TemplateChild<Editor>,

        /// Drag overlay.
        #[template_child]
        drag_overlay: TemplateChild<DragOverlay>,

        /// "Select action" button.
        #[template_child]
        search_button: TemplateChild<gtk::ToggleButton>,

        /// Action search entry.
        #[template_child]
        search_entry: TemplateChild<gtk::SearchEntry>,

        /// Action search bar.
        #[template_child]
        search_bar: TemplateChild<gtk::SearchBar>,

        /// View stack.
        #[template_child]
        view_stack: TemplateChild<gtk::Stack>,

        /// Action search widget.
        #[template_child]
        search: TemplateChild<ActionSearch>,

        /// Toast overlay.
        ///
        /// Used to show action error message
        /// and general feedback messages.
        #[template_child]
        toast_overlay: TemplateChild<adw::ToastOverlay>,

        /// Progress bar for action performing loading.
        #[template_child]
        progress_bar: TemplateChild<gtk::ProgressBar>,

        /// Whether to show loading animation.
        #[property(get, set, default = false)]
        is_loading: Cell<bool>,

        /// Loading animation.
        loading_animation: OnceCell<adw::Animation>,
    }

    #[glib::object_subclass]
    impl ObjectSubclass for Window {
        const NAME: &'static str = "TextPiecesWindow";
        type Type = super::Window;
        type ParentType = adw::ApplicationWindow;

        fn class_init(class: &mut Self::Class) {
            class.bind_template();
            Self::bind_template_callbacks(class);
            TemplateFunctions::bind_template_callbacks(class);
        }

        fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
            obj.init_template();
        }
    }

    #[glib::derived_properties]
    impl ObjectImpl for Window {
        /// Object contructor.
        fn constructed(&self) {
            self.parent_constructed();

            // Put each window into separate window groud
            self.setup_window_group();

            // Load latest window state
            self.load_window_size();

            // Setup drag-n-drop
            self.setup_drop_target();

            // Setup loading animation
            self.setup_animation();

            let win = self.obj();

            // Setup actions
            Self::setup_gactions(&win);
        }
    }

    impl WidgetImpl for Window {}
    impl WindowImpl for Window {
        // Saves window state on delete event
        fn close_request(&self) -> glib::signal::Propagation {
            if let Err(err) = self.save_window_size() {
                warn!("Failed to save window state, {err}");
            }

            // Pass close request on to the parent
            self.parent_close_request()
        }
    }

    impl ApplicationWindowImpl for Window {}
    impl AdwApplicationWindowImpl for Window {}

    impl Window {
        /// Puts the window into a separater window group.
        fn setup_window_group(&self) {
            let group = gtk::WindowGroup::new();
            group.add_window(&*self.obj());
        }

        /// Sets up drag-n-drop for files.
        fn setup_drop_target(&self) {
            let drop_target = gtk::DropTarget::builder()
                .name("file-drop-target")
                .actions(gdk::DragAction::COPY)
                .formats(&gdk::ContentFormats::for_type(gio::File::static_type()))
                .build();

            let win = self.obj();
            drop_target.connect_drop(clone!(
                #[weak]
                win,
                #[upgrade_or]
                false,
                move |_, drop, _, _| {
                    if let Ok(file) = drop.get::<gio::File>() {
                        let win = win.imp();
                        let result = win.load_file(&file);

                        if let Err(err) = result {
                            win.show_toast(&gettext!("Failed to load file: {}", err));

                            return false;
                        }

                        true
                    } else {
                        false
                    }
                }
            ));

            self.drag_overlay.set_drop_target(&drop_target);
        }

        /// Sets up `GAction`s.
        fn setup_gactions(win: &super::Window) {
            let actions: GActions<super::Window> = &[
                ("about", |win| {
                    adw::AboutDialog::builder()
                        // Application info
                        .application_icon(APP_ID)
                        .application_name(gettext("Text Pieces"))
                        .comments(gettext("Developer's scratchpad"))
                        .version(VERSION)
                        // Links
                        .website("https://gitlab.com/liferooter/textpieces")
                        .issue_url("https://gitlab.com/liferooter/textpieces/-/issues")
                        // License
                        .license_type(gtk::License::Gpl30)
                        // Credits
                        .developer_name(gettext("Gleb Smirnov"))
                        .developers([gettext("Gleb Smirnov <glebsmirnov0708@gmail.com>").as_str()])
                        .artists([gettext("Tobias Bernard https://tobiasbernard.com").as_str()])
                        .translator_credits(gettext("translator-credits"))
                        .build()
                        .present(Some(win))
                }),
                ("copy-text", |win| {
                    let win = win.imp();
                    let text = win.editor.text(None);

                    gdk::Display::default()
                        .expect("Failed to get default display")
                        .clipboard()
                        .set_text(&text);

                    win.show_toast(&gettext("Text is copied"));
                }),
                ("load-file", |win| {
                    let win = win.to_owned();
                    spawn_async_local(async move { win.load_file(None).await });
                }),
                ("save-as", |win| {
                    let win = win.to_owned();
                    spawn_async_local(async move { win.save_as().await });
                }),
                ("open-preferences", |win| {
                    Preferences::new().present(Some(win));
                }),
                ("custom-actions", |win| {
                    let prefs = Preferences::new();
                    prefs.set_visible_page_name("actions-settings");
                    prefs.present(Some(win));
                }),
            ];

            win.add_action_entries(to_entries(actions, win));
        }

        /// Loads window size from `GSettings`.
        fn load_window_size(&self) {
            let settings = Application::settings();

            let width = settings.width();
            let height = settings.height();
            let is_maximized = settings.is_maximized();

            self.obj().set_default_size(width, height);

            if is_maximized {
                self.obj().maximize();
            }
        }

        /// Saves window size to `GSettings`.
        fn save_window_size(&self) -> Result<(), glib::BoolError> {
            let win = self.obj();

            let (width, height) = win.default_size();

            let settings = Application::settings();

            settings.try_set_width(width)?;
            settings.try_set_height(height)?;
            settings.try_set_is_maximized(win.is_maximized())?;

            Ok(())
        }

        /// Show message in a toast.
        pub fn show_toast(&self, message: &str) {
            self.toast_overlay.add_toast(
                adw::Toast::builder()
                    .priority(adw::ToastPriority::High)
                    .use_markup(false)
                    .title(message)
                    .timeout(5)
                    .build(),
            )
        }

        /// Loads the editor content from a file.
        ///
        /// This method is the internal implementation.
        /// The public API wrapper gracefully shows errors.
        pub fn load_file(&self, file: &gio::File) -> anyhow::Result<()> {
            let path = file
                .path()
                .ok_or(anyhow!(gettext("Failed to get file path")))?;

            self.editor
                .set_text(&fs::read_to_string(path)?, None, false);

            Ok(())
        }

        /// Saves the editor content to a file.
        ///
        /// This method is the internal implementation.
        /// The public API wrapper gracefully shows errors
        /// and asks for a file.
        pub fn save_as(&self, file: &gio::File) -> anyhow::Result<()> {
            let path = file
                .path()
                .ok_or(anyhow!(gettext("Failed to get file path")))?;

            let text = self.editor.text(None);
            fs::write(path, text)?;

            Ok(())
        }

        fn setup_animation(&self) {
            let window = self.obj().downgrade();
            let animation = adw::TimedAnimation::builder()
                .widget(&*self.obj())
                .value_from(0.)
                .value_to(1.)
                .duration(800 /* ms */)
                .alternate(true)
                .target(&adw::CallbackAnimationTarget::new(move |value| {
                    let Some(window) = window.upgrade() else {
                        return;
                    };

                    let progress_bar = window.imp().progress_bar.get();
                    let new_fraction = value.abs();

                    progress_bar.set_inverted(progress_bar.fraction() > new_fraction);
                    progress_bar.set_fraction(new_fraction);
                }))
                .repeat_count(0)
                .easing(adw::Easing::EaseInQuad)
                .build();
            self.loading_animation
                .set(animation.upcast())
                .expect("Someone else has set animation before us, what?");

            fn update_animation(editor: &super::Window) {
                if editor.is_loading() {
                    editor.imp().loading().play();
                } else {
                    editor.imp().loading().reset();
                }
            }

            let window = self.obj();
            window.connect_is_loading_notify(update_animation);
        }

        fn loading(&self) -> &adw::Animation {
            self.loading_animation
                .get()
                .expect("Loading animation is not set yet")
        }
    }

    #[gtk::template_callbacks]
    impl Window {
        /// Called when user selects an action.
        ///
        /// Returns chosed parameter values, if user performs action.
        /// Otherwise returns [`None`].
        pub async fn on_action_selected(
            &self,
            action_id: &str,
            is_builtin: bool,
            default_params: &[ParameterValue],
        ) -> Option<Vec<ParameterValue>> {
            // Jump back to the editor
            self.search_button.set_active(false);

            // Store text range before selection is broken
            let range = self.editor.selection_or_whole();

            // Zip parameters with their defaults
            let app = Application::get();
            let params: Vec<(Parameter, Option<&ParameterValue>)> = if is_builtin {
                let actions = app.builtin_actions().await;
                actions.actions()[action_id].clone()
            } else {
                let actions = app.custom_actions().await;
                actions.actions()[action_id].clone()
            }
            .parameters
            .into_iter()
            .zip(default_params.iter().map(Some).chain(iter::repeat(None)))
            .collect();

            // Ask for parameters if needed
            let params = if !params.is_empty() {
                // Create a dialog
                let dialog = adw::AlertDialog::builder()
                    .heading(gettext("Set action parameters"))
                    .build();

                // Add responses
                let responses = [
                    (
                        "apply",
                        gettext("_Apply"),
                        adw::ResponseAppearance::Suggested,
                    ),
                    (
                        "close",
                        gettext("_Cancel"),
                        adw::ResponseAppearance::Default,
                    ),
                ];

                for (id, label, appearance) in responses {
                    dialog.add_response(id, &gettext(label));
                    dialog.set_response_appearance(id, appearance);
                }

                dialog.set_default_response(Some("apply"));

                // Create entry rows for parameters
                let parameter_entries: Vec<(ParameterType, adw::PreferencesRow)> = params
                    .into_iter()
                    .map(|(param, default_value)| {
                        let row: adw::PreferencesRow = match param.type_ {
                            ParameterType::String => {
                                let row = adw::EntryRow::builder().activates_default(true).build();
                                row.add_css_class("monospace");

                                if let Some(ParameterValue::String(default_value)) = default_value {
                                    row.set_text(default_value)
                                }

                                row.upcast()
                            }
                            ParameterType::Bool => adw::SwitchRow::builder()
                                .active(if let Some(&ParameterValue::Bool(value)) = default_value {
                                    value
                                } else {
                                    false
                                })
                                .build()
                                .upcast(),
                            ParameterType::Int => adw::SpinRow::builder()
                                .adjustment(
                                    &gtk::Adjustment::builder()
                                        .lower(i128::MIN as f64)
                                        .upper(i128::MAX as f64)
                                        .value(
                                            if let Some(&ParameterValue::Int(value)) = default_value
                                            {
                                                value as f64
                                            } else {
                                                0.0
                                            },
                                        )
                                        .step_increment(1.0)
                                        .page_increment(10.0)
                                        .build(),
                                )
                                .build()
                                .upcast(),
                            ParameterType::Float => adw::SpinRow::builder()
                                .adjustment(
                                    &gtk::Adjustment::builder()
                                        .lower(i128::MIN as f64)
                                        .upper(i128::MAX as f64)
                                        .value(
                                            if let Some(&ParameterValue::Float(value)) =
                                                default_value
                                            {
                                                value
                                            } else {
                                                0.0
                                            },
                                        )
                                        .step_increment(0.001)
                                        .page_increment(0.01)
                                        .build(),
                                )
                                .digits(3)
                                .build()
                                .upcast(),
                        };
                        row.set_title(&if is_builtin {
                            pgettext("action", param.label)
                        } else {
                            param.label
                        });

                        (param.type_, row)
                    })
                    .collect();

                let parameter_box = gtk::ListBox::builder()
                    .width_request(350)
                    .selection_mode(gtk::SelectionMode::None)
                    .build();
                parameter_box.add_css_class("boxed-list");

                for (_, entry) in &parameter_entries {
                    parameter_box.append(entry);
                }

                dialog.set_focus(parameter_entries.first().map(|(_, entry)| entry));
                dialog.set_extra_child(Some(&parameter_box));

                // Show the dialog
                match dialog.choose_future(&*self.obj()).await.as_str() {
                    "apply" => parameter_entries
                        .into_iter()
                        .map(|(type_, row)| match type_ {
                            ParameterType::String => row
                                .downcast::<adw::EntryRow>()
                                .unwrap()
                                .text()
                                .to_string()
                                .into(),
                            ParameterType::Float => {
                                row.downcast::<adw::SpinRow>().unwrap().value().into()
                            }
                            ParameterType::Bool => {
                                row.downcast::<adw::SwitchRow>().unwrap().is_active().into()
                            }
                            ParameterType::Int => {
                                (row.downcast::<adw::SpinRow>().unwrap().value() as i64).into()
                            }
                        })
                        .collect(),
                    "close" => return None,
                    _ => unreachable!(),
                }
            } else {
                vec![]
            };

            let input = self.editor.text(Some(range));

            let app = Application::get();

            self.search_button.set_sensitive(false);

            let mut action_future = {
                let params = params.clone();
                if is_builtin {
                    app.builtin_actions()
                        .then(|actions| async move {
                            actions
                                .perform_action(action_id, &params, &input)
                                .await
                                .map_err(|e| e.to_string())
                        })
                        .boxed_local()
                } else {
                    app.custom_actions()
                        .then(|actions| async move {
                            actions
                                .perform_action(action_id, &params, &input)
                                .await
                                .map_err(|e| e.to_string())
                        })
                        .boxed_local()
                }
            }
            .fuse();

            const SILENT_TIMEOUT: Duration = Duration::from_millis(700);
            const NOTIFICATION_TIMEOUT: Duration = Duration::from_secs(3);

            self.editor.view().set_editable(false);
            self.search_button.set_sensitive(true);

            let mut silent_timeout = glib::timeout_future(SILENT_TIMEOUT).fuse();
            let mut notification_timeout = glib::timeout_future(NOTIFICATION_TIMEOUT).fuse();

            let result = select! {
                action_result = action_future => action_result,
                () = silent_timeout => {
                    self.obj().set_is_loading(true);

                    select! {
                        action_result = action_future => action_result,
                        () = notification_timeout => {
                            let toast = adw::Toast::builder()
                                .title(gettext("Action is running for too long. Dismiss to cancel it."))
                                .timeout(0)
                                .build();
                            let (sender, mut receiver) = oneshot::channel();
                            let sender = RefCell::new(Some(sender));
                            toast.connect_dismissed(move |_| {
                                if let Some(sender) = sender.take() {
                                    let _ = sender.send(());
                                }
                            });

                            self.toast_overlay.add_toast(toast);

                            select! {
                                action_result = action_future => action_result,
                                _ = receiver => {
                                    drop(action_future);
                                    Err(gettext("Action was cancelled"))
                                }
                            }
                        }
                    }
                }
            };

            self.obj().set_is_loading(false);
            self.editor.view().set_editable(true);

            match result {
                Ok(output) => {
                    self.editor
                        .set_text(&output, Some(range), self.editor.buffer().has_selection())
                }
                Err(error) => self.show_toast(&error),
            }

            Some(params)
        }

        /// Called when the action search is toggled.
        #[template_callback]
        fn on_search_toggled(&self) {
            // When the search is closed
            if !self.search_bar.is_search_mode() {
                // Reset the search
                self.search.reset();
                // Focus the editor
                self.editor.grab_focus();
            }
        }

        /// Calculates top bar style.
        #[template_callback]
        fn top_bar_style(&self, search_active: bool) -> adw::ToolbarStyle {
            if search_active {
                adw::ToolbarStyle::Raised
            } else {
                adw::ToolbarStyle::Flat
            }
        }
    }
}

glib::wrapper! {
    /// Text Pieces window.
    pub struct Window(ObjectSubclass<imp::Window>)
        @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow,
        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget,
                    gtk::Native, gtk::ShortcutManager, gio::ActionMap,
                    gio::ActionGroup, gtk::Root;
}

impl Window {
    /// Creates new window for the application.
    pub fn new(app: &Application) -> Self {
        glib::Object::builder().property("application", app).build()
    }

    /// Loads file and shows an error on fail.
    pub async fn load_file(&self, file: Option<&gio::File>) {
        let file = if let Some(file) = file {
            Ok(file.to_owned())
        } else {
            gtk::FileDialog::builder()
                .title(gettext("Load File"))
                .modal(true)
                .filters(&file_filters())
                .build()
                .open_future(Some(self))
                .await
        };

        if let Ok(file) = file {
            let result = self.imp().load_file(&file);

            if let Err(err) = result {
                self.imp()
                    .show_toast(&gettext!("Failed to load file: {}", err))
            }
        }
    }

    /// Saves the text to a file and shows an error on fail.
    pub async fn save_as(&self) {
        let file = gtk::FileDialog::builder()
            .title(gettext("Save File"))
            .modal(true)
            .filters(&file_filters())
            .build()
            .save_future(Some(self))
            .await;

        if let Ok(file) = file {
            let result = self.imp().save_as(&file);

            if let Err(err) = result {
                self.imp()
                    .show_toast(&gettext!("Failed to save file: {}", err))
            }
        }
    }

    /// Public interface to `on_action_selected` callback.
    ///
    /// I use it instead of signals because signals in GLib sucks.
    pub async fn action_selected(
        &self,
        action_id: &str,
        is_builtin: bool,
        default_params: &[ParameterValue],
    ) -> Option<Vec<ParameterValue>> {
        self.imp()
            .on_action_selected(action_id, is_builtin, default_params)
            .await
    }
}

pub trait ShowToast: IsA<gtk::Widget> {
    /// Shows message in a toast.
    ///
    /// Finds the window automatically.
    fn show_toast(&self, message: &str) {
        let window = self
            .root()
            .and_downcast::<Window>()
            .expect("Failed to get main window from the widget");
        window.imp().show_toast(message);
    }
}

impl<T: IsA<gtk::Widget>> ShowToast for T {}
