pub mod config;
pub mod metadata;

use std::collections::HashSet;

use self::config::AccountConfig;
use self::metadata::CommafeedMetadata;
use crate::error::FeedApiError;
use crate::feed_api::{FeedApi, FeedApiResult, FeedHeaderMap, Portal};
use crate::models::{
    ArticleID, Category, CategoryID, CategoryMapping, DirectLogin, Direction, Enclosure, FatArticle, FavIcon, Feed, FeedConversionResult, FeedID,
    FeedMapping, FeedUpdateResult, Headline, LoginData, Marked, NEWSFLASH_TOPLEVEL, PasswordLogin, PluginCapabilities, Read, StreamConversionResult,
    SyncResult, Tag, TagID, Tagging, Url,
};
use crate::util;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use commafeed_api::{ApiError as CommafeedError, Category as CommafeedCategory, CommafeedApi, Entries, MarkRequest, StarRequest, TagRequest};
use feed_rs::parser;
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderValue};

impl From<CommafeedError> for FeedApiError {
    fn from(error: CommafeedError) -> FeedApiError {
        match error {
            CommafeedError::Url(e) => FeedApiError::Url(e),
            CommafeedError::Json { source, json } => FeedApiError::Json { source, json },
            CommafeedError::Http(e) => FeedApiError::Network(e),
            CommafeedError::Parse => FeedApiError::Api {
                message: CommafeedError::Parse.to_string(),
            },
        }
    }
}

pub struct Commafeed {
    api: Option<CommafeedApi>,
    portal: Box<dyn Portal>,
    config: AccountConfig,
}

impl Commafeed {
    fn parse_tree(toplevel: CommafeedCategory, base_url: &Url) -> FeedConversionResult {
        let mut feeds = Vec::<Feed>::new();
        let mut feed_mappings = Vec::<FeedMapping>::new();
        let mut categories = Vec::<Category>::new();
        let mut category_mappings = Vec::<CategoryMapping>::new();

        Self::parse_category(
            toplevel,
            base_url,
            &mut feeds,
            &mut feed_mappings,
            &mut categories,
            &mut category_mappings,
        );

        FeedConversionResult {
            feeds,
            categories,
            feed_mappings,
            category_mappings,
        }
    }

    fn parse_category(
        comma_category: CommafeedCategory,
        base_url: &Url,
        feeds: &mut Vec<Feed>,
        feed_mappings: &mut Vec<FeedMapping>,
        categories: &mut Vec<Category>,
        category_mappings: &mut Vec<CategoryMapping>,
    ) {
        let category_id = if comma_category.id.as_str() == "all" {
            NEWSFLASH_TOPLEVEL.clone()
        } else {
            CategoryID::new(comma_category.id.as_str())
        };

        for (index, feed) in comma_category.feeds.into_iter().enumerate() {
            let feed_id = FeedID::new(&feed.subscription_id.to_string());

            feeds.push(Feed {
                feed_id: feed_id.clone(),
                label: feed.name,
                website: Url::parse(&feed.feed_link).ok(),
                feed_url: Url::parse(&feed.feed_url).ok(),
                icon_url: base_url.join(&feed.icon_url).map(Url::new).ok(),
                error_count: feed.error_count,
                error_message: feed.message,
            });

            feed_mappings.push(FeedMapping {
                feed_id,
                category_id: category_id.clone(),
                sort_index: Some(index as i32),
            });
        }

        for (index, category) in comma_category.children.into_iter().enumerate() {
            let iter_category_id = CategoryID::new(&category.id);

            categories.push(Category {
                category_id: iter_category_id.clone(),
                label: category.name.clone(),
            });

            category_mappings.push(CategoryMapping {
                parent_id: category_id.clone(),
                category_id: iter_category_id,
                sort_index: Some(index as i32),
            });

            Self::parse_category(category, base_url, feeds, feed_mappings, categories, category_mappings);
        }
    }

