mod error;
mod icon_info;
mod scraped_icon;
mod scraper;

pub use self::error::FavIconError;
use self::scraper::IconScraper;
use crate::database::DatabaseExt;
use crate::feed_api::FeedApi;
use crate::models::{FatFavIcon, FavIcon, Feed, FeedID, Url};
use chrono::{DateTime, Duration, Utc};
use image::codecs::png::PngEncoder;
use image::{GenericImageView, ImageEncoder, ImageReader, imageops};
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::{self, Client};
use std::io::Cursor;
use std::sync::Arc;
use tokio::sync::{RwLock, Semaphore};

pub const LOW_RES: u32 = 32;
pub const HIGH_RES: u32 = 144;
pub const MAX_SIZE: u32 = 512;
pub const EXPIRES_AFTER_DAYS: i64 = 30;

pub struct FavIconLoader {
    db: Arc<Box<dyn DatabaseExt>>,
    download_semaphore: Arc<Semaphore>,
}

impl FavIconLoader {
    pub fn new(db: &Arc<Box<dyn DatabaseExt>>, download_semaphore: &Arc<Semaphore>) -> Self {
        FavIconLoader {
            db: db.clone(),
            download_semaphore: download_semaphore.clone(),
        }
    }

    pub async fn get_icon(
        &self,
        feed_id: &FeedID,
        api: &RwLock<Box<dyn FeedApi>>,
        client: &Client,
        header: HeaderMap<HeaderValue>,
    ) -> Result<FavIcon, FavIconError> {
        // Check db for existing icon
        let favicon = self.db.read_favicon(feed_id).ok();
        let valid_icon = favicon.clone().filter(|favicon| !favicon.is_expired());

        if let Some(favicon) = valid_icon {
            Ok(favicon)
        } else {
            Self::get_and_write_new_icon(&self.db, api, &self.download_semaphore, feed_id, favicon.as_ref(), client, header).await
        }
    }

    async fn get_and_write_new_icon(
        db: &Arc<Box<dyn DatabaseExt>>,
        api: &RwLock<Box<dyn FeedApi>>,
        download_semaphore: &Arc<Semaphore>,
        feed_id: &FeedID,
        old_icon: Option<&FavIcon>,
        client: &Client,
        header: HeaderMap<HeaderValue>,
    ) -> Result<FavIcon, FavIconError> {
        tracing::debug!(?feed_id, "Downloading new icon");

        // try to download new icon
        let feed = db.read_feed(feed_id)?;

        let permit = download_semaphore.acquire().await?;
        let favicon = Self::fetch_new_icon(Some(api), &feed, client, header, old_icon).await;
        drop(permit);

        let favicon = match Self::resize_icon(favicon.clone()) {
            Ok(resized_icon) => {
                // write new icon to db
                db.insert_favicon(&resized_icon).map_err(|error| {
                    tracing::error!(%feed_id, %error, "Failed to write favicon to db");
                    FavIconError::DB(error)
                })?;

                resized_icon
            }
            Err(error) => {
                tracing::debug!(%error, "resize of icon failed");
                favicon
            }
        };

        Ok(FavIcon::from(favicon))
    }

    fn resize_icon(mut original: FatFavIcon) -> Result<FatFavIcon, FavIconError> {
        if original.highres.is_some() && original.lowres.is_some() {
            return Ok(original);
        }

        if original.format.as_deref().map(|format| format.starts_with("image/svg")).unwrap_or(false) {
            return Ok(original);
        }

        let data = if original.highres.is_none() {
            original.lowres.clone()
        } else {
            original.highres.clone()
        }
        .ok_or(FavIconError::Resize)?;

        let cursor = Cursor::new(data);
        let image = ImageReader::new(cursor)
            .with_guessed_format()
            .map_err(|error| {
                tracing::debug!(%error, "reader with_guessed_format");
                FavIconError::Resize
            })?
            .decode()
            .map_err(|error| {
                tracing::debug!(%error, "decode");
                FavIconError::Resize
            })?;

        if let Some(format) = original.format.as_deref()
            && format != "image/png"
            && format != "image/jpeg"
        {
            let mut dest = Cursor::new(Vec::new());
            let encoder = PngEncoder::new(&mut dest);
            image.write_with_encoder(encoder).map_err(|_| FavIconError::Resize)?;

            original.format = Some("image/png".to_string());
            original.lowres = Some(dest.into_inner());
        }

        let (original_width, original_height) = image.dimensions();

        if original_width <= LOW_RES && original_height <= LOW_RES {
            return Ok(original);
        }

        let (dest_width, dest_height) = Self::calc_favicon_dimensions(original_width, original_height);

        let resized = imageops::resize(&image, dest_width, dest_height, imageops::FilterType::Triangle);
        let (width, height) = resized.dimensions();

        let resized_raw = resized.into_vec();

        let mut dest = Cursor::new(Vec::new());
        let encoder = PngEncoder::new(&mut dest);
        encoder
            .write_image(&resized_raw, width, height, image::ExtendedColorType::Rgba8)
            .map_err(|error| {
                tracing::debug!(%error, "write image");
                FavIconError::Resize
            })?;

        Ok(FatFavIcon {
            feed_id: original.feed_id,
            expires: original.expires,
            format: Some("image/png".into()),
            etag: original.etag,
            lowres_source_url: None,
            lowres: Some(dest.into_inner()),
            highres: if original.highres.is_none() {
                original.lowres.clone()
            } else {
                original.highres.clone()
            },
            highres_source_url: original.lowres_source_url,
        })
    }

