mod article_widget;
mod builder;
mod drag_buffer;
mod drag_scroll_tracker;
mod enclosure_container;
mod enclosure_link;
mod enclosure_list;
mod header;
mod loader;
mod swipeable;
mod url_overlay;

use self::article_widget::ArticleWidget;
use self::enclosure_list::EnclosureList;
use self::url_overlay::UrlOverlay;
use crate::App;
use crate::app::ARTICLE_CACHE_DIR;
use crate::content_page::ArticleViewColumn;
use crate::gobject_models::GArticle;
use crate::infrastructure::TokioRuntime;
use crate::self_stack::{SelfStack, SelfStackTransition};
use crate::settings::ArticleViewSettings;
pub use builder::ArticleBuilder;
use drag_scroll_tracker::DragScrollTracker;
use futures::future::{BoxFuture, FutureExt};
use glib::{Properties, clone, subclass::*};
use gtk4::{
    Accessible, Allocation, Buildable, CompositeTemplate, ConstraintTarget, EventSequenceState, GestureDrag, Widget,
    prelude::*, subclass::prelude::*,
};
use html2gtk::ParseResult;
pub use loader::ArticleLoader;
use news_flash::models::Url;
use reqwest::Client;
use std::cell::{Cell, RefCell};
use std::marker::PhantomData;
use std::str;
use url::{Host, Origin};

mod imp {
    use crate::i18n::i18n;

    use super::*;

    #[derive(Default, CompositeTemplate, Properties)]
    #[properties(wrapper_type = super::ArticleView)]
    #[template(file = "data/resources/ui_templates/article_view/view.blp")]
    pub struct ArticleView {
        #[template_child]
        pub self_stack: TemplateChild<SelfStack>,
        #[template_child]
        pub drag_gesture: TemplateChild<GestureDrag>,
        #[template_child]
        pub drag_scroll_tracker: TemplateChild<DragScrollTracker>,
        #[template_child]
        pub url_overlay: TemplateChild<UrlOverlay>,

        #[property(get, set, name = "status-margin")]
        pub status_margin: Cell<i32>,

        #[property(get, set, name = "prefer-scraped-content")]
        pub prefer_scraped_content: Cell<bool>,

        #[property(get = Self::get_article, set = Self::set_article, nullable)]
        pub article: PhantomData<Option<GArticle>>,

        #[property(get, set)]
        pub settings: RefCell<ArticleViewSettings>,

        pub article_widget: RefCell<Option<ArticleWidget>>,
        pub video_thumbnail_extractor: RefCell<Option<html2gtk::VideoThumbnailExtractor>>,
    }

    #[glib::object_subclass]
    impl ObjectSubclass for ArticleView {
        const NAME: &'static str = "ArticleView";
        type ParentType = Widget;
        type Type = super::ArticleView;

        fn class_init(klass: &mut Self::Class) {
            UrlOverlay::ensure_type();

            klass.bind_template();
            klass.bind_template_callbacks();

            EnclosureList::ensure_type();
            swipeable::ArticleSwipeable::ensure_type();
        }

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

    #[glib::derived_properties]
    impl ObjectImpl for ArticleView {
        fn constructed(&self) {
            let request_client: Box<dyn Fn() -> BoxFuture<'static, Client> + Send + Sync> =
                Box::new(move || App::client().boxed());
            let video_thumbnail_extractor =
                html2gtk::VideoThumbnailExtractor::new(&TokioRuntime::instance(), Box::new(request_client));
            self.video_thumbnail_extractor.replace(Some(video_thumbnail_extractor));

            let widget = ArticleWidget::default();
            self.set_article_widget(widget);

            self.settings.replace(App::default().settings().article_view());
            self.parent_constructed();
        }

        fn dispose(&self) {
            self.self_stack.unparent();
        }
    }

    impl WidgetImpl for ArticleView {
        fn size_allocate(&self, width: i32, height: i32, baseline: i32) {
            // linear interpolation for margins
            const MIN_MARGIN: f32 = 20.0;
            const MAX_MARGIN: f32 = 60.0;
            const MIN_WIDTH: f32 = 360.0;
            const MAX_WIDTH: f32 = 600.0;
            const FAC: f32 = (MAX_MARGIN - MIN_MARGIN) / (MAX_WIDTH - MIN_WIDTH);

            let margin = MIN_MARGIN + ((width as f32 - MIN_WIDTH) * FAC);

            if let Some(article_widget) = self.article_widget.borrow().as_ref() {
                article_widget.set_inner_margin(margin as i32);
            }

            self.self_stack
                .size_allocate(&Allocation::new(0, 0, width, height), baseline);
            self.parent_size_allocate(width, height, baseline);
        }
    }

    #[gtk4::template_callbacks]
    impl ArticleView {
        #[template_callback]
        fn on_drag_scroll(&self, diff: f64) {
            self.obj().scroll_diff(diff);
        }