    fn convert_entries(entries: Entries, portal: &dyn Portal) -> StreamConversionResult {
        let mut articles = Vec::new();
        let mut taggings = Vec::new();
        let mut enclosures = Vec::new();

        for entry in entries.entries {
            let article_id = ArticleID::new(&entry.id);
            let article_exists_locally = portal.get_article_exists(&article_id).unwrap_or(false);

            let plain_text = if article_exists_locally {
                None
            } else {
                entry.content.as_deref().map(util::html2text::html2text)
            };
            let summary = plain_text.as_deref().map(util::html2text::text2summary);
            let thumbnail_url = if entry.media_thumbnail_url.is_some() {
                entry.media_thumbnail_url.clone()
            } else {
                entry.content.as_deref().and_then(crate::util::thumbnail::extract_thumbnail)
            };

            if let Some(url) = entry.enclosure_url.and_then(|url| Url::parse(&url).ok()) {
                enclosures.push(Enclosure {
                    article_id: article_id.clone(),
                    url,
                    mime_type: entry.enclosure_type,
                    title: entry.media_description,
                    position: None,
                    summary: None,
                    thumbnail_url: entry.media_thumbnail_url,
                    filesize: None,
                    width: None,
                    height: None,
                    duration: None,
                    framerate: None,
                    alternative: None,
                    is_default: false,
                });
            }

            for tag in entry.tags {
                taggings.push(Tagging {
                    article_id: article_id.clone(),
                    tag_id: TagID::new(&tag),
                });
            }

            articles.push(FatArticle {
                article_id,
                title: Some(entry.title),
                author: entry.author,
                feed_id: FeedID::new(&entry.feed_id),
                url: Url::parse(&entry.url).ok(),
                date: DateTime::from_timestamp_millis(entry.date).unwrap_or(Utc::now()),
                synced: Utc::now(),
                updated: None,
                html: entry.content,
                summary,
                direction: Some(if entry.rtl { Direction::RightToLeft } else { Direction::LeftToRight }),
                unread: if entry.read { Read::Read } else { Read::Unread },
                marked: if entry.starred { Marked::Marked } else { Marked::Unmarked },
                scraped_content: None,
                plain_text,
                thumbnail_url,
            });
        }

        StreamConversionResult {
            articles,
            headlines: Vec::new(),
            taggings,
            enclosures,
        }
    }

    fn convert_tags(tags: Vec<String>) -> Vec<Tag> {
        tags.into_iter()
            .enumerate()
            .map(|(i, t)| Tag {
                tag_id: TagID::new(&t),
                label: t,
                color: None,
                sort_index: Some(i as i32),
            })
            .collect::<Vec<_>>()
    }

    async fn fetch_articles(&self, category: &str, limit: u32, read: bool, client: &Client) -> FeedApiResult<StreamConversionResult> {
        if let Some(api) = self.api.as_ref() {
            let mut offset = 0;
            let mut result = StreamConversionResult::new();

            loop {
                let entries = api
                    .get_category_entries(
                        category,
                        read,
                        None,
                        Some(offset),
                        Some(limit as i32),
                        None,
                        None,
                        None,
                        None,
                        None,
                        client,
                    )
                    .await?;
                let done = !entries.has_more;

                let converted_entries = Self::convert_entries(entries, self.portal.as_ref());
                result.add(converted_entries);

                if done {
                    break;
                } else {
                    offset += limit as i32;
                }
            }

            Ok(result)
        } else {
            Err(FeedApiError::Login)
        }
    }
}

#[async_trait]
impl FeedApi for Commafeed {
    fn features(&self) -> FeedApiResult<PluginCapabilities> {
        Ok(PluginCapabilities::ADD_REMOVE_FEEDS
            | PluginCapabilities::SUPPORT_CATEGORIES
            | PluginCapabilities::MODIFY_CATEGORIES
            | PluginCapabilities::SUPPORT_SUBCATEGORIES
            | PluginCapabilities::SUPPORT_TAGS)
    }