    fn calc_favicon_dimensions(original_width: u32, original_height: u32) -> (u32, u32) {
        if original_width <= LOW_RES && original_height <= LOW_RES {
            return (original_width, original_height);
        }

        let ratio = (original_width as f64) / (original_height as f64);
        if original_width >= original_height {
            (LOW_RES, (LOW_RES as f64 / ratio) as u32)
        } else {
            ((LOW_RES as f64 * ratio) as u32, LOW_RES)
        }
    }

    pub async fn fetch_new_icon(
        api: Option<&RwLock<Box<dyn FeedApi>>>,
        feed: &Feed,
        client: &Client,
        header: HeaderMap<HeaderValue>,
        old_icon: Option<&FavIcon>,
    ) -> FatFavIcon {
        tracing::debug!(?feed, "fetch_new_icon");
        if let Some(api) = api
            && let Ok(favicon) = api.read().await.get_favicon(&feed.feed_id, client, header).await
        {
            if let (Some(source_url), None) = (&favicon.source_url, &favicon.data) {
                if let Ok(favicon) = Self::download(source_url, &feed.feed_id, client, old_icon).await {
                    tracing::debug!(?source_url, "Favicon downloaded backend source url");
                    if favicon.data.as_deref().map(Self::check_aspect_ratio).unwrap_or(false) {
                        return FatFavIcon::from(favicon);
                    }
                }
            } else if favicon.data.is_some() {
                tracing::debug!("Favicon downloaded from backend.");
                return FatFavIcon::from(favicon);
            } else {
                tracing::warn!("Favicon from backend doesn't contain data or source url");
            }
        }

        if let Some(icon_url) = &feed.icon_url
            && let Ok(favicon) = Self::download(icon_url, &feed.feed_id, client, old_icon).await
        {
            tracing::debug!(has_data = favicon.data.is_some(), "Favicon downloaded from feed data");
            if favicon.data.as_deref().map(Self::check_aspect_ratio).unwrap_or(false) {
                return FatFavIcon::from(favicon);
            }
        }

        if let Some(fatfavicon) = Self::scrap(feed, client).await {
            tracing::debug!("Favicon scraped from website.");
            return fatfavicon;
        }

        // everything failed -> return empty icon
        FatFavIcon {
            feed_id: feed.feed_id.clone(),
            expires: Self::gen_expires(),
            format: None,
            etag: None,
            lowres_source_url: None,
            lowres: None,
            highres: None,
            highres_source_url: None,
        }
    }

    fn check_aspect_ratio(data: &[u8]) -> bool {
        if let Some(image) = ImageReader::new(Cursor::new(data))
            .with_guessed_format()
            .map_err(|error| tracing::debug!(%error, "read with_guessed_format"))
            .ok()
            .and_then(|image| image.decode().map_err(|error| tracing::debug!(%error, "decode")).ok())
        {
            let aspect_ratio = image.width() as f32 / image.height() as f32;
            if aspect_ratio > 1.5 {
                tracing::debug!("Image is very wide with an apect ratio of {aspect_ratio}. It is probably not a favicon");
                false
            } else {
                true
            }
        } else {
            false
        }
    }