        #[template_callback]
        fn on_drag_begin(&self, _x: f64, _y: f64) {
            self.drag_gesture.set_state(EventSequenceState::Claimed);
            self.drag_scroll_tracker.begin_drag();
        }

        #[template_callback]
        fn on_drag_update(&self, _x: f64, y: f64) {
            self.drag_gesture.set_state(EventSequenceState::Claimed);
            self.drag_scroll_tracker.update_drag(y);
        }

        #[template_callback]
        fn on_drag_end(&self, _x: f64, _y: f64) {
            self.drag_gesture.set_state(EventSequenceState::Claimed);

            if let Some(article_widget) = self.article_widget.borrow().as_ref() {
                let vadj = article_widget.imp().scroll.vadjustment();
                let scroll_pos = vadj.value();
                let scroll_upper = vadj.upper();
                let page_size = vadj.page_size();
                self.drag_scroll_tracker.end_drag(scroll_pos, scroll_upper, page_size);
            }
        }

        #[template_callback]
        fn has_child(&self, child: Option<Widget>) -> bool {
            child.is_some()
        }

        #[template_callback]
        fn invert(&self, boolean: bool) -> bool {
            !boolean
        }

        pub(super) fn set_article_widget(&self, widget: ArticleWidget) {
            self.url_overlay.set_child(Some(widget.upcast_ref()));
            widget.bind_property("hovered-url", &*self.url_overlay, "url").build();

            self.article_widget.replace(Some(widget));
        }

        fn get_article(&self) -> Option<GArticle> {
            if self.self_stack.is_status_shown() {
                return None;
            }

            self.article_widget
                .borrow()
                .as_ref()
                .and_then(|widget| widget.article())
        }

        fn set_article(&self, new_article: Option<GArticle>) {
            let Some(new_article) = new_article else {
                self.close_article();
                return;
            };

            let borrow = self.article_widget.borrow();

            let Some(article_widget) = borrow.as_ref() else {
                return;
            };

            let obj = self.obj();

            let prefer_scraped_content_differs = self.prefer_scraped_content.get() != new_article.has_scraped_content();

            let is_new_article = if let Some(current_article) = article_widget.article() {
                current_article.article_id() != new_article.article_id()
                    || current_article.feed_title() != new_article.feed_title()
                    || current_article.has_scraped_content() != new_article.has_scraped_content()
                    || prefer_scraped_content_differs
            } else {
                true
            };

            self.drag_scroll_tracker.stop();

            if !is_new_article {
                obj.set_prefer_scraped_content(new_article.has_scraped_content());
                article_widget.set_article(Some(new_article));
                obj.grab_focus();
                return;
            }

            if let Some(article_widget) = self.article_widget.borrow().as_ref() {
                article_widget.cancel_all_image_downloads();
            }

            self.self_stack.freeze();
            obj.set_prefer_scraped_content(new_article.has_scraped_content());
            article_widget.collapse_enclosures();
            article_widget.set_article(Some(new_article));

            glib::spawn_future_local(clone!(
                #[weak(rename_to = imp)]
                self,
                async move {
                    let Some(article) = imp.article_widget.borrow().as_ref().and_then(|widget| widget.article()) else {
                        imp.show_error();
                        return;
                    };

                    let Some(result) = imp.build_article(&article, imp.prefer_scraped_content.get()).await else {
                        imp.show_error();
                        return;
                    };

                    if let Some(article_widget) = imp.article_widget.borrow().as_ref() {
                        article_widget.imp().scroll.vadjustment().set_value(0.0);
                        article_widget.set_parse_result(result);
                    }

                    imp.self_stack.show_content_full(SelfStackTransition::Crossfade);
                }
            ));
        }

        pub(super) fn close_article(&self) {
            self.show_empty();
            if let Some(article_widget) = self.article_widget.borrow().as_ref() {
                article_widget.clear();
            }
            self.obj().set_prefer_scraped_content(false);
        }

        pub(super) fn is_empty(&self) -> bool {
            self.self_stack.is_status_shown()
        }

        pub(super) fn show_empty(&self) {
            self.self_stack.set_status_icon_name(Some("view-dual-symbolic"));
            self.self_stack.set_status_title(i18n("Select an Article"));
            self.self_stack.set_status_description(None::<&str>);
            self.self_stack.show_status();
        }

        pub(super) fn show_error(&self) {
            self.self_stack.set_status_icon_name(Some("face-crying-symbolic"));
            self.self_stack.set_status_title(i18n("Error"));
            self.self_stack
                .set_status_description(Some(i18n("Parsing Article Failed")));
            self.self_stack.show_status();
        }