    fn has_user_configured(&self) -> FeedApiResult<bool> {
        Ok(self.api.is_some())
    }

    async fn is_reachable(&self, client: &Client) -> FeedApiResult<bool> {
        if let Some(url) = self.config.get_url() {
            let url = url.trim_end_matches("rest/");
            let res = client.head(url).send().await?;
            Ok(res.status().is_success())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn is_logged_in(&self, client: &Client) -> FeedApiResult<bool> {
        match &self.api {
            None => Ok(false),
            Some(api) => {
                _ = api.get_profile(client).await?;
                Ok(true)
            }
        }
    }

    async fn user_name(&self) -> Option<String> {
        self.config.get_user_name()
    }

    async fn get_login_data(&self) -> Option<LoginData> {
        if self.has_user_configured().unwrap_or(false) {
            let username = self.config.get_user_name();
            let password = self.config.get_password();

            if let (Some(username), Some(password)) = (username, password) {
                return Some(LoginData::Direct(DirectLogin::Password(PasswordLogin {
                    id: CommafeedMetadata::get_id(),
                    url: self.config.get_url(),
                    user: username,
                    password,
                    basic_auth: None,
                })));
            }
        }

        None
    }

    async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
        if let LoginData::Direct(DirectLogin::Password(password_data)) = data {
            let api = if let Some(mut url_string) = password_data.url.clone() {
                if !url_string.ends_with('/') {
                    url_string.push('/');
                }
                if !url_string.ends_with("rest/") {
                    url_string.push_str("rest/");
                }

                self.config.set_url(&url_string);
                self.config.set_password(&password_data.password);
                self.config.set_user_name(&password_data.user);

                let url = Url::parse(&url_string)?;
                let api = CommafeedApi::new(&url, &password_data.user, &password_data.password);
                let profile = api.get_profile(client).await?;

                tracing::debug!(%profile.name, "logged in");

                api
            } else {
                tracing::error!("No URL set");
                return Err(FeedApiError::Login);
            };

            if self.config.get_user_name().is_none() {
                let profile = api.get_profile(client).await?;
                self.config.set_user_name(&profile.name);
            }

            self.config.save()?;
            self.api = Some(api);
            return Ok(());
        }

        self.api = None;
        Err(FeedApiError::Login)
    }

    async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
        self.config.delete()?;
        Ok(())
    }