    async fn scrap(feed: &Feed, client: &Client) -> Option<FatFavIcon> {
        let Some(website) = &feed.website else {
            return None;
        };

        let Ok(mut scraper) = IconScraper::from_http(website, client).await else {
            tracing::warn!(%website, "Failed to scrap icon");
            return None;
        };

        let Some(scraped_icon) = scraper.fetch_best(client, LOW_RES).await else {
            tracing::warn!(%website, "Failed to download best scrapped icon");
            return None;
        };

        tracing::debug!(%scraped_icon.info.url, "Scraped favicon");

        let Some(highres_scraped_icon) = scraper.fetch_best(client, HIGH_RES).await else {
            return Some(FatFavIcon {
                feed_id: feed.feed_id.clone(),
                expires: Self::gen_expires(),
                format: scraped_icon.mime,
                etag: scraped_icon.etag,
                lowres_source_url: Some(Url::new(scraped_icon.info.url)),
                lowres: Some(scraped_icon.data),
                highres: None,
                highres_source_url: None,
            });
        };

        let (highres, highres_source_url) = if scraped_icon.data != highres_scraped_icon.data {
            (Some(highres_scraped_icon.data), Some(Url::new(highres_scraped_icon.info.url)))
        } else {
            (None, None)
        };

        Some(FatFavIcon {
            feed_id: feed.feed_id.clone(),
            expires: Self::gen_expires(),
            format: scraped_icon.mime,
            etag: scraped_icon.etag,
            lowres_source_url: Some(Url::new(scraped_icon.info.url)),
            lowres: Some(scraped_icon.data),
            highres,
            highres_source_url,
        })
    }

    async fn download(url: &Url, feed_id: &FeedID, client: &Client, old_icon: Option<&FavIcon>) -> Result<FavIcon, FavIconError> {
        let res = client.get(url.as_str()).send().await?;
        let etag = res
            .headers()
            .get(reqwest::header::ETAG)
            .and_then(|etag| etag.to_str().ok())
            .map(ToString::to_string);
        let content_type = res
            .headers()
            .get(reqwest::header::CONTENT_TYPE)
            .and_then(|etag| etag.to_str().ok())
            .map(ToString::to_string);

        if let Some(old_icon) = old_icon
            && let Some(old_etag) = old_icon.etag.as_deref()
            && let Some(http_etag) = etag.as_deref()
            && old_etag == http_etag
        {
            return Ok(FavIcon {
                feed_id: old_icon.feed_id.clone(),
                expires: Self::gen_expires(),
                format: old_icon.format.clone(),
                etag,
                source_url: old_icon.source_url.clone(),
                data: old_icon.data.clone(),
            });
        }

        let data = res.bytes().await?.to_vec();

        Ok(FavIcon {
            feed_id: feed_id.clone(),
            expires: Self::gen_expires(),
            format: content_type,
            etag,
            source_url: Some(url.clone()),
            data: Some(data),
        })
    }