        pub(super) async fn build_article(
            &self,
            article: &GArticle,
            prefer_scraped_content: bool,
        ) -> Option<ParseResult> {
            let base_url = Self::get_base_url(article);
            let math_delim = Self::get_inline_math_delimiter(article);

            if !ARTICLE_CACHE_DIR.exists() {
                _ = std::fs::create_dir_all(ARTICLE_CACHE_DIR.as_path());
            }

            let article_id = article.article_id();
            let scraped_suffix = if prefer_scraped_content && article.scraped_content().is_some() {
                "scraped"
            } else {
                "orig"
            };
            let cache_file_name = if let Some(delim) = math_delim.as_deref() {
                format!("{article_id}_{scraped_suffix}_{delim}.json")
            } else {
                format!("{article_id}_{scraped_suffix}.json")
            };
            let sanitized_filename = sanitize_filename::sanitize(cache_file_name);
            let cache_file_path = ARTICLE_CACHE_DIR.join(sanitized_filename);

            if cache_file_path.exists() {
                tracing::trace!(?cache_file_path, "loading html2gtk cache");

                let handle = TokioRuntime::spawn(async move {
                    std::fs::read_to_string(cache_file_path)
                        .ok()
                        .and_then(|json| serde_json::from_str::<ParseResult>(&json).ok())
                });
                let result = handle.await.ok()??;
                return Some(result);
            }

            let html = if prefer_scraped_content {
                article
                    .scraped_content()
                    .unwrap_or_else(|| article.content().unwrap_or_default())
            } else {
                article.content().unwrap_or_default()
            };

            let video_thumbnail_extractor = self.video_thumbnail_extractor.borrow().clone();
            let handle = TokioRuntime::spawn(async move {
                let mut result = html2gtk::Parser::default()
                    .parse_fragment(html, base_url.as_deref(), math_delim.as_deref())
                    .inspect_err(|e| {
                        tracing::error!(?e, "html2gtk");
                    })
                    .ok();

                if let Some(result) = result.as_mut()
                    && let Some(extractor) = video_thumbnail_extractor
                {
                    extractor.add_missing_thumbnails(&mut result.root).await;
                }

                result
            });
            let result = handle.await.ok()??;

            if let Ok(json) = serde_json::to_string_pretty(&result) {
                tracing::trace!(?cache_file_path, "write html2gtk cache");
                _ = std::fs::write(&cache_file_path, json);
            }

            Some(result)
        }

        fn get_base_url(article: &GArticle) -> Option<String> {
            let url = article.url().and_then(|url| Url::parse(&url).ok())?;

            match url.origin() {
                Origin::Opaque(_op) => None,
                Origin::Tuple(scheme, host, port) => {
                    let host = match host {
                        Host::Domain(domain) => domain,
                        Host::Ipv4(ipv4) => ipv4.to_string(),
                        Host::Ipv6(ipv6) => ipv6.to_string(),
                    };
                    if port == 80 || port == 443 {
                        Some(format!("{scheme}://{host}/"))
                    } else {
                        Some(format!("{scheme}://{host}:{port}/"))
                    }
                }
            }
        }

        fn get_inline_math_delimiter(article: &GArticle) -> Option<String> {
            let feed_settings = App::default()
                .settings()
                .get_merged_feed_setting(article.feed_id().as_ref());
            feed_settings.inline_math.clone()
        }
    }
}

glib::wrapper! {
    pub struct ArticleView(ObjectSubclass<imp::ArticleView>)
        @extends Widget,
        @implements Accessible, Buildable, ConstraintTarget;
}

impl Default for ArticleView {
    fn default() -> Self {
        glib::Object::new::<Self>()
    }
}

impl ArticleView {
    pub fn instance() -> Self {
        ArticleViewColumn::instance().imp().article_view.get()
    }

    pub fn redraw_article(&self) {
        let imp = self.imp();

        if imp.is_empty() {
            tracing::warn!("Can't redraw article view. No article is on display.");
            return;
        }

        imp.drag_scroll_tracker.stop();

        glib::spawn_future_local(clone!(
            #[weak]
            imp,
            async move {
                let Some(article) = imp.article_widget.borrow().as_ref().and_then(|widget| widget.article()) else {
                    imp.show_error();
                    return;
                };

                let Some(result) = imp.build_article(&article, imp.prefer_scraped_content.get()).await else {
                    imp.show_error();
                    return;
                };

                if let Some(article_widget) = imp.article_widget.borrow().as_ref() {
                    article_widget.set_parse_result(result);
                }
            }
        ));
    }

    pub fn replace_article_widget(&self, new_widget: ArticleWidget) {
        let imp = self.imp();
        imp.prefer_scraped_content.set(
            new_widget
                .article()
                .map(|article| article.has_scraped_content())
                .unwrap_or(false),
        );
        imp.set_article_widget(new_widget);
    }

    pub fn scroll_diff(&self, diff: f64) {
        if let Some(article_widget) = self.imp().article_widget.borrow().as_ref() {
            let vadj = article_widget.imp().scroll.vadjustment();
            vadj.set_value(vadj.value() + diff);
        }
    }
}