    async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
        if let Some(api) = self.api.as_ref() {
            let base_url = self.config.get_icon_base_url()?;

            let tree = api.get_category_tree(client);
            let tags = api.get_tags(client);

            let unread_result = self.fetch_articles("all", 999, false, client);
            let starred_result = self.fetch_articles("starred", 999, true, client);

            let (tree, tags, unread_result, starred_result) = futures::join!(tree, tags, unread_result, starred_result);

            let mut unread_result = unread_result?;
            let starred_result = starred_result?;

            let converted_tree = Self::parse_tree(tree?, &base_url);
            let tags = Self::convert_tags(tags?);

            unread_result.add(starred_result);

            Ok(SyncResult {
                feeds: util::vec_to_option(converted_tree.feeds),
                categories: util::vec_to_option(converted_tree.categories),
                feed_mappings: util::vec_to_option(converted_tree.feed_mappings),
                category_mappings: util::vec_to_option(converted_tree.category_mappings),
                tags: util::vec_to_option(tags),
                taggings: util::vec_to_option(unread_result.taggings),
                headlines: None,
                articles: util::vec_to_option(unread_result.articles),
                enclosures: util::vec_to_option(unread_result.enclosures),
            })
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
        if let Some(api) = self.api.as_ref() {
            let base_url = self.config.get_icon_base_url()?;
            let max_count = self.portal.get_config().read().await.get_sync_amount();

            let mut result = StreamConversionResult::new();

            let tree = api.get_category_tree(client);
            let tags = api.get_tags(client);

            let unread_result = self.fetch_articles("all", max_count, false, client);
            let starred_result = self.fetch_articles("starred", max_count, true, client);
            let recent_entries = api.get_category_entries("all", true, None, None, Some(max_count as i32), None, None, None, None, None, client);

            let (tree, tags, unread_result, starred_result, recent_entries) =
                futures::join!(tree, tags, unread_result, starred_result, recent_entries);

            let unread_result = unread_result?;
            let starred_result = starred_result?;
            let recent_entries = recent_entries?;

            let converted_tree = Self::parse_tree(tree?, &base_url);
            let tags = Self::convert_tags(tags?);

            let converted_recent_entries = Self::convert_entries(recent_entries, self.portal.as_ref());
            result.add(converted_recent_entries);

            // get local IDs
            let local_unread_ids = self.portal.get_article_ids_unread_all()?;
            let local_marked_ids = self.portal.get_article_ids_marked_all()?;

            let local_unread_ids: HashSet<ArticleID> = local_unread_ids.into_iter().collect();
            let local_marked_ids: HashSet<ArticleID> = local_marked_ids.into_iter().collect();

            let remote_unread_ids: HashSet<ArticleID> = unread_result.articles.iter().map(|a| &a.article_id).cloned().collect();
            let remote_starred_ids: HashSet<ArticleID> = starred_result.articles.iter().map(|a| &a.article_id).cloned().collect();

            // mark remotely read article as read
            let mut should_mark_read_headlines = local_unread_ids
                .difference(&remote_unread_ids)
                .map(|id| Headline {
                    article_id: ArticleID::new(&id.to_string()),
                    unread: Read::Read,
                    marked: if remote_starred_ids.contains(id) {
                        Marked::Marked
                    } else {
                        Marked::Unmarked
                    },
                })
                .collect();
            result.headlines.append(&mut should_mark_read_headlines);

            // unmark remotly unstarred articles locally
            let mut missing_unmarked_headlines = local_marked_ids
                .difference(&remote_starred_ids)
                .map(|id| Headline {
                    article_id: ArticleID::new(&id.to_string()),
                    marked: Marked::Unmarked,
                    unread: if remote_unread_ids.contains(id) { Read::Unread } else { Read::Read },
                })
                .collect();
            result.headlines.append(&mut missing_unmarked_headlines);

            Ok(SyncResult {
                feeds: util::vec_to_option(converted_tree.feeds),
                categories: util::vec_to_option(converted_tree.categories),
                feed_mappings: util::vec_to_option(converted_tree.feed_mappings),
                category_mappings: util::vec_to_option(converted_tree.category_mappings),
                tags: util::vec_to_option(tags),
                taggings: util::vec_to_option(result.taggings),
                headlines: util::vec_to_option(result.headlines),
                articles: util::vec_to_option(result.articles),
                enclosures: util::vec_to_option(result.enclosures),
            })
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn fetch_feed(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
        if let Some(api) = self.api.as_ref() {
            let comma_feed_id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
                message: format!("Failed to parse id {feed_id}"),
            })?;

            let base_url = self.config.get_icon_base_url()?;
            let feed = api.fetch_feed(comma_feed_id, client).await?;
            let feed = Feed {
                feed_id: feed_id.clone(),
                label: feed.name,
                website: Url::parse(&feed.feed_link).ok(),
                feed_url: Url::parse(&feed.feed_url).ok(),
                icon_url: base_url.join(&feed.icon_url).map(Url::new).ok(),
                error_count: feed.error_count,
                error_message: feed.message,
            };

            let entries = api
                .get_feed_entries(feed_id.as_str(), true, None, None, None, None, None, None, client)
                .await?;
            let converted_entries = Self::convert_entries(entries, self.portal.as_ref());

            Ok(FeedUpdateResult {
                feed: Some(feed),
                taggings: util::vec_to_option(converted_entries.taggings),
                articles: util::vec_to_option(converted_entries.articles),
                enclosures: util::vec_to_option(converted_entries.enclosures),
            })
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn set_article_read(&self, articles: &[ArticleID], read: Read, client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            let requests = articles
                .iter()
                .map(|id| MarkRequest {
                    id: id.as_str().into(),
                    read: read == Read::Read,
                    older_than: None,
                    keywords: None,
                    excluded_subscriptions: None,
                })
                .collect();
            api.mark_multiple_entries_read(requests, client).await?;
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn set_article_marked(&self, articles: &[ArticleID], marked: Marked, client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            let requests = articles
                .iter()
                .map(|id| StarRequest {
                    id: id.as_str().into(),
                    feed_id: 0,
                    starred: marked == Marked::Marked,
                })
                .collect::<Vec<_>>();

            for request in requests {
                api.mark_entry_starred(request, client).await?;
            }
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn set_feed_read(&self, feeds: &[FeedID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            for feed in feeds {
                api.mark_feed_read(feed.as_str(), true, None, None, None, client).await?;
            }
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn set_category_read(&self, categories: &[CategoryID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            for category in categories {
                api.mark_category_read(category.as_str(), true, None, None, None, client).await?;
            }
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn set_tag_read(&self, _tags: &[TagID], articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            let requests = articles
                .iter()
                .map(|id| MarkRequest {
                    id: id.as_str().into(),
                    read: true,
                    older_than: None,
                    keywords: None,
                    excluded_subscriptions: None,
                })
                .collect();
            api.mark_multiple_entries_read(requests, client).await?;
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn set_all_read(&self, articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            let requests = articles
                .iter()
                .map(|id| MarkRequest {
                    id: id.as_str().into(),
                    read: true,
                    older_than: None,
                    keywords: None,
                    excluded_subscriptions: None,
                })
                .collect();
            api.mark_multiple_entries_read(requests, client).await?;
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn add_feed(
        &self,
        url: &Url,
        title: Option<String>,
        category_id: Option<CategoryID>,
        client: &Client,
    ) -> FeedApiResult<(Feed, Option<Category>)> {
        if let Some(api) = self.api.as_ref() {
            let feed = if let Some(title) = title.as_deref() {
                let feed_id = api
                    .subscribe_to_feed(url.as_str(), title, category_id.as_ref().map(|id| id.to_string()).as_deref(), client)
                    .await?;
                Feed {
                    feed_id: FeedID::new(&feed_id.to_string()),
                    label: title.to_owned(),
                    website: None,
                    feed_url: Some(url.clone()),
                    icon_url: None,
                    error_count: 0,
                    error_message: None,
                }
            } else {
                let feed_id = api.subscribe_to_feed_simple(url.as_str(), client).await?;
                let feed_response = client.get(url.as_str()).send().await?.error_for_status()?;
                let result_bytes = feed_response
                    .bytes()
                    .await
                    .inspect_err(|error| tracing::error!(%url, %error, "Reading response as bytes failed"))?;

                let parser = parser::Builder::new().base_uri(Some(url)).build();
                let feed = parser.parse(result_bytes.as_ref())?;
                let mut feed = Feed::from_feed_rs(&feed, title, url);
                feed.feed_id = FeedID::new(&feed_id.to_string());
                feed
            };

            let categories = self.portal.get_categories()?;
            let category = categories.iter().find(|c| Some(&c.category_id) == category_id.as_ref()).cloned();

            Ok((feed, category))
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn remove_feed(&self, id: &FeedID, client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            let id = id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
                message: format!("Failed to parse id {id}"),
            })?;
            api.unsubscribe_from_feed(id, client).await?;
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn move_feed(&self, feed_id: &FeedID, _from: &CategoryID, to: &CategoryID, client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            let id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
                message: format!("Failed to parse id {feed_id}"),
            })?;
            api.modify_feed(id, None, Some(to.as_str()), None, client).await?;
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn rename_feed(&self, feed_id: &FeedID, new_title: &str, client: &Client) -> FeedApiResult<FeedID> {
        if let Some(api) = self.api.as_ref() {
            let id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
                message: format!("Failed to parse id {feed_id}"),
            })?;
            api.modify_feed(id, Some(new_title), None, None, client).await?;
            Ok(feed_id.clone())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn edit_feed_url(&self, _feed_id: &FeedID, _new_url: &str, _client: &Client) -> FeedApiResult<()> {
        Err(FeedApiError::Unsupported)
    }

    async fn add_category<'a>(&self, title: &str, parent: Option<&'a CategoryID>, client: &Client) -> FeedApiResult<CategoryID> {
        if let Some(api) = self.api.as_ref() {
            let id = api.create_category(title, parent.map(|id| id.to_string()).as_deref(), client).await?;
            Ok(CategoryID::new(&id.to_string()))
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn remove_category(&self, id: &CategoryID, _remove_children: bool, client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            let id = id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
                message: format!("Failed to parse id {id}"),
            })?;
            api.delete_category(id, client).await?;
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn rename_category(&self, category_id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
        if let Some(api) = self.api.as_ref() {
            let id = category_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
                message: format!("Failed to parse id {category_id}"),
            })?;
            api.modify_category(id, Some(new_title), None, None, client).await?;
            Ok(category_id.clone())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn move_category(&self, category_id: &CategoryID, parent: &CategoryID, client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            let id = category_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
                message: format!("Failed to parse id {category_id}"),
            })?;
            api.modify_category(id, None, Some(parent.as_str()), None, client).await?;
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn import_opml(&self, opml: &str, client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            api.import_opml(opml, client).await?;
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn add_tag(&self, title: &str, _client: &Client) -> FeedApiResult<TagID> {
        Ok(TagID::new(title))
    }

    async fn remove_tag(&self, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
        let taggings = self.portal.get_taggings(None, Some(tag_id))?;
        for tagging in taggings {
            self.untag_article(&tagging.article_id, tag_id, client).await?;
        }

        Ok(())
    }

    async fn rename_tag(&self, tag_id: &TagID, new_title: &str, client: &Client) -> FeedApiResult<TagID> {
        if let Some(api) = self.api.as_ref() {
            let taggings = self.portal.get_taggings(None, Some(tag_id))?;

            for tagging in taggings {
                let entry_id = tagging.article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
                    message: format!("Failed to parse id {}", tagging.article_id),
                })?;

                let article_taggings = self.portal.get_taggings(Some(&tagging.article_id), None)?;
                let mut tags = article_taggings
                    .into_iter()
                    .filter_map(|tagging| {
                        if &tagging.tag_id != tag_id {
                            Some(tagging.tag_id.to_string())
                        } else {
                            None
                        }
                    })
                    .collect::<Vec<_>>();
                tags.push(new_title.into());

                let request = TagRequest { entry_id, tags };
                api.set_tags(request, client).await?;
            }

            Ok(TagID::new(new_title))
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn tag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            let entry_id = article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
                message: format!("Failed to parse id {article_id}"),
            })?;

            let taggings = self.portal.get_taggings(Some(article_id), None)?;
            let mut tags = taggings.into_iter().map(|tagging| tagging.tag_id.to_string()).collect::<Vec<_>>();
            tags.push(tag_id.to_string());

            let request = TagRequest { entry_id, tags };
            api.set_tags(request, client).await?;
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn untag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
        if let Some(api) = self.api.as_ref() {
            let entry_id = article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
                message: format!("Failed to parse id {article_id}"),
            })?;

            let taggings = self.portal.get_taggings(Some(article_id), None)?;
            let tags = taggings
                .into_iter()
                .filter_map(|tagging| {
                    if &tagging.tag_id != tag_id {
                        Some(tagging.tag_id.to_string())
                    } else {
                        None
                    }
                })
                .collect::<Vec<_>>();

            let request = TagRequest { entry_id, tags };
            api.set_tags(request, client).await?;
            Ok(())
        } else {
            Err(FeedApiError::Login)
        }
    }

    async fn get_favicon(&self, _feed_id: &FeedID, _client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
        Err(FeedApiError::Unsupported)
    }
}