    fn gen_expires() -> DateTime<Utc> {
        Utc::now() + Duration::try_days(EXPIRES_AFTER_DAYS).unwrap()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    use crate::models::{Feed, FeedID, Url};
    use test_log::test;

    #[ignore = "checks url redirect"]
    #[test(tokio::test)]
    async fn investigatewest() {
        let feed_id = FeedID::new("https://www.investigatewest.org/latest/rss/");
        let feed = Feed {
            feed_id: feed_id.clone(),
            label: "InvestigateWest".into(),
            website: Url::parse("https://www.investigatewest.org/").ok(),
            feed_url: Url::parse("https://www.investigatewest.org/latest/rss/").ok(),
            icon_url: Url::parse("https://www.investigatewest.org/favicon.png").ok(),
            error_count: 0,
            error_message: None,
        };

        let favicon = FavIconLoader::fetch_new_icon(None, &feed, &reqwest::Client::new(), HeaderMap::new(), None).await;

        assert_eq!(
            favicon.lowres_source_url.unwrap().as_str(),
            "https://storage.ghost.io/c/51/fb/51fbb531-c573-4349-a070-603375674d25/content/images/2025/07/favicon.ico"
        )
    }

    #[test]
    fn calc_dimensions_already_small() {
        // Both dimensions <= LOW_RES → returned unchanged
        assert_eq!(FavIconLoader::calc_favicon_dimensions(16, 16), (16, 16));
        assert_eq!(FavIconLoader::calc_favicon_dimensions(1, 1), (1, 1));
        assert_eq!(FavIconLoader::calc_favicon_dimensions(32, 16), (32, 16));
        assert_eq!(FavIconLoader::calc_favicon_dimensions(16, 32), (16, 32));
    }

    #[test]
    fn calc_dimensions_exact_boundary() {
        // Exactly LOW_RES x LOW_RES → returned unchanged
        assert_eq!(FavIconLoader::calc_favicon_dimensions(32, 32), (32, 32));
    }

    #[test]
    fn calc_dimensions_square_downscale() {
        // Square larger than LOW_RES → scaled to 32x32
        assert_eq!(FavIconLoader::calc_favicon_dimensions(64, 64), (32, 32));
        assert_eq!(FavIconLoader::calc_favicon_dimensions(256, 256), (32, 32));
    }

    #[test]
    fn calc_dimensions_landscape() {
        // Wider than tall → width becomes 32, height shrinks proportionally
        assert_eq!(FavIconLoader::calc_favicon_dimensions(128, 64), (32, 16));
        assert_eq!(FavIconLoader::calc_favicon_dimensions(200, 100), (32, 16));
    }

    #[test]
    fn calc_dimensions_portrait() {
        // Taller than wide → height becomes 32, width shrinks proportionally
        assert_eq!(FavIconLoader::calc_favicon_dimensions(64, 128), (16, 32));
        assert_eq!(FavIconLoader::calc_favicon_dimensions(100, 200), (16, 32));
    }

    #[test]
    fn golem() {
        let url = Url::parse("https://golem.de").unwrap();
        let html = std::fs::read_to_string("test-input/golem.html").unwrap();

        let mut scraper = IconScraper::from_html(html, &url).unwrap();
        let best_icon = scraper.best(super::LOW_RES).unwrap();

        assert_eq!(best_icon.url.as_str(), "https://golem.de/assets/icons/favicon.ico");
    }

    #[test]
    fn planet_gnome() {
        let url = Url::parse("http://planet.gnome.org/").unwrap();
        let html = std::fs::read_to_string("test-input/planet_gnome.html").unwrap();

        let mut scraper = IconScraper::from_html(html, &url).unwrap();
        let best_icon = scraper.best(super::LOW_RES).unwrap();

        assert_eq!(best_icon.url.as_str(), "http://planet.gnome.org/img/apple-touch-icon.png");
    }

    #[test]
    fn reddit_scraper() {
        let url = Url::parse("http://reddit.com/").unwrap();
        let html = std::fs::read_to_string("test-input/reddit.html").unwrap();

        let mut scraper = IconScraper::from_html(html, &url).unwrap();
        let best_icon = scraper.best(super::LOW_RES).unwrap();

        assert_eq!(best_icon.url.as_str(), "https://www.redditstatic.com/shreddit/assets/favicon/76x76.png");
    }

    #[test]
    fn reddit_scraper_highres() {
        let url = Url::parse("http://reddit.com/").unwrap();
        let html = std::fs::read_to_string("test-input/reddit.html").unwrap();

        let mut scraper = IconScraper::from_html(html, &url).unwrap();
        let best_icon = scraper.best(super::HIGH_RES).unwrap();

        assert_eq!(best_icon.url.as_str(), "https://www.redditstatic.com/shreddit/assets/favicon/152x152.png");
    }

    #[test]
    fn serienjunkies_scraper() {
        let url = Url::parse("https://www.serienjunkies.de/news/").unwrap();
        let html = std::fs::read_to_string("test-input/serienjunkies.html").unwrap();

        let mut scraper = IconScraper::from_html(html, &url).unwrap();
        let best_icon = scraper.best(super::LOW_RES).unwrap();

        assert_eq!(best_icon.url.as_str(), "https://www.serienjunkies.de/favicon.ico");
    }

    #[test]
    fn spiegel_scraper() {
        let url = Url::parse("http://www.spiegel.de/").unwrap();
        let html = std::fs::read_to_string("test-input/spiegel.html").unwrap();

        let mut scraper = IconScraper::from_html(html, &url).unwrap();
        let best_icon = scraper.best(super::LOW_RES).unwrap();

        assert_eq!(
            best_icon.url.as_str(),
            "https://cdn.prod.www.spiegel.de/public/spon/images/icons/touch-icon.png"
        );
    }
}
